feat(web-shell): add footnote previews and per-turn sources (#11480)

* feat(web-shell): add grouped footnote previews

* fix(web-shell): synchronize footnote type dependency lockfile

* feat(web-shell): complete source footnote previews

* fix(web-shell): stabilize citation footer previews

* fix(web-shell): align citation controls

* fix(web-shell): optically align citation icon

* feat(web-shell): generalize footnotes and expose host icon resolvers

* fix(web-shell): align demo citation artwork with footer icons

* feat(web-shell): add framework-neutral footnote preview content slot

* feat(web-shell): show turn sources independently of footnotes

* test(web-shell): use same-origin footnote thumbnail fixture

* fix(web-shell): preserve source focus and nested footnotes

* test(web-shell): isolate footnote model-list fixtures

* chore: regenerate pnpm lockfile for citation types

* fix(web-shell): reuse committed turn sources while streaming

* docs(web-shell): clarify panel-only source visibility

* fix(web-shell): isolate history sources and restore hover dismissal

* fix(web-shell): preserve single formula text in footnote previews

* fix(web-shell): restore declined footnote pages on return
This commit is contained in:
callmeYe authored and GitHub committed 2026-09-16 03:05:46 +00:00
1 parent c755723286
commit 8aaaaf2524
36 files changed
+5714 -188

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
# Markdown footnote cards and turn sources
[中文](./markdown-footnote-cards.zh-CN.md)
## Problem and decision
Reports cite web pages, files, attachments, knowledge records, and explanatory notes. All resolved Markdown footnotes use the same aggregation mechanism, regardless of ID or content. Numeric, named, Chinese, linked and plain-text notes are supported. The existing knowledge icon remains the default. Hosts can select an image resource for each inline footnote group. The Assistant action footer independently summarizes the current turn’s entries from the Sources panel.
```markdown
Orders follow a shared definition.[^a][^b]
Resource group specifications affect concurrency.[^c]
[^a]: [Order definition](https://example.com/orders 'Knowledge') — Business definition.
[^b]: An explanation without a link.
More detail, preserved in the original definition.
[^c]: [Resource specifications](https://example.com/resources) — Specification details.
```
## Public contract
`WebShellMarkdownCustomization` exposes the optional synchronous, side-effect-free `getInlineFootnoteIcon`. It uses the exported `WebShellFootnoteIconResolver` type and returns the existing `WebShellIconSource` resource URL, or null/undefined for the default. The host needs no React dependency to write these functions.
The function receives a readonly list of `WebShellFootnote` values:
| Field | Meaning |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id: string` | Logical footnote ID as written in its definition, without the message DOM prefix or URL encoding. |
| `number: number` | Footnote number in first-reference order. |
| `definitionMarkdown: string` | Complete definition, including `[^id]:`, multiline content and original URLs. Read directly from the AST source position in the Markdown after `transformMarkdown`. |
| `title?: string` | Text of the first safe link. |
| `summary: string` | Remaining textual content; the whole note for an unlinked definition. |
| `href?: string` | First safe link target, using the existing Markdown URL transformation. |
| `source?: string` | The first safe link's optional title attribute. |
| `image?: string` | First safe thumbnail URL. |
No HAST, DOM or React objects enter the public list. The example produces inline calls with `[a,b]` and `[c]`. Footnotes do not determine the Assistant source count. Each list is deduplicated by ID in first-reference order; distinct IDs sharing a URL remain distinct. Pagination selects content within the group without changing the resolver input. React may render more than once; total callback invocation counts are not guaranteed.
```ts
const markdown = {
getInlineFootnoteIcon: (notes) =>
notes.every((note) => note.href?.startsWith('https://citation.invalid/'))
? '/icons/knowledge.svg'
: '/icons/web.svg',
};
```
Missing callbacks, empty/null results, invalid URLs and thrown exceptions fall back to the default knowledge icon. Custom assets use the composer's monochrome mask and image URL policy (including rejection of SVG data URLs). Inline icons are 16px; footer icons are 14px. Only the default footer knowledge glyph receives the existing 1px optical lift. The host controls only the icon; Qwen owns counts, buttons, hover, keyboard, pagination and links. Host assets must normalize their own viewBox padding, painted size, optical center and stroke weight: equal CSS boxes alone do not imply matching glyphs. The demo footer resource is sized against the Assistant copy glyph; browser acceptance checks painted bounds and visual centroids as well as element geometry.
## Rendering and lifecycle
- Adjacent references, allowing whitespace, form one group within their inline parent. Text, punctuation, block and table-cell boundaries break groups. A single reference also forms a group.
- Pure-text notes have a localized “Footnote n” title, the full explanation and no navigation. Long descriptions are scrollable, including with the keyboard.
- Formula titles and summaries use the TeX annotation from rendered KaTeX once, including in host callback data. The complete definition Markdown and ordinary report formula rendering are preserved.
- Hover, focus and click open the preview. Paging or clicking the trigger pins it until Escape or an outside click. Hover-only previews close after leaving, including after Escape restores focus to the trigger. Restored focus alone does not keep later hover previews open; deliberate keyboard focus and focus inside the preview still do. A page represents one definition.
- The Assistant footer shows “N sources” beside copy, branch and time, using Sources-panel entries explicitly associated with that turn. It follows existing message hover/focus and touch visibility rules. It never substitutes the footnote count, and no separate Markdown footnote summary is rendered.
- Unresolved definitions keep literal references. A definition is removed from the ordinary footer only when every occurrence was converted. References that cannot be converted (for example inside a link) retain their ordinary target and return navigation. Definitions containing nested footnote references also keep the ordinary list, preserving the path to the nested note.
- Definition extraction shares the existing AST pipeline; it does not reparse Markdown. Stable component identities preserve open cards through streaming updates. Message instances keep isolated DOM anchor namespaces.
- Markdown copy and static document export preserve standard footnotes. Custom `components.sup` continues to opt out of aggregation. Advanced-table copy keeps original reference text.
## Current-page content slot
`mountFootnotePreview(container, info)` optionally replaces only the current page's source label, title, description and thumbnail. Qwen retains grouping, trigger icons/counts, popup placement, hover/pin/Escape behavior, keyboard navigation and the pager. This slot serves inline footnote previews; the Sources footer uses its own source list. Omitting it, declining with null/undefined, or synchronously failing restores the default page.
The framework-neutral mount function receives a connected, visible HTML container and `WebShellFootnotePreviewInfo`: the complete readonly `footnotes` list, current `footnote`, zero-based `index`, localized `title`, resolved `sourceLabel`, and `sourceLink` (a Qwen-owned HTMLElement). Metadata remains plain data without AST/React objects. The DOM element is a presentation handle: place it in the custom layout without replacing its children. Qwen renders the current source link into it through the existing `components.a` path, including host interception and ordinary safe-link behavior. Unlinked notes expose a non-navigable title.
Return a `WebShellFootnotePreviewHandle` with synchronous `update(info)` and `dispose()` methods. Mount runs when the popup opens. Paging and streamed data normally invoke update without recreating the host view. Returning to a footnote that this mount function previously declined disposes the active view and retries mount; another decline restores the default page. Declines are tracked by footnote ID within the open popup, reset when the mount function changes, and cleared when a retry succeeds. Closing/unmounting, replacing the mount function or falling back disposes the view. React StrictMode may mount/dispose more than once; each successful mount has one matching cleanup. Hosts should keep the mount function stable, handle their own asynchronous errors, and clean up resources if mounting throws before a handle is returned. Synchronous mount/update/cleanup errors are isolated from the report; changing page/data or the mount function can retry a failed page.
Custom content stays in the shell portal and a bounded scroll region. DOM/SolidJS implementations need no React element factory. A host that mounts Solid must retain its reactive owner as required by Solid and return its update/dispose operations. This slot neither changes `components.sup` opt-out priority nor runs for static document footnotes.
The demo offers default and custom content modes; custom content uses a plain DOM mount, places the Qwen-managed source link in its layout and updates on the built-in pager. Unit and browser checks cover payload isolation, current-page/list consistency, mount/update/dispose, fallback/recovery, preserved link interception, focus/paging, streaming and static export.
A minimal DOM host can use the slot as follows:
```js
const markdown = {
mountFootnotePreview(container, initial) {
const summary = container.ownerDocument.createElement('p');
const update = (info) => {
summary.textContent = info.footnote.summary;
container.replaceChildren(info.sourceLink, summary);
};
update(initial);
return {
update,
dispose() {
container.replaceChildren();
},
};
},
};
```
## Host links and scope
Card titles use the existing host `components.a` renderer. Internal reference/backreference navigation stays built in. A normal HTTP(S) link opens normally. A host may encode private locator fields in a sentinel such as `https://citation.invalid/dataworks-knowledge#...`, validate and resolve it on click, and open its own panel. Web Shell neither interprets these fields nor builds OpenCode business URLs. Without a host resolver, the sentinel remains non-navigable. The demo uses fixed fixtures, ordinary IDs, two different inline asset icons, an independent footer asset and an illustrative host panel.
Aggregation is not limited to sentinel links or any business source format. A footnote records what the report cites, not proof of retrieval or entailment. Session Sources provide the canonical data for the separate turn source control. This change adds no MCP, Core citation protocol, metadata fetches or provider-specific resolver.
## Turn source footer
The footer is a view of the same registered sources and attachment fallback entries used by the Sources panel, filtered to explicit associations with the current user turn. Reusing an existing source in another turn counts in both turns, while each turn deduplicates its entries. Only the final Assistant message of a turn displays the control. An empty or unavailable source set has no control; footnotes never fill the gap.
`environmentPanel.items` controls panel sections only. Omitting both `sources` and `attachments` hides that panel section; the Assistant source footer remains enabled. Its source and attachment metadata still load under the existing capability, session and owner guards. This lets hosts hide the built-in section while using their own Sources panel. The panel configuration is not a global source-feature opt-out.
Frozen paginated history omits the turn source footer, like other turn-level outputs: a page can contain only part of a turn and cannot certify its complete source count. Returning to the live transcript restores the footer and its source-opening action.
Associations come from successful top-level `record_source` records, user-message attachment references, or explicit host `sourceReferences` containing session ID, user-turn ID and registered source ID. The selector intersects references with currently available panel entries, excluding failed/pending/cancelled tool registrations, unknown/deleted sources and unrelated turns. Source IDs from tool output are preferred; normalized locators are a fallback for forked histories whose source IDs were rehashed. Workspace-file fallback requires matching workspace roots. No creation/update timestamp or snapshot-revision inference is used. Manual/hook/client registrations without a turn reference remain session-wide panel entries; this change introduces no Core persistence protocol or claim of automatic actual-use tracking.
At the Web Shell customization level, `getAssistantSourcesIcon` receives the complete readonly `WebShellSource` list for the turn and returns a resource URL, with the same default/URL policy as other host icons. `WebShellSource` distinguishes registered `SessionSource` entries and unregistered attachment entries already visible in the panel. This replaces `markdown.getAssistantFootnoteIcon`; it is intentionally separate from Markdown customization.
Hover/focus/click opens a scrollable read-only source list. Selecting a row follows the existing Sources-panel preview action. The footer cannot add, edit or remove sources. Embedded read-only transcripts receive sources, attachment entries, session identity, explicit source references and an open callback from their host. Session/workspace owner guards and existing source preview rules remain authoritative; a foreign or stale source set must never be borrowed to populate a turn.
Tail-only streaming text updates reuse the last committed source associations, preserving completed turns’ source-list identities. Inventory, host references, session/workspace changes and structural transcript updates recompute the associations. Publishing the cache after commit prevents StrictMode replays or abandoned renders from replacing the committed value.
Acceptance covers source counts independent of footnote counts, cross-turn reuse, attachment deduplication, failed/unknown/deleted registrations, fork-locator fallback, explicit host associations, owner isolation, source preview handoff, independent icon customization, hover/focus and ordinary footnote behavior.
Host configuration example (the user-message ID identifies the turn; it is not the daemon prompt ID):
```js
const shellOptions = {
getAssistantSourcesIcon: (entries) =>
entries.every(
(entry) => entry.type === 'source' && entry.source.kind === 'link',
)
? '/icons/web-sources.svg'
: '/icons/sources.svg',
sourceReferences: [
{
sessionId: 'session-id',
turnId: 'user-message-id',
sourceId: 'registered-source-id',
},
],
markdown: {
getInlineFootnoteIcon: () => '/icons/footnote.svg',
},
};
```
For `WebShellTranscript`, provide `sources`, optional `sourceAttachments`, `sourceSessionId` and `onSourceOpen` as well: a read-only transcript has no live Sources-panel loader. The primary App uses its existing guarded panel state and preview callbacks. Attachment inventory refreshes independently of panel visibility, retaining owner checks and streaming throttling, so unregistered attachments also appear without opening the panel. Missing source data stays empty, including other panes that do not supply their own source state; the primary session's entries are never used as a fallback for another pane.
## Implementation and verification
Changes are limited to Web Shell customization types/exports, the existing Markdown AST transform, footnote cards, shared Sources lists and attachment loading, Assistant footer wiring, demo and tests. There are no open design questions.
Focused tests verify IDs, multiline original text after transforms, safe parsed fields, exact footnote groups and per-turn source lists, same-URL distinct notes, callback independence/fallback, default/custom sizing, streaming, incomplete definitions, partial conversion, host links, copy and static export. Development and production browser E2E cover grouping, scrollable descriptions, paging persistence, message hover, keyboard/touch, themes, viewport/portal boundaries and cross-message isolation. The development demo additionally verifies custom SVG assets and host panel handoff; production acceptance uses the built app. Build and typecheck must pass.
@@ -0,0 +1,150 @@
# Markdown 脚注卡片与本轮来源
[English](./markdown-footnote-cards.md)
## 问题与方案
报告引用网页、文件、附件、知识库记录,也可能包含纯文字说明。所有可解析的标准 Markdown 脚注均使用同一套聚合机制,不要求特殊 ID 或内容格式。数字、命名、中文 ID,以及无链接、无加粗的脚注均可聚合。保留当前默认知识图标,宿主通过资源 URL 定制正文脚注图标;Assistant 操作栏独立汇总来源面板中属于当前轮次的条目。
```markdown
订单遵循统一口径。[^a][^b]
资源组影响并发。[^c]
[^a]: [订单定义](https://example.com/orders '知识库') — 业务定义。
[^b]: 这里是一条没有链接的补充说明。
第二段说明也完整保留。
[^c]: [资源组规格](https://example.com/resources) — 规格说明。
```
## 公开接口
`WebShellMarkdownCustomization` 提供可选的 `getInlineFootnoteIcon`,使用导出的 `WebShellFootnoteIconResolver` 类型,必须同步、无副作用,返回现有 `WebShellIconSource` 资源 URL 或 null/undefined。SolidJS 宿主编写普通 JavaScript 函数即可,无须 React 依赖或挂载桥接。
每个函数收到只读的 `WebShellFootnote` 列表:
| 字段 | 含义 |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `id: string` | definition 中写出的脚注逻辑标识,不包含消息 DOM 前缀或 URL 编码。 |
| `number: number` | 按首次引用顺序得到的脚注编号。 |
| `definitionMarkdown: string` | 包含 `[^id]:`、多行内容和原始链接的完整 definition;按 AST 位置直接截取 `transformMarkdown` 后实际渲染的 Markdown。 |
| `title?: string` | 首个安全链接的文字。 |
| `summary: string` | 其余文字;没有链接时为完整脚注说明。 |
| `href?: string` | 首个安全链接的地址,沿用现有 Markdown URL 转换。 |
| `source?: string` | 首个安全链接的可选 title 属性。 |
| `image?: string` | 首张安全缩略图的地址。 |
公开列表不含 HAST、DOM 或 React 对象。示例中正文分别收到 `[a,b]`、`[c]`;脚注不决定 Assistant 操作栏的来源数量。每组均按首次引用顺序、脚注 ID 去重;不同 ID 即使 URL 相同也不合并。翻页不改变图标判断函数的完整列表。React 可能重复渲染,不承诺生命周期内函数总共只执行两次。
```ts
const markdown = {
getInlineFootnoteIcon: (notes) =>
notes.every((note) => note.href?.startsWith('https://citation.invalid/'))
? '/icons/knowledge.svg'
: '/icons/web.svg',
};
```
未传入函数、返回空值、无效地址或抛出异常,均回退到默认知识图标。自定义图标沿用输入框标签的单色 mask 和图片 URL 策略(包括拒绝 SVG data URL)。正文 16px,操作栏 14px;只有默认操作栏知识图标上移 1px,自定义图标正常居中。宿主仅决定图标,数量、按钮、Hover、翻页、键盘与点击行为由 Qwen 负责。宿主资源需自行统一 viewBox 留白、实际绘制尺寸、视觉重心和线宽;CSS 容器等大不代表图形等大。Demo 操作栏资源以 Assistant 复制图形为参照,浏览器验收同时检查实绘边界、视觉重心和元素几何位置。
## 展示与生命周期
- 同一行内父节点中,相邻脚注允许空白间隔并聚合;正文、标点、块和表格单元格边界中断聚合。单个脚注也构成一组。
- 纯文本脚注使用“脚注 n”作为标题,完整说明可滚动查看并支持键盘滚动;无链接则无跳转。
- 标题和摘要中的公式只提取一次 KaTeX 的 TeX annotation,宿主回调收到同样的数据。完整脚注 Markdown 和报告中的公式渲染保持原样。
- Hover、聚焦或点击可打开预览。点击分页器或触发器后卡片保持打开,Escape 或外部点击关闭;仅 Hover 打开的卡片移出后关闭,Escape 归还焦点后再次 Hover 也遵循该规则。自动归还的焦点不会让后续 Hover 卡片保持打开;用户主动键盘聚焦和卡片内部焦点仍受保护。一个 definition 对应一页。
- Assistant 操作栏在复制、分支和时间旁显示“N 个来源”,取来源面板中明确关联到本轮的条目;沿用消息 Hover/聚焦和触屏展示规则。不以脚注数量兜底,也不再渲染独立 Markdown 脚注汇总入口。
- 缺失 definition 时保留引用原文。只有所有引用均已转换的 definition 才从普通文末列表移除;不能转换的引用(例如链接内脚注)仍有有效文末目标与返回正文链接。定义内包含其他脚注引用时也保留普通文末列表,确保仍可访问嵌套脚注。
- 在现有 AST 管线中提取原文,不二次解析 Markdown。保持稳定的组件身份,流式更新不重挂已打开的卡片;消息之间的 DOM 锚点互相隔离。
- 复制 Markdown 和静态文档导出保留标准脚注。宿主自定义 `components.sup` 时继续退出聚合,表格复制保留原脚注编号。
## 当前页内容插槽
可选的 `mountFootnotePreview(container, info)` 只替换当前页的来源标签、标题、说明和缩略图。聚合、入口图标/数量、浮层定位、Hover/保持打开/Escape、键盘导航和分页器继续由 Qwen 管理。此插槽用于正文脚注卡片;操作栏来源使用独立的来源列表。不配置、返回 null/undefined 或同步执行失败时使用默认内容。
框架无关的挂载函数接收已连接且可布局的独立 HTML 容器和 `WebShellFootnotePreviewInfo`:完整只读 `footnotes` 列表、当前 `footnote`、从零开始的 `index`、本地化 `title`、已解析的 `sourceLabel`,以及 Qwen 管理的 HTMLElement `sourceLink`。元数据仍不含 AST/React 对象;DOM 元素属于展示句柄,宿主可将其放入布局,但不要替换其子节点。Qwen 通过原有 `components.a` 在该元素内渲染当前来源链接,保留宿主接管和普通安全链接行为。纯文本脚注提供不可跳转的标题。
挂载成功后返回 `WebShellFootnotePreviewHandle`,包含同步的 `update(info)` 与 `dispose()`。浮层打开时挂载,普通翻页/流式数据变化时更新而不重建宿主视图。返回当前挂载函数曾拒绝的脚注时,先清理活动视图并重新尝试挂载;再次拒绝则恢复默认页。拒绝记录按脚注 ID 保存于本次打开的浮层中,更换挂载函数时重置,重试成功后清除。关闭/卸载、替换挂载函数或回退时清理。React StrictMode 可能多次挂载/清理,每次成功挂载对应一次清理。宿主应保持挂载函数引用稳定,自行处理异步错误,并在挂载尚未返回句柄就抛错时清理已申请的资源。同步 mount/update/cleanup 错误不会破坏报告;切换页面/数据或更换挂载函数后可以重试。
自定义内容保留在 Web Shell Portal 及有高度边界的滚动区域内。原生 DOM/SolidJS 接入不需要创建 React 元素;Solid 宿主按自身需要保留响应式 owner,返回更新和清理方法。此插槽不改变 `components.sup` 退出聚合的优先级,也不用于静态文档脚注。
Demo 提供默认/自定义内容两种模式:自定义内容用原生 DOM 挂载,将 Qwen 管理的来源链接放进布局,使用内建分页器更新。单测与浏览器验收覆盖数据隔离、当前页/完整列表、挂载/更新/清理、回退/恢复、来源接管、焦点/翻页、流式更新与静态导出。
最小原生 DOM 接入示例:
```js
const markdown = {
mountFootnotePreview(container, initial) {
const summary = container.ownerDocument.createElement('p');
const update = (info) => {
summary.textContent = info.footnote.summary;
container.replaceChildren(info.sourceLink, summary);
};
update(initial);
return {
update,
dispose() {
container.replaceChildren();
},
};
},
};
```
## 宿主链接与范围
卡片标题继续通过宿主现有 `components.a` 渲染。内部脚注和返回正文链接沿用内建导航。普通 HTTP(S) 链接正常打开;宿主也可使用 `https://citation.invalid/dataworks-knowledge#...` 等 sentinel 携带私有定位字段,点击后校验、解析并打开自己的面板。Web Shell 不解析业务字段,也不拼接 OpenCode 业务 URL。没有宿主解析器时 sentinel 不可导航。Demo 使用固定数据、普通脚注 ID、两种正文资源图标、独立操作栏资源图标和示意右侧面板。
聚合不依赖 sentinel 或业务来源格式。脚注表达报告声称引用的内容,不证明执行过检索或结论已获证据支持。Session Sources 是本轮来源入口的权威数据,同时保留完整会话来源目录。本次不增加 MCP、Core 引用协议、网页元数据请求或业务 URL 解析器。
## 本轮来源操作栏
操作栏复用来源面板的已登记来源及附件兜底条目,只展示与当前用户轮次明确关联的项。旧来源在另一轮再次关联时,两轮都计入;每轮内部去重。入口只出现在该轮最终 Assistant 消息的操作栏。来源为空或不可用时不显示入口,不以脚注数量代替。
`environmentPanel.items` 只控制面板分区。省略 `sources` 和 `attachments` 时隐藏对应分区,Assistant 操作栏来源仍然启用;所需来源和附件元数据继续按现有能力、会话及 owner 校验加载。宿主因此可以隐藏内置分区并使用自己的来源面板。该面板配置不是全局关闭来源能力的开关。
冻结的分页历史视图与其他按轮输出一样,不展示操作栏来源:单页可能只包含一轮的部分记录,无法给出完整来源数量。返回实时对话后恢复该入口及来源打开行为。
关联来自成功的顶层 `record_source` 记录、用户消息附件引用,以及宿主显式提供的 `sourceReferences`(会话 ID、用户轮次 ID、已登记来源 ID)。选择器与当前来源面板条目求交集,排除失败/进行中/取消的登记、未知或已删除来源及其他轮次。优先匹配工具返回的来源 ID;fork 导致来源 ID 重新计算时,用规范化 locator 兜底,工作区文件还必须匹配工作区根。禁止用创建/更新时间或快照 revision 推测关联。手动、hook、client 仅登记到会话、却没有轮次关联的项仍只属于完整来源面板;本次不新增 Core 持久化协议,也不宣称自动追踪全部实际使用行为。
Web Shell 顶层的 `getAssistantSourcesIcon` 接收本轮完整只读 `WebShellSource` 列表,返回资源 URL,沿用现有默认图标和 URL 策略。`WebShellSource` 区分已登记 `SessionSource` 和来源面板中已有的未登记附件条目。它替换 `markdown.getAssistantFootnoteIcon`,与 Markdown 定制分开。
Hover/聚焦/点击展开可滚动的只读来源列表,点击条目复用来源面板的预览动作;不在操作栏增加新增/编辑/删除功能。嵌入式只读 transcript 由宿主传入来源、附件、会话身份、显式轮次关联和打开回调。会话/工作区 owner 与原有预览校验继续生效,不能借用其他会话或过期的来源集合。
仅末尾文本变化的流式更新复用上次已提交的来源关联,保持已完成轮次的来源列表引用稳定。来源清单、宿主显式关联、会话/工作区或会话结构变化时重新计算。缓存仅在提交后发布,避免 StrictMode 重复渲染或被中止的渲染替换已提交值。
验收覆盖来源数量与脚注数量独立、跨轮复用、附件去重、失败/未知/已删除登记、fork locator 兜底、宿主显式关联、owner 隔离、来源预览接管、独立图标定制、Hover/聚焦及正文脚注回归。
宿主配置示例(`turnId` 是用户消息 ID,不是 daemon 的 prompt ID):
```js
const shellOptions = {
getAssistantSourcesIcon: (entries) =>
entries.every(
(entry) => entry.type === 'source' && entry.source.kind === 'link',
)
? '/icons/web-sources.svg'
: '/icons/sources.svg',
sourceReferences: [
{
sessionId: 'session-id',
turnId: 'user-message-id',
sourceId: 'registered-source-id',
},
],
markdown: {
getInlineFootnoteIcon: () => '/icons/footnote.svg',
},
};
```
使用 `WebShellTranscript` 时还需提供 `sources`、可选 `sourceAttachments`、`sourceSessionId` 和 `onSourceOpen`,因为只读 transcript 没有在线来源面板加载器。主 App 复用已有受 owner 保护的面板状态和预览回调。附件目录的刷新不依赖面板是否打开,同时保留 owner 校验和流式节流,因此未登记的附件也能直接显示。缺少来源数据时保持为空;未提供自身来源状态的其他面板也不会借用主会话条目作为兜底。
## 实现与验收
修改范围为 Web Shell 公开类型/导出、现有 Markdown AST 转换、脚注卡片、共用来源列表与附件加载、Assistant 操作栏接线、Demo 和测试。没有待定设计问题。
定向测试覆盖各种 ID、多行原文及 transform 后内容、安全字段、脚注分组与本轮来源完整列表、同 URL 不合并、两个函数独立与回退、默认/自定义尺寸、流式更新、缺失 definition、部分转换、宿主链接、复制及静态导出。开发和生产浏览器 E2E 覆盖聚合、长说明滚动、翻页保持打开、消息 Hover、键盘/触屏、主题、视口/Portal 和跨消息隔离。开发 Demo 额外验证自定义 SVG 资源和宿主面板,生产验收使用构建产物。完成构建和类型检查。
+10
View File
@@ -31227,6 +31227,7 @@
"@playwright/test": "1.61.1",
"@qwen-code/sdk": "file:../sdk-typescript",
"@tailwindcss/vite": "^4.3.2",
"@types/hast": "^3.0.5",
"@types/node": "^22.0.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.0.0",
@@ -31737,6 +31738,15 @@
"@types/hast": "^3.0.4"
}
},
"packages/web-shell/node_modules/@types/hast": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
"integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"packages/web-shell/node_modules/@types/node": {
"version": "22.19.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz",
+27
View File
@@ -26846,6 +26846,33 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
@types/[email protected]
(https://github.com/DefinitelyTyped/DefinitelyTyped.git)
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
============================================================
[email protected]
(git+https://github.com/slevithan/oniguruma-to-es.git)
+64 -12
View File
@@ -1,4 +1,6 @@
import './styles/globals.css';
import { getSourceEntries } from './components/sources/sourceEntries';
import { openSourceEntry } from './components/panels/SourcesSection';
import { isSessionWriterBlockedCode } from './daemon/session/session-context';
import { TurnNotificationNavigationContext } from './daemon/session/turn-notification-context';
import { useBrowserNotificationSettings } from './browser-turn-notifications';
@@ -397,6 +399,9 @@ import {
type WebShellComposerApi,
type WebShellComposerInput,
type WebShellMarkdownCustomization,
type WebShellSource,
type WebShellSourceIconResolver,
type WebShellSourceReference,
type ToolHeaderExtraRenderer,
type UserMessageContentRenderer,
type UserMessageContentParser,
@@ -1338,6 +1343,8 @@ export interface WebShellProps {
onComposerTagClick?: ComposerTagClickHandler;
/** Custom renderer displayed after the final assistant message of each turn. */
renderAssistantTurnFooter?: AssistantTurnFooterRenderer;
getAssistantSourcesIcon?: WebShellSourceIconResolver;
sourceReferences?: readonly WebShellSourceReference[];
/** Custom renderer inserted before the built-in chat composer toolbar controls. */
renderComposerToolbarStart?: ComposerToolbarStartRenderer;
/** Custom renderer inserted after the built-in composer toolbar controls. */
@@ -3023,6 +3030,8 @@ export function App({
renderComposerTagTooltip,
onComposerTagClick,
renderAssistantTurnFooter,
getAssistantSourcesIcon,
sourceReferences,
renderComposerToolbarStart,
renderComposerToolbarEnd,
renderComposerToolbarRight,
@@ -3290,6 +3299,8 @@ export function App({
renderComposerTagTooltip,
onComposerTagClick,
renderAssistantTurnFooter,
getAssistantSourcesIcon,
sourceReferences,
renderComposerToolbarStart,
renderComposerToolbarEnd,
renderComposerToolbarRight,
@@ -3320,6 +3331,8 @@ export function App({
renderComposerTagTooltip,
onComposerTagClick,
renderAssistantTurnFooter,
getAssistantSourcesIcon,
sourceReferences,
renderComposerToolbarStart,
renderComposerToolbarEnd,
renderComposerToolbarRight,
@@ -4409,29 +4422,22 @@ export function App({
const sessionAttachmentsRequestIdRef = useRef(0);
const attachmentRetryCountRef = useRef(new Map<string, number>());
const [attachmentRefreshNonce, setAttachmentRefreshNonce] = useState(0);
// Uploaded sources come from the daemon attachment store. Refresh on
// transcript updates while the panel is open, throttled during streaming.
// The source panel and turn footers share the attachment inventory.
// Refresh on transcript updates, throttled during streaming.
const transcriptRevision = blockChangeSummary?.revision ?? 0;
const sessionAttachmentsRequestEligibleRef = useRef(false);
sessionAttachmentsRequestEligibleRef.current =
environmentPanelReachable &&
environmentSourcesEnabled &&
environmentPanelOpen &&
connection.status === 'connected' &&
Boolean(connection.sessionId && logicalSessionKey) &&
connection.capabilities?.features.includes(
SESSION_ATTACHMENT_LIST_FEATURE,
) === true;
useEffect(() => {
const attachmentsSectionEnabled =
environmentPanelReachable && environmentSourcesEnabled;
const attachmentsSupported =
connection.capabilities?.features.includes(
SESSION_ATTACHMENT_LIST_FEATURE,
) === true;
if (
!attachmentsSectionEnabled ||
!environmentPanelOpen ||
connection.status !== 'connected' ||
!connection.sessionId ||
!logicalSessionKey
@@ -4544,13 +4550,10 @@ export function App({
connection.status,
attachmentRefreshNonce,
environmentPanelOpen,
environmentPanelReachable,
environmentPanelItems,
logicalSessionKey,
transcriptRevision,
sessionActions,
sessionAttachmentsOwner,
environmentSourcesEnabled,
t,
]);
const artifactPanelOpenRef = useRef(artifactPanelOpen);
@@ -17738,6 +17741,52 @@ export function App({
onToggleFullscreen: toggleArtifactPanelFullscreen,
};
const environmentPanelOwner = sessionOwnerGuard.capture();
const panelAttachments = logicalSessionKey
? sessionAttachmentsBySessionRef.current.get(logicalSessionKey)
: undefined;
const sourceEntries = useMemo(
() =>
getSourceEntries(
sourcesState.supported ? sourcesState.sources : [],
panelAttachments ?? [],
),
[sourcesState.supported, sourcesState.sources, panelAttachments],
);
const openTurnSource = useCallback(
(entry: WebShellSource) => {
const owner = sourcesState.owner;
if (!owner.isCurrent()) return;
openSourceEntry(entry, {
onOpen: openSourcePanel,
onReadImage: readSessionImage,
onImagePreview: (src, alt, source) => {
if (owner.isCurrent()) openImagePanel(src, alt, source);
},
onAttachmentPreview: (file) => {
if (owner.isCurrent())
openAttachmentPanel(file, undefined, undefined, true);
},
onAttachmentPreviewError: (error) => {
if (owner.isCurrent())
pushToast(
'error',
t('rightPanel.attachmentLoadFailed', {
error: formatError(error, t('environment.unavailable')),
}),
);
},
});
},
[
sourcesState.owner,
openSourcePanel,
readSessionImage,
openImagePanel,
openAttachmentPanel,
pushToast,
t,
],
);
// BrandProvider sits above I18nProvider so portals and every pane see it. The
// prettier-ignore keeps adding it from re-indenting the whole subtree, the
@@ -19366,6 +19415,9 @@ export function App({
? fileChangesByTurn
: undefined
}
sourceEntries={sourceEntries}
sourceSessionId={connection.sessionId}
onSourceOpen={openTurnSource}
turnArtifacts={
visibleTurnOutputKinds.has('artifact')
? artifactsByTurn
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_88_06697"><rect x="-0.6000000238418579" y="-0.6000000238418579" width="17.200000047683716" height="17.200000047683716" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_88_06697)"><path d="M1.20038491859,11.005953759375C1.200149609725,11.755224959375,1.19997104636,12.323807259375,1.20071147982,13.598170759375L1.7394445525,13.585617059375Q3.9948006125,13.518669159375001,5.4713478125,14.043804659374999Q6.1284523125,14.277577359375,8.0002565125,15.194705459375001Q9.8718824125,14.277411959375,10.5291614125,14.043804659374999Q12.0057103125,13.518668659374999,14.2610673125,13.585617059375L14.7998063125,13.598170759375C14.8003403125,12.329537659375,14.8002063125,11.760353359375,14.8000303125,11.016051759375C14.7999253125,10.569817459375,14.7998043125,10.060638759375,14.7998043125,9.300048829375L13.7536623125,8.820068359375C13.7536623125,10.493802659375,13.7537253125,10.857962859375,13.7537253125,12.531697059375Q11.6181373125,12.545812359375,10.1787252125,13.057869459375Q9.5245581125,13.290340859375,7.9999938125,13.976203459375Q6.4757614125,13.290275059375,5.8217845125,13.057869459375Q4.2782967124999995,12.545818759374999,2.2468024125,12.531697059375C2.2468024125,10.857961859375,2.2468257124999997,10.493803059375,2.2468262125000003,8.820068359375L1.20068359375,9.300048829375C1.20068359497,10.054907359375001,1.2005243017,10.562129459375,1.20038491859,11.005953759375Z" fill="#1E2939" fill-opacity="1"/><path d="M8.0222616125,15.239603559375Q9.8913174125,14.323569259374999,10.5459070125,14.090917559375Q12.0136163125,13.568925859375,14.2595853125,13.635595359375L14.8498003125,13.649349659375L14.8501273125,11.449118359375L14.8498003125,9.267975629375L13.7036623125,8.742115751375L13.7036943125,10.675883659375L13.7037253125,12.482094559375Q11.5925523125,12.501844859375,10.1619673125,13.010761759375Q9.5103197125,13.242337259374999,7.9999971125,13.921378659375Q6.4900050125,13.242273359375,5.8385277125,13.010756059375Q4.305463512499999,12.502152159375001,2.2968029125,12.482118559375L2.2968143125,10.675882259375L2.2968267124999997,8.742116131375L1.1506899785,9.267974909375L1.1502554045,11.449101659375L1.1507223435,13.649349659375L1.7406094025,13.635603459375Q3.9868958125,13.568926359375,5.4545936125,14.090914259375001Q6.1090097125,14.323730459375,7.9782572125,15.239605859375L8.000259912499999,15.250386759375L8.0222616125,15.239603559375ZM14.2625513125,13.535639259375Q11.9978063125,13.468411959375,10.5124082125,13.996695059375Q9.8563299125,14.229875059375,8.0002527125,15.139029059375Q6.1440143125,14.230044359375,5.4881029125,13.996695059375Q4.0027058125,13.468412359375,1.7382797625,13.535631159375L1.2507006225,13.546992759375L1.2502554095,11.449121459375L1.2506772095,9.332122799375L2.1968257424999997,8.898020587375L2.1968141825,10.675881659375L2.1968022025,12.581350759374999L2.2464547125000003,12.581696059375Q4.2700498125,12.595762759374999,5.8050418125,13.104983359375Q6.4571004125,13.336706659375,7.9794750125,14.021799559375001L7.9999914125,14.031032059375L8.0205078125,14.021801959375Q9.5432167125,13.336773859375,10.1954841125,13.104977559375001Q11.6269263125,12.595755859375,13.7540563125,12.581696259375L13.8037273125,12.581367959375001L13.8036943125,10.675881959375L13.8036633125,8.898020871375L14.7498103125,9.332122269375L14.7501273125,11.449103559375L14.7498153125,13.546992759375L14.2625513125,13.535639259375Z" fill-rule="evenodd" fill="#0A0A0B" fill-opacity="1"/><ellipse cx="3.5201416015625" cy="3.099877953529358" rx="1.699999988079071" ry="1.699999988079071" fill-opacity="0" stroke-opacity="1" stroke="#0A0A0B" fill="none" stroke-width="1.2000000476837158"/><ellipse cx="12.96029782295227" cy="4.859980583190918" rx="0.9799999594688416" ry="0.9800000786781311" fill-opacity="0" stroke-opacity="1" stroke="#0A0A0B" fill="none" stroke-width="1.2000000476837158"/><ellipse cx="7.999868273735046" cy="9.339980602264404" rx="1.4600000977516174" ry="1.4600000977516174" fill-opacity="0" stroke-opacity="1" stroke="#0A0A0B" fill="none" stroke-width="1.2000000476837158"/><g transform="matrix(0.5843047499656677,0.8115343451499939,-0.8115343451499939,0.5843047499656677,5.653669123084079,-1.7452428851323916)"><path d="M8.7112484078125,5.245571191654358L4.5535922608125,5.406567271654358L4.5071596928125,4.207465971654358L8.6648164078125,4.046469871654358L8.7112484078125,5.245571191654358Z" fill-rule="evenodd" fill="#0A0A0B" fill-opacity="1"/></g><g transform="matrix(-0.7875048518180847,0.6163084506988525,-0.6163084506988525,-0.7875048518180847,25.894701241068248,2.803825883880897)"><path d="M16.6606488578125,6.463703754779358L12.5163326598125,6.826633394779358L12.4116458558125,5.631208434779358L16.5559620578125,5.268278714779358L16.6606488578125,6.463703754779358Z" fill-rule="evenodd" fill="#0A0A0B" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -13,7 +13,10 @@ import type {
TodoItem,
} from '../adapters/types';
import { CompactModeContext } from '../WebShellContexts';
import type { WebShellAssistantTurnFooterRenderInfo } from '../customization';
import type {
WebShellAssistantTurnFooterRenderInfo,
WebShellSource,
} from '../customization';
import { useI18n } from '../i18n';
import { ErrorBoundary } from './ErrorBoundary';
import { MessageTimestamp } from './MessageTimestamp';
@@ -72,6 +75,8 @@ interface MessageItemProps {
showAssistantBranch?: boolean;
isLocateFlashing?: boolean;
assistantTurnFooterInfo?: WebShellAssistantTurnFooterRenderInfo;
turnSources?: readonly WebShellSource[];
onSourceOpen?: (source: WebShellSource) => void;
generateContent?: SessionContentGenerator;
}
@@ -96,6 +101,8 @@ export const MessageItem = memo(function MessageItem({
showAssistantBranch = false,
isLocateFlashing = false,
assistantTurnFooterInfo,
turnSources,
onSourceOpen,
generateContent,
}: MessageItemProps) {
const { t } = useI18n();
@@ -179,6 +186,8 @@ export const MessageItem = memo(function MessageItem({
showBranchAction={showAssistantBranch}
isLocateFlashing={isLocateFlashing}
customFooterInfo={assistantTurnFooterInfo}
turnSources={turnSources}
onSourceOpen={onSourceOpen}
/>
);
case 'thinking':
@@ -419,6 +428,11 @@ function areMessageItemPropsEqual(
if (prev.showAssistantBranch !== next.showAssistantBranch) return false;
if (prev.isLocateFlashing !== next.isLocateFlashing) return false;
if (prev.generateContent !== next.generateContent) return false;
if (
prev.turnSources !== next.turnSources ||
prev.onSourceOpen !== next.onSourceOpen
)
return false;
if (
!areAssistantTurnFooterInfosEqual(
prev.assistantTurnFooterInfo,
@@ -1,3 +1,4 @@
import { getSourcesByTurn } from './sources/sourceEntries';
import {
forwardRef,
memo,
@@ -44,6 +45,7 @@ import { CompactModeContext } from '../WebShellContexts';
import {
useWebShellCustomization,
type WebShellAssistantTurnFooterRenderInfo,
type WebShellSource,
} from '../customization';
import { useI18n } from '../i18n';
import { formatContextTokens } from '../utils/formatTokenCount';
@@ -173,6 +175,9 @@ export interface MessageListProps {
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
turnFileChanges?: ReadonlyMap<string, readonly TurnOutputFileChange[]>;
turnArtifacts?: ReadonlyMap<string, readonly DaemonSessionArtifact[]>;
sourceEntries?: readonly WebShellSource[];
sourceSessionId?: string;
onSourceOpen?: (source: WebShellSource) => void;
turnScheduledTasks?: ReadonlyMap<string, readonly TurnOutputScheduledTask[]>;
onReviewChanges?: (
changes: readonly TurnOutputFileChange[],
@@ -2957,6 +2962,9 @@ export const MessageList = memo(
onCanScrollToBottomChange,
turnFileChanges,
turnArtifacts,
sourceEntries,
sourceSessionId,
onSourceOpen,
turnScheduledTasks,
onReviewChanges,
onOpenArtifact,
@@ -3475,7 +3483,66 @@ export const MessageList = memo(
// (collapsed once complete). `displayItems` stays the full, pre-collapse
// list — used only to locate rows hidden inside a collapsed turn — while
// `visibleItems` is what actually renders.
const { collapseCompletedTurns } = useWebShellCustomization();
const { collapseCompletedTurns, sourceReferences } =
useWebShellCustomization();
const sourcesByTurnCache = useRef<
| {
sourceMessages: readonly Message[];
dependencies: readonly unknown[];
value: ReadonlyMap<string, readonly WebShellSource[]>;
}
| undefined
>(undefined);
const sourcesByTurn = useMemo(() => {
const dependencies = [
sourceEntries,
workspaceCwd,
sourceSessionId,
sourceReferences,
] as const;
const cached = sourcesByTurnCache.current;
return streamingTailContentOnly &&
isResponding &&
cached &&
cached.sourceMessages === previousMessagesRef.current &&
sameIdentities(cached.dependencies, dependencies)
? cached.value
: getSourcesByTurn(
messages,
sourceEntries ?? [],
workspaceCwd,
sourceSessionId,
sourceReferences,
);
}, [
messages,
sourceEntries,
workspaceCwd,
sourceSessionId,
sourceReferences,
streamingTailContentOnly,
isResponding,
]);
useLayoutEffect(() => {
// Keep StrictMode replays and abandoned renders out of the cache.
sourcesByTurnCache.current = {
sourceMessages: messages,
dependencies: [
sourceEntries,
workspaceCwd,
sourceSessionId,
sourceReferences,
],
value: sourcesByTurn,
};
}, [
messages,
sourceEntries,
workspaceCwd,
sourceSessionId,
sourceReferences,
sourcesByTurn,
]);
const collapseEnabled = collapseCompletedTurns ?? true;
const [collapseOverrides, setCollapseOverrides] = useState<
ReadonlyMap<string, boolean>
@@ -5615,6 +5682,12 @@ export const MessageList = memo(
flashTarget,
)}
assistantTurnFooterInfo={assistantTurnFooterInfo}
turnSources={
finalAssistantTurnId
? sourcesByTurn.get(finalAssistantTurnId)
: undefined
}
onSourceOpen={onSourceOpen}
generateContent={generateContent}
/>
);
@@ -5676,6 +5749,8 @@ export const MessageList = memo(
onOpenScheduledTask,
onReviewChanges,
onTurnOutputOpen,
sourcesByTurn,
onSourceOpen,
onError,
],
);
@@ -141,6 +141,21 @@ async function setup(supported = true, cursorOnly = false, turnCount = 4) {
onReloadTranscript: vi.fn(),
onLoadOlderHistory: legacyLoad,
hasOlderHistory: true,
sourceEntries: [
{
type: 'source',
source: {
id: 'source',
title: 'Source',
kind: 'link',
locator: { type: 'url', url: 'https://example.com/source' },
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
},
},
],
sourceSessionId: 'session',
onSourceOpen: vi.fn(),
};
act(() => root!.render(<TranscriptViewport {...props} ref={ref} />));
const click = async (key: string) => {
@@ -269,6 +284,9 @@ describe('TranscriptViewport', () => {
it('switches only visible rows, disables historical mutations and returns live through the forwarded handle', async () => {
const { props, ref, click } = await setup();
expect(observed.props?.messages).toBe(live);
expect(observed.props?.sourceEntries).toBe(props.sourceEntries);
expect(observed.props?.sourceSessionId).toBe('session');
expect(observed.props?.onSourceOpen).toBe(props.onSourceOpen);
await click('history.openEarlier');
expect(
container
@@ -285,6 +303,9 @@ describe('TranscriptViewport', () => {
expect(observed.props?.onBranchSession).toBeUndefined();
expect(observed.props?.onRetryClick).toBeUndefined();
expect(observed.props?.onReloadTranscript).toBeUndefined();
expect(observed.props?.sourceEntries).toBeUndefined();
expect(observed.props?.sourceSessionId).toBeUndefined();
expect(observed.props?.onSourceOpen).toBeUndefined();
const historical = observed.props?.messages;
const nextLive: Message[] = [
...live,
@@ -299,6 +320,9 @@ describe('TranscriptViewport', () => {
act(() => ref.current?.scrollToBottom());
expect(observed.props?.messages).toBe(nextLive);
expect(observed.props?.onEditUserMessage).toBe(props.onEditUserMessage);
expect(observed.props?.sourceEntries).toBe(props.sourceEntries);
expect(observed.props?.sourceSessionId).toBe('session');
expect(observed.props?.onSourceOpen).toBe(props.onSourceOpen);
});
it('preserves the original loader with turn navigation enabled', async () => {
@@ -528,6 +528,9 @@ export const TranscriptViewport = forwardRef<
welcomeHeader: undefined,
activeTurnStartedAt: undefined,
turnFileChanges: undefined,
sourceEntries: undefined,
sourceSessionId: undefined,
onSourceOpen: undefined,
turnArtifacts: undefined,
turnScheduledTasks: undefined,
generateContent: undefined,
@@ -1,3 +1,4 @@
import { getSourceEntries } from './sources/sourceEntries';
import '../styles/globals.css';
import 'katex/dist/katex.min.css';
import {
@@ -8,7 +9,11 @@ import {
type CSSProperties,
type ReactElement,
} from 'react';
import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon';
import type {
DaemonTranscriptBlock,
SessionSource,
DaemonSessionAttachmentReference,
} from '@qwen-code/sdk/daemon';
import { CompactModeContext, TodoContextsProvider } from '../WebShellContexts';
import {
WebShellCustomizationProvider,
@@ -21,6 +26,9 @@ import {
type UserMessageContentRenderer,
type WebShellComposerTagIconMap,
type WebShellMarkdownCustomization,
type WebShellSource,
type WebShellSourceReference,
type WebShellSourceIconResolver,
} from '../customization';
import { ErrorBoundary } from './ErrorBoundary';
import { MessageList } from './MessageList';
@@ -64,6 +72,12 @@ export interface WebShellTranscriptProps {
markdownTableMode?: MarkdownTableMode;
virtualScrollThreshold?: number;
markdown?: WebShellMarkdownCustomization;
sourceSessionId?: string;
sources?: readonly SessionSource[];
sourceAttachments?: readonly DaemonSessionAttachmentReference[];
sourceReferences?: readonly WebShellSourceReference[];
getAssistantSourcesIcon?: WebShellSourceIconResolver;
onSourceOpen?: (source: WebShellSource) => void;
composerTagIcons?: WebShellComposerTagIconMap;
renderToolHeaderExtra?: ToolHeaderExtraRenderer;
artifact?: WebShellArtifactCustomization;
@@ -118,6 +132,12 @@ function WebShellTranscriptContent({
markdownTableMode = 'basic',
virtualScrollThreshold,
markdown,
sourceSessionId,
sources,
sourceAttachments,
sourceReferences,
getAssistantSourcesIcon,
onSourceOpen,
composerTagIcons,
artifact,
renderToolHeaderExtra,
@@ -138,6 +158,10 @@ function WebShellTranscriptContent({
() => transcriptBlocksToLocalizedMessages(blocks, t, documentMode),
[blocks, documentMode, t],
);
const sourceEntries = useMemo(
() => getSourceEntries(sources ?? [], sourceAttachments ?? []),
[sources, sourceAttachments],
);
const todoDetails = useMemo(() => computeTodoDetails(messages), [messages]);
const todoTimeline = useMemo(() => computeTodoTimeline(messages), [messages]);
const customization = useMemo(
@@ -154,6 +178,8 @@ function WebShellTranscriptContent({
collapseCompletedTurns: effectiveCollapseCompletedTurns,
markdownTableMode: effectiveMarkdownTableMode,
markdown,
sourceReferences,
getAssistantSourcesIcon,
}),
[
artifact,
@@ -161,6 +187,8 @@ function WebShellTranscriptContent({
compactThinking,
composerTagIcons,
markdown,
sourceReferences,
getAssistantSourcesIcon,
effectiveMarkdownTableMode,
parseUserMessageContent,
renderAssistantTurnFooter,
@@ -279,6 +307,11 @@ function WebShellTranscriptContent({
>
<MessageList
messages={messages}
sourceSessionId={sourceSessionId}
sourceEntries={
documentMode ? undefined : sourceEntries
}
onSourceOpen={onSourceOpen}
pendingApproval={null}
isResponding={false}
workspaceCwd={workspaceCwd}
@@ -1,9 +1,11 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { LightbulbIcon } from 'lucide-react';
import { Markdown } from './Markdown';
import { TurnSources } from '../sources/TurnSources';
import {
useWebShellCustomization,
type WebShellAssistantTurnFooterRenderInfo,
type WebShellSource,
} from '../../customization';
import { useI18n } from '../../i18n';
import {
@@ -31,6 +33,8 @@ interface AssistantMessageProps {
showBranchAction?: boolean;
isLocateFlashing?: boolean;
customFooterInfo?: WebShellAssistantTurnFooterRenderInfo;
turnSources?: readonly WebShellSource[];
onSourceOpen?: (source: WebShellSource) => void;
}
export const AssistantMessage = memo(function AssistantMessage({
@@ -42,6 +46,8 @@ export const AssistantMessage = memo(function AssistantMessage({
showBranchAction = false,
isLocateFlashing = false,
customFooterInfo,
turnSources,
onSourceOpen,
}: AssistantMessageProps) {
const { t } = useI18n();
const documentMode = useTranscriptRenderMode() === 'document';
@@ -49,7 +55,10 @@ export const AssistantMessage = memo(function AssistantMessage({
const [copied, flashCopied] = useCopiedFlash();
const [branchPending, setBranchPending] = useState(false);
const showFooter =
!!content && !isStreaming && showFooterActions && !documentMode;
!!content &&
!isStreaming &&
(showFooterActions || (turnSources?.length ?? 0) > 0) &&
!documentMode;
const customFooter = useMemo(
() =>
customFooterInfo
@@ -97,16 +106,18 @@ export const AssistantMessage = memo(function AssistantMessage({
)}
{showFooter && (
<div className={styles.messageFooter}>
<button
type="button"
className={styles.copyButton}
title={t('assistant.copy')}
aria-label={t('assistant.copy')}
onClick={handleCopy}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
{showBranchAction && onBranchSession && (
{showFooterActions && (
<button
type="button"
className={styles.copyButton}
title={t('assistant.copy')}
aria-label={t('assistant.copy')}
onClick={handleCopy}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
)}
{showFooterActions && showBranchAction && onBranchSession && (
<button
type="button"
className={styles.copyButton}
@@ -118,7 +129,10 @@ export const AssistantMessage = memo(function AssistantMessage({
<BranchIcon />
</button>
)}
{timestamp !== undefined && (
{turnSources?.length ? (
<TurnSources sources={turnSources} onOpen={onSourceOpen} />
) : null}
{showFooterActions && timestamp !== undefined && (
<span className={styles.footerTime} aria-hidden="true">
{formatTimestamp(timestamp)}
</span>
@@ -0,0 +1,745 @@
// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { I18nProvider } from '../../i18n';
import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
import {
WebShellCustomizationProvider,
type WebShellFootnoteIconResolver,
} from '../../customization';
import { AssistantMessage } from './AssistantMessage';
import { Markdown } from './Markdown';
const triggerSelector = '[data-web-shell-footnote-trigger]';
const cardSelector = '[data-web-shell-footnote-card]';
const definitions = `
[^source-a]: [**Tourism office**](https://tourism.example/event) Concert details. ![Poster](https://images.example/poster.png)
[^source-b]: [Ticket website](https://tickets.example/show) Ticket information.
[^source-plain]: **Plain source** A plain note with no link.
`;
let root: Root;
let container: HTMLDivElement;
function render(children: ReactNode) {
if (!container) {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
}
act(() => root.render(<I18nProvider language="en">{children}</I18nProvider>));
return container;
}
function click(element: Element | null) {
expect(element).not.toBeNull();
act(() => (element as HTMLElement).click());
}
function card() {
return document.querySelector(cardSelector)!;
}
afterEach(() => {
if (root) act(() => root.unmount());
container?.remove();
container = undefined!;
vi.restoreAllMocks();
});
describe('Markdown footnote cards', () => {
it.each([
[
'[Physics](https://example.com/p) Mass-energy $E=mc^2$ equivalence.',
'Physics',
'Mass-energy E=mc^2 equivalence.',
],
[
'[$E=mc^2$](https://example.com/p) A formula title.',
'E=mc^2',
'A formula title.',
],
['Two formulas: $x^2$ and $y_1$.', undefined, 'Two formulas: x^2 and y_1.'],
[
'Display formula:\n\n $$\n \\frac{a}{b}\n $$\n\n Followed by text.',
undefined,
'Display formula: \\frac{a}{b} Followed by text.',
],
])('extracts each KaTeX formula once from %s', (body, title, summary) => {
const definition = `[^a]: ${body}`;
const inline = vi.fn<WebShellFootnoteIconResolver>(() => null);
render(
<WebShellCustomizationProvider
value={{ markdown: { getInlineFootnoteIcon: inline } }}
>
<AssistantMessage content={`Energy[^a].\n\n${definition}`} />
</WebShellCustomizationProvider>,
);
expect(inline).toHaveBeenCalled();
for (const [notes] of inline.mock.calls) {
expect(notes).toHaveLength(1);
expect(notes[0]).toMatchObject({
title,
summary,
definitionMarkdown: definition,
});
}
click(container.querySelector(triggerSelector));
expect(
card().querySelector('[data-web-shell-footnote-summary]')?.textContent,
).toBe(summary);
if (title) expect(card().querySelector('a')?.textContent).toBe(title);
});
it('groups adjacent references in order, deduplicates IDs, and stops at text', () => {
render(
<AssistantMessage
showFooterActions
content={`Sources[^source-a][^source-b] [^source-a][^source-plain]. Separate[^source-b], text[^source-a].${definitions}`}
/>,
);
const triggers = container.querySelectorAll(triggerSelector);
expect([...triggers].map((node) => node.textContent)).toEqual([
'3',
'',
'',
]);
expect(container.querySelector('[data-footnotes]')).toBeNull();
expect(
container.querySelector('[data-web-shell-turn-sources-trigger]'),
).toBeNull();
click(triggers[0]);
expect(card().textContent).toContain('tourism.example');
expect(card().textContent).toContain('Tourism office');
expect(card().textContent).toContain('Concert details.');
expect(card().textContent).toContain('1 / 3');
expect(card().querySelector('img')?.getAttribute('src')).toBe(
'https://images.example/poster.png',
);
expect(
card().querySelector('[aria-label="Previous reference"]'),
).toHaveProperty('disabled', true);
click(card().querySelector('[aria-label="Next reference"]'));
expect(card().textContent).toContain('Ticket website');
expect(card().querySelector('img')).toBeNull();
click(card().querySelector('[aria-label="Next reference"]'));
expect(card().textContent).toContain('Plain source');
expect(card().textContent).toContain('A plain note with no link.');
expect(card().querySelector('a')).toBeNull();
expect(
card().querySelector('[aria-label="Next reference"]'),
).toHaveProperty('disabled', true);
});
it('keeps the card open when pagination races a pending hover dismissal', () => {
vi.useFakeTimers();
try {
render(
<Markdown content={`Sources[^source-a][^source-b].${definitions}`} />,
);
const trigger = container.querySelector(triggerSelector)!;
act(() => {
trigger.dispatchEvent(new MouseEvent('pointerover', { bubbles: true }));
vi.advanceTimersByTime(150);
});
const preview = card();
click(preview.querySelector('[aria-label="Next reference"]'));
act(() => {
preview.dispatchEvent(
new MouseEvent('pointerout', {
bubbles: true,
relatedTarget: document.body,
}),
);
});
act(() => vi.advanceTimersByTime(250));
expect(document.querySelector(cardSelector)).not.toBeNull();
expect(card().textContent).toContain('Ticket website');
} finally {
vi.useRealTimers();
}
});
it('dismisses re-hovered cards after Escape while preserving keyboard focus', () => {
vi.useFakeTimers();
try {
render(<Markdown content={`Sources[^source-a].${definitions}`} />);
const trigger =
container.querySelector<HTMLButtonElement>(triggerSelector)!;
const hover = () => {
act(() => {
trigger.dispatchEvent(
new MouseEvent('pointerover', { bubbles: true }),
);
vi.advanceTimersByTime(150);
});
};
const leave = () => {
act(() => {
trigger.dispatchEvent(
new MouseEvent('pointerout', {
bubbles: true,
relatedTarget: document.body,
}),
);
});
act(() => vi.advanceTimersByTime(250));
};
hover();
leave();
expect(document.querySelector(cardSelector)).toBeNull();
act(() => trigger.focus());
act(() =>
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
}),
),
);
expect(document.querySelector(cardSelector)).toBeNull();
expect(document.activeElement).toBe(trigger);
for (let i = 0; i < 2; i++) {
hover();
expect(document.querySelector(cardSelector)).not.toBeNull();
leave();
expect(document.querySelector(cardSelector)).toBeNull();
expect(document.activeElement).toBe(trigger);
}
act(() => trigger.blur());
act(() => trigger.focus());
hover();
leave();
expect(document.querySelector(cardSelector)).not.toBeNull();
expect(document.activeElement).toBe(trigger);
} finally {
vi.useRealTimers();
}
});
it('does not merge across paragraphs, cells, or inline code', () => {
render(
<Markdown
content={`First[^source-a].\n\nSecond[^source-b]\n\n| A | B |\n|---|---|\n| Cell[^source-a] | Cell[^source-b] |\n\nCode \`[^source-a][^source-b]\` and [^unknown].${definitions}`}
/>,
);
expect(container.querySelectorAll(triggerSelector)).toHaveLength(4);
expect(container.querySelector('code')?.textContent).toBe(
'[^source-a][^source-b]',
);
expect(container.textContent).toContain('[^unknown]');
});
it('retains incomplete references and resolves them when definitions arrive', () => {
render(<Markdown content="Sources[^source-a][^source-b]" isStreaming />);
expect(container.textContent).toContain('[^source-a][^source-b]');
expect(container.querySelector(triggerSelector)).toBeNull();
render(
<Markdown content={`Sources[^source-a][^source-b]${definitions}`} />,
);
expect(container.querySelector(triggerSelector)?.textContent).toBe('2');
});
it('keeps the selected source as streamed content grows and falls back when removed', () => {
render(
<Markdown content={`Sources[^source-a][^source-b]${definitions}`} />,
);
click(container.querySelector(triggerSelector));
click(card().querySelector('[aria-label="Next reference"]'));
render(
<Markdown
content={`Sources[^source-a][^source-b][^source-plain]${definitions}`}
/>,
);
expect(card().textContent).toContain('Ticket website');
expect(card().textContent).toContain('2 / 3');
render(
<Markdown content={`Sources[^source-a][^source-plain]${definitions}`} />,
);
expect(card().textContent).toContain('Tourism office');
expect(card().textContent).toContain('1 / 2');
});
it('gives repeated references and separate documents valid isolated return targets', () => {
const text = `Note[^note] [^note]. Again[^note].\n\n[^note]: A normal footnote.`;
render(
<TranscriptRenderModeProvider value="document">
<Markdown content={text} />
<Markdown content={text} />
</TranscriptRenderModeProvider>,
);
const ids = [...container.querySelectorAll('[id]')].map((node) => node.id);
expect(new Set(ids).size).toBe(ids.length);
expect(container.querySelectorAll('[data-footnote-backref]')).toHaveLength(
6,
);
const docs = [...container.children];
for (const doc of docs) {
for (const link of doc.querySelectorAll<HTMLAnchorElement>(
'[data-footnote-backref]',
)) {
const target = document.getElementById(
link.getAttribute('href')!.slice(1),
);
expect(target?.matches('[data-footnote-ref]')).toBe(true);
expect(doc.contains(target)).toBe(true);
click(link);
expect(document.activeElement).toBe(target);
}
}
});
it('keeps standard navigable references in static exports', () => {
render(
<TranscriptRenderModeProvider value="document">
<Markdown content={`Sources[^source-a][^source-b]${definitions}`} />
</TranscriptRenderModeProvider>,
);
expect(container.querySelector(triggerSelector)).toBeNull();
const reference = container.querySelector<HTMLAnchorElement>('sup a')!;
expect(reference.id).not.toBe('');
expect(reference.getAttribute('target')).toBeNull();
click(reference);
expect(document.activeElement?.id).toBe(
reference.getAttribute('href')?.slice(1),
);
click(container.querySelector('[data-footnote-backref]'));
expect(document.activeElement).toBe(reference);
});
it('preserves custom superscript overrides and original reference children', () => {
render(
<WebShellCustomizationProvider
value={{
markdown: {
components: {
sup: ({ children }) => <sup data-custom-sup="">{children}</sup>,
},
},
}}
>
<Markdown
source="assistant"
content={`Sources[^source-a][^source-b]${definitions}`}
/>
</WebShellCustomizationProvider>,
);
expect(container.querySelector(triggerSelector)).toBeNull();
expect(container.querySelectorAll('[data-custom-sup]')).toHaveLength(2);
expect(container.querySelectorAll('[data-footnote-ref]')).toHaveLength(2);
});
it('filters unsafe URLs before previews and hides failed thumbnails', () => {
render(
<Markdown
content={
'Source[^source-a].\n\n[^source-a]: [Unsafe](javascript:alert%281%29) ![Bad](data:image/svg+xml;base64,PHN2Zz4=) [Safe](https://safe.example) ![Good](https://safe.example/image.png)'
}
/>,
);
click(container.querySelector(triggerSelector));
expect(card().querySelector('a')?.getAttribute('href')).toBe(
'https://safe.example',
);
expect(card().querySelectorAll('img')).toHaveLength(1);
act(() => card().querySelector('img')!.dispatchEvent(new Event('error')));
expect(card().querySelector('img')).toBeNull();
expect(card().querySelector('[aria-label="Next reference"]')).toBeNull();
});
it('preserves original reference text in advanced table copy', async () => {
const writeText = vi
.spyOn(navigator.clipboard, 'writeText')
.mockResolvedValue();
render(
<Markdown
tableMode="advanced"
content={`| Conclusion |\n| --- |\n| Result[^source-a] [^source-b][^source-a] |${definitions}`}
/>,
);
expect(container.querySelector(`td ${triggerSelector}`)?.textContent).toBe(
'2',
);
const copy = [...container.querySelectorAll('button')].find(
(button) => button.getAttribute('aria-label') === 'Copy table',
);
click(copy!);
await act(async () => {});
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('Result1 21'),
);
});
it('uses Chinese labels when the shell language is Chinese', () => {
render(
<I18nProvider language="zh-CN">
<AssistantMessage
showFooterActions
content={`Sources[^source-a][^source-plain]${definitions}`}
/>
</I18nProvider>,
);
expect(
container.querySelector(triggerSelector)?.getAttribute('aria-label'),
).toBe('查看 2 条引用');
expect(
container.querySelector('[data-web-shell-turn-sources-trigger]'),
).toBeNull();
click(container.querySelector(triggerSelector));
click(card().querySelector('[aria-label="下一条引用"]'));
expect(card().textContent).toContain('脚注 2');
});
it('aggregates numeric, named and Chinese IDs without requiring links or bold titles', () => {
render(
<AssistantMessage
showFooterActions
content={`Numeric[^1]. Named[^order]. Chinese[^说明]. Missing[^missing].
[^1]: Numeric explanation.
[^order]: Named explanation.
[^说明]: First paragraph.
Second paragraph without a link.`}
/>,
);
const triggers = container.querySelectorAll(triggerSelector);
expect(triggers).toHaveLength(3);
expect(container.querySelector('[data-footnotes]')).toBeNull();
expect(container.textContent).toContain('[^missing]');
expect(
container.querySelector('[data-web-shell-turn-sources-trigger]'),
).toBeNull();
click(triggers[2]);
expect(card().textContent).toContain('Footnote 3');
expect(card().textContent).toContain(
'First paragraph. Second paragraph without a link.',
);
expect(card().querySelector('a')).toBeNull();
});
it('keeps a source definition when one occurrence cannot become a card', () => {
render(
<WebShellCustomizationProvider
value={{
markdown: {
components: {
a: ({ children }) => <span data-host-link="">{children}</span>,
},
},
}}
>
<Markdown
source="assistant"
content={`Converted[^source-a]. [Linked reference[^source-a]](https://outer.example).${definitions}`}
/>
</WebShellCustomizationProvider>,
);
expect(container.querySelectorAll(triggerSelector)).toHaveLength(1);
const linkedReference = container.querySelector<HTMLAnchorElement>(
'[data-host-link] [data-footnote-ref]',
);
expect(linkedReference).not.toBeNull();
const target = document.getElementById(
linkedReference!.getAttribute('href')!.slice(1),
);
expect(target?.textContent).toContain('Concert details.');
expect(
container.querySelector('[data-web-shell-turn-sources-trigger]'),
).toBeNull();
});
it('retains nested definitions and their forward navigation paths', () => {
render(
<Markdown
source="assistant"
content={'Body[^1].\n\n[^1]: See also[^2].\n[^2]: Inner note.'}
/>,
);
expect(container.querySelectorAll(triggerSelector)).toHaveLength(0);
expect(container.querySelectorAll('[data-footnotes] li')).toHaveLength(2);
const refs = container.querySelectorAll<HTMLAnchorElement>(
'[data-footnote-ref]',
);
expect(refs).toHaveLength(2);
for (const ref of refs) {
const target = document.getElementById(ref.hash.slice(1));
expect(target).not.toBeNull();
click(ref);
expect(document.activeElement).toBe(target);
}
});
it('routes source links through the host component with the locator intact', () => {
const locator =
'https://citation.invalid/dataworks-knowledge#v=1&kind=content&kbInstanceId=instance-1&sourceFileId=file-2&citationId=citation-3&relativePath=docs%2Forder.md&anchor=definition%20one';
render(
<WebShellCustomizationProvider
value={{
markdown: {
components: {
a: ({ href, children }) =>
href === 'https://outer.example' ? (
<span>{children}</span>
) : (
<a data-host-link="" href={href}>
{children}
</a>
),
section: ({ children }) => (
<section data-host-section="">{children}</section>
),
},
},
}}
>
<Markdown
source="assistant"
content={`Source[^source-locator]. [Note[^note]](https://outer.example).\n\n[^source-locator]: [Knowledge result](<${locator}>) Result excerpt.\n[^note]: A normal footnote.`}
/>
</WebShellCustomizationProvider>,
);
click(container.querySelector(triggerSelector));
expect(card().querySelector('[data-host-link]')?.getAttribute('href')).toBe(
locator,
);
expect(
container.querySelectorAll(
'[data-footnote-ref][data-host-link], [data-footnote-backref][data-host-link]',
),
).toHaveLength(0);
expect(container.querySelectorAll('[data-footnote-ref]')).toHaveLength(1);
expect(container.querySelectorAll('[data-footnote-backref]')).toHaveLength(
1,
);
expect(container.querySelector('[data-host-section]')).not.toBeNull();
});
it('does not navigate citation locators without a host link resolver', () => {
const locator =
'https://citation.invalid/dataworks-knowledge#v=1&sourceFileId=file-2';
render(
<Markdown
content={`Source[^source-locator].\n\n[^source-locator]: [Knowledge result](<${locator}> "DataWorks Knowledge") Result excerpt.`}
/>,
);
click(container.querySelector(triggerSelector));
expect(card().textContent).toContain('DataWorks Knowledge');
expect(card().textContent).toContain('Knowledge result');
expect(card().querySelector('a')).toBeNull();
});
});
const resourceDefinitions = [
'[^a]: [Order](https://example.com/shared "Knowledge") — Definition.\n\n A second paragraph. ![Preview](https://example.com/preview.png)',
'[^b]: Plain explanation without a link.',
'[^c]: [Specification](https://example.com/shared) — Details.',
];
const resourceReport = `First[^a] [^b][^a].\n\nSecond[^c].${'\n\n'}${resourceDefinitions.join('\n')}`;
function iconMask(selector: string) {
return container
.querySelector(selector)
?.querySelector<HTMLElement>('[aria-hidden="true"]')?.style.maskImage;
}
describe('Host inline footnote icon selection', () => {
it('receives complete independent groups and transformed definitions without rendering nodes', () => {
const inline = vi.fn<WebShellFootnoteIconResolver>((notes) =>
notes.length === 2 ? '/icons/pair.svg' : '/icons/single.svg',
);
render(
<WebShellCustomizationProvider
value={{
markdown: {
transformMarkdown: () => resourceReport,
getInlineFootnoteIcon: inline,
},
}}
>
<AssistantMessage content="Before transformation" showFooterActions />
</WebShellCustomizationProvider>,
);
const groups = inline.mock.calls.map(([notes]) => notes);
expect(groups.map((notes) => notes.map((note) => note.id))).toContainEqual([
'a',
'b',
]);
expect(groups.map((notes) => notes.map((note) => note.id))).toContainEqual([
'c',
]);
const all = [
...groups.find((notes) => notes.length === 2)!,
...groups.find((notes) => notes.length === 1)!,
];
expect(all.map((note) => note.definitionMarkdown)).toEqual(
resourceDefinitions,
);
expect(all.map((note) => note.number)).toEqual([1, 2, 3]);
expect(all[0]).toMatchObject({
id: 'a',
title: 'Order',
source: 'Knowledge',
summary: '— Definition. A second paragraph.',
href: 'https://example.com/shared',
image: 'https://example.com/preview.png',
});
expect(all[1]).toMatchObject({
title: undefined,
href: undefined,
summary: 'Plain explanation without a link.',
});
expect(all[2].href).toBe(all[0].href);
expect(all.every((note) => !('linkNode' in note))).toBe(true);
expect(
container.querySelector('[data-web-shell-turn-sources-trigger]'),
).toBeNull();
const triggers = container.querySelectorAll(triggerSelector);
expect(iconMask(triggerSelector)).toContain('/icons/pair.svg');
expect(
(triggers[1].firstElementChild as HTMLElement).style.maskImage,
).toContain('/icons/single.svg');
click(triggers[0]);
click(card().querySelector('[aria-label="Next reference"]'));
expect(card().textContent).toContain('Plain explanation without a link.');
expect(iconMask(triggerSelector)).toContain('/icons/pair.svg');
expect(
inline.mock.calls.every(
([notes]) => notes.map((note) => note.id).join(',') !== 'b',
),
).toBe(true);
});
it.each([
undefined,
null,
'',
' ',
'javascript:alert(1)',
'data:image/svg+xml;base64,PHN2Zz4=',
'//external.example/icon.svg',
])('falls back for an empty or unsafe inline icon %s', (result) => {
render(<AssistantMessage content={resourceReport} />);
const fallback = iconMask(triggerSelector);
render(
<WebShellCustomizationProvider
value={{ markdown: { getInlineFootnoteIcon: () => result } }}
>
<AssistantMessage content={resourceReport} />
</WebShellCustomizationProvider>,
);
expect(iconMask(triggerSelector)).toBe(fallback);
click(container.querySelector(triggerSelector));
expect(card().textContent).toContain('Order');
});
it('isolates icon exceptions from the report', () => {
render(
<WebShellCustomizationProvider
value={{
markdown: {
getInlineFootnoteIcon: () => {
throw new Error('Icon failed');
},
},
}}
>
<AssistantMessage content={resourceReport} />
</WebShellCustomizationProvider>,
);
click(container.querySelector(triggerSelector));
expect(card().textContent).toContain('Order');
});
it('preserves Chinese IDs, CRLF definitions and message isolation', () => {
const inline = vi.fn<WebShellFootnoteIconResolver>();
const original = '[^说明]: First line.\r\n\r\n Second line.';
render(
<WebShellCustomizationProvider
value={{ markdown: { getInlineFootnoteIcon: inline } }}
>
<AssistantMessage content={`Note[^说明].\r\n\r\n${original}`} />
<AssistantMessage
content={'Other[^说明].\n\n[^说明]: Different explanation.'}
/>
</WebShellCustomizationProvider>,
);
expect(
inline.mock.calls.every(
([notes]) => notes.length === 1 && notes[0].id === '说明',
),
).toBe(true);
expect(
inline.mock.calls.map(([notes]) => notes[0].definitionMarkdown),
).toContain(original);
expect(inline.mock.calls.map(([notes]) => notes[0].summary)).toContain(
'Different explanation.',
);
});
it('keeps percent IDs distinct and case-insensitive references deduplicated', () => {
const inline = vi.fn<WebShellFootnoteIconResolver>();
render(
<WebShellCustomizationProvider
value={{ markdown: { getInlineFootnoteIcon: inline } }}
>
<AssistantMessage
content={
'Notes[^a][^%61][^ORDER][^order].\n\n[^a]: Letter.\n[^%61]: Percent.\n[^Order]: Order.'
}
/>
</WebShellCustomizationProvider>,
);
expect(inline.mock.calls.at(-1)![0].map((note) => note.id)).toEqual([
'a',
'%61',
'Order',
]);
});
it('keeps the current page open across assistant updates', () => {
const markdown = { getInlineFootnoteIcon: () => '/icons/pair.svg' };
const tree = (content: string) => (
<WebShellCustomizationProvider value={{ markdown }}>
<AssistantMessage content={content} />
</WebShellCustomizationProvider>
);
render(tree(resourceReport));
click(container.querySelector(triggerSelector));
click(card().querySelector('[aria-label="Next reference"]'));
const before = card();
render(tree(`${resourceReport}\n\nMore text.`));
expect(card()).toBe(before);
expect(card().textContent).toContain('2 / 2');
});
it('copies original Markdown and keeps static export as standard footnotes', async () => {
const writeText = vi
.spyOn(navigator.clipboard, 'writeText')
.mockResolvedValue();
render(<AssistantMessage content={resourceReport} showFooterActions />);
click(container.querySelector('[aria-label="Copy"]'));
await act(async () => {});
expect(writeText).toHaveBeenCalledWith(resourceReport);
const resolver = vi.fn<WebShellFootnoteIconResolver>();
render(
<WebShellCustomizationProvider
value={{ markdown: { getInlineFootnoteIcon: resolver } }}
>
<TranscriptRenderModeProvider value="document">
<AssistantMessage content={resourceReport} />
</TranscriptRenderModeProvider>
</WebShellCustomizationProvider>,
);
expect(container.querySelectorAll('[data-footnotes] li')).toHaveLength(3);
expect(container.querySelector(triggerSelector)).toBeNull();
expect(resolver).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,253 @@
import {
createElement,
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from 'react';
import type { Components, ExtraProps } from 'react-markdown';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { useI18n } from '../../i18n';
import { cssUrlValue } from '../../utils/cssUrlVar';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Button } from '../ui/button';
import knowledgeIcon from '../../assets/icons/knowledge.svg';
import type { FootnoteElement, FootnotePreview } from './rehype-footnote-cards';
import type {
WebShellFootnoteIconResolver,
WebShellFootnotePreviewMount,
} from '../../customization';
import { FootnotePreviewContent } from './FootnotePreviewContent';
import { isSafeImageSrc } from './Markdown';
export function FootnoteSup({
node,
children,
linkComponent,
iconResolver,
mountPreview,
...props
}: ComponentProps<'sup'> &
ExtraProps & {
linkComponent?: Components['a'];
iconResolver?: WebShellFootnoteIconResolver;
mountPreview?: WebShellFootnotePreviewMount;
}) {
const notes = (node as FootnoteElement | undefined)?.data?.footnoteCards;
return notes ? (
<FootnoteCard
id={props.id}
notes={notes}
linkComponent={linkComponent}
iconResolver={iconResolver}
mountPreview={mountPreview}
/>
) : (
<sup {...props}>{children}</sup>
);
}
export function FootnoteSection({
node,
children,
sectionComponent,
...props
}: ComponentProps<'section'> &
ExtraProps & { sectionComponent?: Components['section'] }) {
if (
(node as FootnoteElement | undefined)?.data?.hasVisibleFootnotes === false
)
return null;
return sectionComponent ? (
createElement(
sectionComponent,
typeof sectionComponent === 'string' ? props : { ...props, node },
children,
)
) : (
<section {...props}>{children}</section>
);
}
function FootnoteCard({
id,
notes,
linkComponent,
iconResolver,
mountPreview,
}: {
id?: string;
notes: FootnotePreview[];
linkComponent?: Components['a'];
iconResolver?: WebShellFootnoteIconResolver;
mountPreview?: WebShellFootnotePreviewMount;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string>();
const trigger = useRef<HTMLButtonElement>(null);
const content = useRef<HTMLDivElement>(null);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pinned = useRef(false);
const restoredFocus = useRef(false);
const customIcon = useMemo(() => {
try {
const icon = iconResolver?.(
notes.map(({ linkNode: _linkNode, ...footnote }) => footnote),
);
return typeof icon === 'string' && isSafeImageSrc(icon)
? icon.trim()
: undefined;
} catch {
return undefined;
}
}, [iconResolver, notes]);
const index = Math.max(
0,
notes.findIndex((note) => note.id === selectedId),
);
function cancelTimer() {
clearTimeout(timer.current);
}
function changeOpen(next: boolean) {
cancelTimer();
if (!next) pinned.current = false;
if (next && !open) setSelectedId(undefined);
setOpen(next);
}
function pinOpen() {
pinned.current = true;
cancelTimer();
}
function leave() {
cancelTimer();
if (pinned.current) return;
timer.current = setTimeout(() => {
if (
(restoredFocus.current || !trigger.current?.matches(':focus-within')) &&
!content.current?.matches(':focus-within')
) {
setOpen(false);
}
}, 200);
}
useEffect(() => () => clearTimeout(timer.current), []);
return (
<Popover open={open} onOpenChange={changeOpen}>
<PopoverTrigger asChild>
<button
ref={trigger}
id={id}
type="button"
data-web-shell-footnote-trigger=""
className="mx-0.5 inline-flex h-5 items-center gap-1 rounded-full bg-muted px-1.5 align-middle text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-ring"
aria-label={t('footnotes.references', { count: notes.length })}
onPointerEnter={(event) => {
if (event.pointerType === 'touch') return;
cancelTimer();
timer.current = setTimeout(() => changeOpen(true), 150);
}}
onPointerLeave={leave}
onFocus={() => {
restoredFocus.current = false;
changeOpen(true);
}}
onBlur={leave}
onKeyDown={(event) => {
if (!['Enter', ' ', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
restoredFocus.current = false;
changeOpen(true);
requestAnimationFrame(() => {
const target = content.current?.querySelector<HTMLElement>(
'a[href], button:not(:disabled)',
);
(target ?? content.current)?.focus();
});
}}
onClick={(event) => {
event.preventDefault();
pinOpen();
changeOpen(true);
}}
>
<span
aria-hidden="true"
className="inline-block size-4 shrink-0 bg-current"
style={{
maskImage: cssUrlValue(customIcon ?? knowledgeIcon),
maskSize: 'contain',
maskRepeat: 'no-repeat',
maskPosition: 'center',
}}
/>
{notes.length > 1 ? notes.length : null}
</button>
</PopoverTrigger>
<PopoverContent
ref={content}
data-web-shell-footnote-card=""
className="w-[360px] max-w-[calc(100vw-24px)] gap-3 rounded-2xl p-4 shadow-lg"
side="top"
align="start"
sideOffset={6}
collisionPadding={12}
aria-label={t('footnotes.preview')}
aria-describedby={undefined}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => {
trigger.current?.focus({ preventScroll: true });
restoredFocus.current = true;
}}
onPointerEnter={cancelTimer}
onPointerLeave={leave}
onFocusCapture={cancelTimer}
onBlurCapture={leave}
>
<FootnotePreviewContent
notes={notes}
index={index}
mount={mountPreview}
linkComponent={linkComponent}
/>
{notes.length > 1 && (
<div className="flex items-center justify-end gap-2 border-t border-border pt-2">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={t('footnotes.previous')}
disabled={index === 0}
onClick={() => {
pinOpen();
setSelectedId(notes[index - 1].id);
}}
>
<ChevronLeftIcon />
</Button>
<span className="text-xs text-muted-foreground" aria-live="polite">
{index + 1} / {notes.length}
</span>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={t('footnotes.next')}
disabled={index === notes.length - 1}
onClick={() => {
pinOpen();
setSelectedId(notes[index + 1].id);
}}
>
<ChevronRightIcon />
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,470 @@
// @vitest-environment jsdom
import { act, StrictMode, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import {
WebShellCustomizationProvider,
type WebShellFootnotePreviewInfo,
type WebShellFootnotePreviewMount,
} from '../../customization';
import { I18nProvider } from '../../i18n';
import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
import { AssistantMessage } from './AssistantMessage';
import { FootnotePreviewContent } from './FootnotePreviewContent';
const report =
'First[^a][^b].\n\nSecond[^c].\n\n[^a]: [Alpha](https://citation.invalid/record#id=a "Knowledge") First summary.\n[^b]: Plain beta.\n[^c]: [Gamma](https://example.com/gamma) Third summary.';
const trigger = '[data-web-shell-footnote-trigger]';
const cardSelector = '[data-web-shell-footnote-card]';
let container: HTMLDivElement;
let root: Root;
function render(node: ReactNode) {
if (!container) {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
}
act(() => root.render(<I18nProvider language="en">{node}</I18nProvider>));
}
function click(element: Element | null) {
expect(element).not.toBeNull();
act(() => (element as HTMLElement).click());
}
function close() {
act(() =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
),
);
expect(document.querySelector(cardSelector)).toBeNull();
}
function preview() {
return document.querySelector(cardSelector)!;
}
function harness(onUpdate?: (info: WebShellFootnotePreviewInfo) => void) {
const views: Array<{
element: HTMLElement;
update: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
}> = [];
const mount = vi.fn<WebShellFootnotePreviewMount>((element, initial) => {
expect(element.hidden).toBe(false);
const summary = document.createElement('p');
summary.dataset['hostSummary'] = '';
const update = vi.fn((info: WebShellFootnotePreviewInfo) => {
onUpdate?.(info);
summary.textContent = `Host ${info.footnote.summary}`;
element.replaceChildren(info.sourceLink, summary);
});
const dispose = vi.fn(() => element.replaceChildren());
views.push({ element, update, dispose });
update(initial);
return { update, dispose };
});
return { mount, views };
}
function tree(mount: WebShellFootnotePreviewMount, content = report) {
return (
<WebShellCustomizationProvider
value={{ markdown: { mountFootnotePreview: mount } }}
>
<AssistantMessage content={content} showFooterActions />
</WebShellCustomizationProvider>
);
}
afterEach(() => {
if (root) act(() => root.unmount());
container?.remove();
container = undefined!;
vi.restoreAllMocks();
});
it('mounts only an open page, supplies isolated data and updates without replacing the host view', () => {
const { mount, views } = harness();
render(tree(mount));
expect(mount).not.toHaveBeenCalled();
const triggers = container.querySelectorAll(trigger);
click(triggers[0]);
const first = mount.mock.calls[0][1];
expect(first.footnotes.map((note) => note.id)).toEqual(['a', 'b']);
expect(first.footnote).toBe(first.footnotes[0]);
expect(first).toMatchObject({
index: 0,
title: 'Alpha',
sourceLabel: 'Knowledge',
});
expect(first.footnote.definitionMarkdown).toContain('[^a]: [Alpha]');
expect(Object.keys(first.footnote)).not.toContain('linkNode');
expect(first.sourceLink).toBeInstanceOf(HTMLElement);
const element = views[0].element;
click(preview().querySelector('[aria-label="Next reference"]'));
expect(mount).toHaveBeenCalledTimes(1);
expect(views[0].element).toBe(element);
expect(views[0].update.mock.calls.at(-1)?.[0]).toMatchObject({
index: 1,
title: 'Footnote 2',
});
expect(views[0].update.mock.calls.at(-1)?.[0].sourceLink).toBe(
first.sourceLink,
);
expect(preview().textContent).toContain('Host Plain beta.');
expect(preview().textContent).toContain('2 / 2');
expect(preview().querySelector('a')).toBeNull();
close();
expect(views[0].dispose).toHaveBeenCalledTimes(1);
click(triggers[1]);
expect(mount.mock.calls.at(-1)![1].footnotes.map((note) => note.id)).toEqual([
'c',
]);
close();
});
it('preserves components.a interception inside the DOM content slot', () => {
const { mount } = harness();
const open = vi.fn();
render(
<WebShellCustomizationProvider
value={{
markdown: {
mountFootnotePreview: mount,
components: {
a: ({ href, children }) => (
<button data-host-open="" onClick={() => open(href)}>
{children}
</button>
),
},
},
}}
>
<AssistantMessage content={report} />
</WebShellCustomizationProvider>,
);
click(container.querySelector(trigger));
click(preview().querySelector('[data-host-open]'));
expect(open).toHaveBeenCalledWith('https://citation.invalid/record#id=a');
expect(preview().textContent).toContain('1 / 2');
});
it('updates a mounted view when report content grows and disposes on message removal', () => {
const { mount, views } = harness();
render(tree(mount));
click(container.querySelector(trigger));
click(preview().querySelector('[aria-label="Next reference"]'));
const before = preview();
render(tree(mount, report.replace('Plain beta.', 'Updated beta.')));
expect(preview()).toBe(before);
expect(preview().textContent).toContain('Host Updated beta.');
expect(preview().textContent).toContain('2 / 2');
expect(mount).toHaveBeenCalledTimes(1);
expect(views[0].dispose).not.toHaveBeenCalled();
render(tree(mount, report.replace('First[^a][^b].', 'First[^a][^b][^c].')));
expect(preview()).toBe(before);
expect(preview().textContent).toContain('2 / 3');
expect(
views[0].update.mock.calls
.at(-1)?.[0]
.footnotes.map((note: { id: string }) => note.id),
).toEqual(['a', 'b', 'c']);
expect(mount).toHaveBeenCalledTimes(1);
render(null);
expect(views[0].dispose).toHaveBeenCalledTimes(1);
});
it.each([null, undefined])(
'restores a declined page with %s after visiting a custom page',
(value) => {
const { mount: custom, views } = harness();
const mount = vi.fn<WebShellFootnotePreviewMount>((element, info) =>
info.footnote.id === 'a' ? value : custom(element, info),
);
render(tree(mount));
click(container.querySelector(trigger));
expect(
preview().querySelector('[data-web-shell-footnote-summary]')?.textContent,
).toBe('First summary.');
click(preview().querySelector('[aria-label="Next reference"]'));
expect(preview().textContent).toContain('Host Plain beta.');
click(preview().querySelector('[aria-label="Previous reference"]'));
expect(
preview().querySelector('[data-web-shell-footnote-summary]')?.textContent,
).toBe('First summary.');
expect(preview().querySelector('[data-host-summary]')).toBeNull();
expect(
preview().querySelector('[data-web-shell-footnote-custom-content]'),
).toHaveProperty('hidden', true);
expect(mount.mock.calls.map(([, info]) => info.footnote.id)).toEqual([
'a',
'b',
'a',
]);
expect(views[0].update.mock.calls.map(([info]) => info.index)).toEqual([1]);
expect(views[0].dispose).toHaveBeenCalledTimes(1);
click(preview().querySelector('[aria-label="Next reference"]'));
expect(preview().textContent).toContain('Host Plain beta.');
close();
expect(views.every((view) => view.dispose.mock.calls.length === 1)).toBe(
true,
);
},
);
it('tracks declined footnotes by ID across reordering and forgets a successful retry', () => {
const { mount: custom, views } = harness();
let decline = true;
const mount = vi.fn<WebShellFootnotePreviewMount>((element, info) =>
decline && info.footnote.id === 'a' ? null : custom(element, info),
);
render(tree(mount));
click(container.querySelector(trigger));
click(preview().querySelector('[aria-label="Next reference"]'));
const reordered = report.replace('First[^a][^b].', 'First[^b][^a].');
render(tree(mount, reordered));
expect(preview().textContent).toContain('Host Plain beta.');
expect(preview().textContent).toContain('1 / 2');
expect(mount).toHaveBeenCalledTimes(2);
click(preview().querySelector('[aria-label="Next reference"]'));
expect(preview().querySelector('[data-host-summary]')).toBeNull();
expect(views[0].dispose).toHaveBeenCalledTimes(1);
expect(mount.mock.calls.at(-1)?.[1].footnote.id).toBe('a');
expect(mount.mock.calls.at(-1)?.[1].index).toBe(1);
click(preview().querySelector('[aria-label="Previous reference"]'));
decline = false;
click(preview().querySelector('[aria-label="Next reference"]'));
expect(preview().textContent).toContain('Host First summary.');
expect(views[1].dispose).toHaveBeenCalledTimes(1);
const calls = mount.mock.calls.length;
click(preview().querySelector('[aria-label="Previous reference"]'));
click(preview().querySelector('[aria-label="Next reference"]'));
expect(mount).toHaveBeenCalledTimes(calls);
expect(views.at(-1)!.dispose).not.toHaveBeenCalled();
close();
expect(views.every((view) => view.dispose.mock.calls.length === 1)).toBe(
true,
);
});
it('does not carry declined pages into a replacement renderer', () => {
const first = harness();
const second = harness();
const mount: WebShellFootnotePreviewMount = (element, info) =>
info.footnote.id === 'a' ? null : first.mount(element, info);
const notes = ['a', 'b'].map((id, index) => ({
id,
number: index + 1,
summary: `Summary ${id}.`,
definitionMarkdown: `[^${id}]: Summary ${id}.`,
}));
const page = (renderer: WebShellFootnotePreviewMount, index: number) => (
<FootnotePreviewContent notes={notes} index={index} mount={renderer} />
);
render(page(mount, 0));
render(page(mount, 1));
render(page(second.mount, 1));
expect(first.views[0].dispose).toHaveBeenCalledTimes(1);
render(page(second.mount, 0));
expect(container.textContent).toContain('Host Summary a.');
expect(second.mount).toHaveBeenCalledTimes(1);
expect(second.views[0].dispose).not.toHaveBeenCalled();
render(null);
expect(second.views[0].dispose).toHaveBeenCalledTimes(1);
});
it('falls back after mount failure and recovers on a different page', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const { mount: custom } = harness();
const mount: WebShellFootnotePreviewMount = (element, info) => {
if (info.index === 0) {
element.textContent = 'Partial mount';
throw new Error('Mount failed');
}
return custom(element, info);
};
render(tree(mount));
click(container.querySelector(trigger));
expect(preview().textContent).not.toContain('Partial mount');
expect(preview().textContent).toContain('First summary.');
click(preview().querySelector('[aria-label="Next reference"]'));
expect(preview().textContent).toContain('Host Plain beta.');
});
it('disposes a failing update once, keeps the pager and retries after changing page', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const { mount, views } = harness((info) => {
if (info.index === 1) throw new Error('Update failed');
});
render(tree(mount));
click(container.querySelector(trigger));
click(preview().querySelector('[aria-label="Next reference"]'));
expect(views[0].dispose).toHaveBeenCalledTimes(1);
expect(preview().querySelector('[data-host-summary]')).toBeNull();
expect(preview().textContent).toContain('Plain beta.');
expect(preview().textContent).toContain('2 / 2');
click(preview().querySelector('[aria-label="Previous reference"]'));
expect(preview().textContent).toContain('Host First summary.');
close();
expect(views.every((view) => view.dispose.mock.calls.length === 1)).toBe(
true,
);
});
it('cleans up on renderer replacement and balances StrictMode mounts', () => {
const first = harness();
const second = harness();
const notes = [
{
id: 'a',
number: 1,
summary: 'Description.',
definitionMarkdown: '[^a]: Description.',
},
];
const node = (mount: WebShellFootnotePreviewMount) => (
<StrictMode>
<FootnotePreviewContent notes={notes} index={0} mount={mount} />
</StrictMode>
);
render(node(first.mount));
expect(first.mount.mock.calls.length).toBeGreaterThan(0);
render(node(second.mount));
expect(
first.views.every((view) => view.dispose.mock.calls.length === 1),
).toBe(true);
expect(second.mount.mock.calls.length).toBeGreaterThan(0);
render(null);
expect(
second.views.every((view) => view.dispose.mock.calls.length === 1),
).toBe(true);
});
it('contains disposal errors when the popup closes', () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
const dispose = vi.fn(() => {
throw new Error('Dispose failed');
});
const mount: WebShellFootnotePreviewMount = (element, info) => {
element.append(info.sourceLink);
return { update() {}, dispose };
};
render(tree(mount));
click(container.querySelector(trigger));
close();
expect(dispose).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalled();
expect(
container.querySelector('[data-web-shell-turn-sources-trigger]'),
).toBeNull();
});
it('does not mount for static export or custom superscript rendering', () => {
const { mount } = harness();
render(
<TranscriptRenderModeProvider value="document">
{tree(mount)}
</TranscriptRenderModeProvider>,
);
expect(container.querySelectorAll('[data-footnotes] li')).toHaveLength(3);
expect(mount).not.toHaveBeenCalled();
render(
<WebShellCustomizationProvider
value={{
markdown: {
mountFootnotePreview: mount,
components: { sup: ({ children }) => <sup>{children}</sup> },
},
}}
>
<AssistantMessage content={report} />
</WebShellCustomizationProvider>,
);
expect(container.querySelector(trigger)).toBeNull();
expect(mount).not.toHaveBeenCalled();
});
it('rejects an invalid JavaScript mount handle while cleaning up and showing the default page', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const dispose = vi.fn();
const mount = ((element: HTMLElement) => {
element.textContent = 'Incomplete host view';
return { dispose };
}) as unknown as WebShellFootnotePreviewMount;
render(tree(mount));
click(container.querySelector(trigger));
expect(preview().textContent).toContain('First summary.');
expect(preview().textContent).not.toContain('Incomplete host view');
expect(dispose).toHaveBeenCalled();
});
it('keeps DOM and footnote data separate for messages sharing an ID', () => {
const { mount } = harness();
render(
<WebShellCustomizationProvider
value={{ markdown: { mountFootnotePreview: mount } }}
>
<AssistantMessage content={'One[^a].\n\n[^a]: First message.'} />
<AssistantMessage content={'Two[^a].\n\n[^a]: Second message.'} />
</WebShellCustomizationProvider>,
);
const triggers = container.querySelectorAll(trigger);
click(triggers[0]);
const first = mount.mock.calls.at(-1)![1];
close();
click(triggers[1]);
const second = mount.mock.calls.at(-1)![1];
expect(second.sourceLink).not.toBe(first.sourceLink);
expect(first.footnote.summary).toBe('First message.');
expect(second.footnote.summary).toBe('Second message.');
expect(preview().textContent).not.toContain('First message.');
});
it('retries a previously declined renderer after another renderer succeeds', () => {
const first = harness();
const second = harness();
let decline = true;
const mount: WebShellFootnotePreviewMount = (element, info) =>
decline ? null : first.mount(element, info);
const notes = [
{
id: 'a',
number: 1,
summary: 'Renderer cycle.',
definitionMarkdown: '[^a]: Renderer cycle.',
},
];
const node = (renderer: WebShellFootnotePreviewMount) => (
<FootnotePreviewContent notes={notes} index={0} mount={renderer} />
);
render(node(mount));
render(node(second.mount));
decline = false;
render(node(mount));
expect(container.textContent).toContain('Host Renderer cycle.');
expect(second.views[0].dispose).toHaveBeenCalledTimes(1);
});
it('keeps every StrictMode mount measurable even when the host declines', () => {
const hidden: boolean[] = [];
const mount: WebShellFootnotePreviewMount = (element) => {
hidden.push(element.hidden);
return null;
};
const notes = [
{
id: 'a',
number: 1,
summary: 'Declined.',
definitionMarkdown: '[^a]: Declined.',
},
];
render(
<StrictMode>
<FootnotePreviewContent notes={notes} index={0} mount={mount} />
</StrictMode>,
);
expect(hidden.length).toBeGreaterThan(0);
expect(hidden.every((value) => !value)).toBe(true);
expect(container.textContent).toContain('Declined.');
});
@@ -0,0 +1,292 @@
import {
createElement,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import type { Components } from 'react-markdown';
import type {
WebShellFootnotePreviewHandle,
WebShellFootnotePreviewInfo,
WebShellFootnotePreviewMount,
} from '../../customization';
import { useI18n } from '../../i18n';
import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener';
import { cssUrlValue } from '../../utils/cssUrlVar';
import knowledgeIcon from '../../assets/icons/knowledge.svg';
import { ErrorBoundary } from '../ErrorBoundary';
import type { FootnotePreview } from './rehype-footnote-cards';
type PreviewData = Omit<WebShellFootnotePreviewInfo, 'sourceLink'>;
export function FootnotePreviewContent({
notes,
index,
mount,
linkComponent,
}: {
notes: FootnotePreview[];
index: number;
mount?: WebShellFootnotePreviewMount;
linkComponent?: Components['a'];
}) {
const { t } = useI18n();
const footnotes = useMemo(
() => notes.map(({ linkNode: _linkNode, ...footnote }) => footnote),
[notes],
);
const note = notes[index];
const title = note.title || t('footnotes.note', { number: note.number });
let hostname: string | undefined;
try {
hostname = note.href ? new URL(note.href).hostname : undefined;
} catch {
// Relative links and anchors have no source hostname.
}
const sourceLabel =
note.source || hostname || t('footnotes.note', { number: note.number });
const info = useMemo<PreviewData>(
() => ({
footnotes,
footnote: footnotes[index],
index,
title,
sourceLabel,
}),
[footnotes, index, title, sourceLabel],
);
const sourceLink = (
<FootnoteSourceLink
note={note}
title={title}
navigable={!!note.href && hostname !== 'citation.invalid'}
linkComponent={linkComponent}
/>
);
const fallback = (
<DefaultFootnoteContent
note={note}
sourceLabel={sourceLabel}
sourceLink={sourceLink}
/>
);
if (!mount) return fallback;
return (
<ErrorBoundary
label="footnote preview content"
resetKeys={[mount, info]}
fallback={fallback}
>
<MountedFootnoteContent
mount={mount}
info={info}
sourceLink={sourceLink}
fallback={fallback}
/>
</ErrorBoundary>
);
}
function FootnoteSourceLink({
note,
title,
navigable,
linkComponent,
}: {
note: FootnotePreview;
title: string;
navigable: boolean;
linkComponent?: Components['a'];
}) {
const openExternalLink = useExternalLinkOpener();
const className =
'line-clamp-2 font-semibold break-words text-popover-foreground hover:underline';
if (note.href && linkComponent) {
return createElement(
linkComponent,
{
className,
href: note.href,
title: note.source,
target: '_blank',
rel: 'noopener noreferrer',
...(typeof linkComponent === 'string' ? {} : { node: note.linkNode }),
},
title,
);
}
return navigable ? (
<a
className={className}
href={note.href}
title={note.source}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => openExternalLink(event, note.href)}
>
{title}
</a>
) : (
<div className="line-clamp-2 font-semibold break-words">{title}</div>
);
}
function DefaultFootnoteContent({
note,
sourceLabel,
sourceLink,
}: {
note: FootnotePreview;
sourceLabel: string;
sourceLink: ReactNode;
}) {
const [failedImage, setFailedImage] = useState<string>();
return (
<>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span
aria-hidden="true"
className="size-4 shrink-0 bg-current"
style={{
maskImage: cssUrlValue(knowledgeIcon),
maskSize: 'contain',
maskRepeat: 'no-repeat',
}}
/>
<span className="truncate">{sourceLabel}</span>
</div>
<div
className="flex items-start gap-3"
aria-live="polite"
aria-atomic="true"
>
<div className="min-w-0 flex-1">
{sourceLink}
{note.summary && (
<p
data-web-shell-footnote-summary=""
tabIndex={0}
className="mt-1 max-h-48 overflow-y-auto overscroll-contain text-sm break-words text-muted-foreground"
>
{note.summary}
</p>
)}
</div>
{note.image && note.image !== failedImage && (
<img
key={note.image}
src={note.image}
alt=""
className="size-16 shrink-0 rounded-lg object-cover"
onError={() => setFailedImage(note.image)}
/>
)}
</div>
</>
);
}
function MountedFootnoteContent({
mount,
info,
sourceLink,
fallback,
}: {
mount: WebShellFootnotePreviewMount;
info: PreviewData;
sourceLink: ReactNode;
fallback: ReactNode;
}) {
const container = useRef<HTMLDivElement>(null);
const active = useRef<
| {
mount: WebShellFootnotePreviewMount;
handle: WebShellFootnotePreviewHandle;
container: HTMLElement;
link: HTMLElement;
}
| undefined
>(undefined);
const declined = useRef({ mount, ids: new Set<string>() });
const [linkRoot, setLinkRoot] = useState<HTMLElement | null>();
const dispose = useCallback(() => {
const previous = active.current;
active.current = undefined;
if (!previous) return;
try {
previous.handle.dispose();
} catch (error) {
console.error('[web-shell] footnote preview cleanup failed:', error);
} finally {
previous.link.remove();
previous.container.replaceChildren();
}
}, []);
useLayoutEffect(() => {
const target = container.current!;
let pendingLink: HTMLElement | undefined;
try {
if (declined.current.mount !== mount)
declined.current = { mount, ids: new Set() };
if (active.current?.mount !== mount) dispose();
if (declined.current.ids.has(info.footnote.id)) dispose();
const existing = active.current;
if (existing) {
existing.handle.update({ ...info, sourceLink: existing.link });
return;
}
// A declined page hides this container; each host mount needs layout.
target.hidden = false;
pendingLink = target.ownerDocument.createElement('span');
pendingLink.dataset['webShellFootnoteSourceLink'] = '';
const handle = mount(target, { ...info, sourceLink: pendingLink });
if (handle == null) {
declined.current.ids.add(info.footnote.id);
pendingLink.remove();
target.replaceChildren();
target.hidden = true;
setLinkRoot(null);
return;
}
if (
typeof handle.update !== 'function' ||
typeof handle.dispose !== 'function'
) {
if (typeof handle.dispose === 'function') handle.dispose();
throw new Error(
'Footnote preview mount must return update and dispose methods',
);
}
declined.current.ids.delete(info.footnote.id);
active.current = { mount, handle, container: target, link: pendingLink };
setLinkRoot(pendingLink);
} catch (error) {
dispose();
pendingLink?.remove();
target.replaceChildren();
throw error;
}
}, [mount, info, dispose]);
useLayoutEffect(() => dispose, [dispose]);
return (
<>
{linkRoot === null && fallback}
<div
ref={container}
hidden={linkRoot === null}
tabIndex={0}
data-web-shell-footnote-custom-content=""
className="max-h-[min(50vh,320px)] overflow-y-auto overscroll-contain"
aria-live="polite"
aria-atomic="true"
/>
{linkRoot && createPortal(sourceLink, linkRoot)}
</>
);
}
@@ -1,13 +1,16 @@
import {
createContext,
createElement,
memo,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactNode,
type ComponentProps,
} from 'react';
import { useTheme } from '../../themeContext';
import { useTranscriptRenderMode } from '../../transcriptRenderMode';
@@ -17,7 +20,7 @@ import {
} from '../../utils/clipboard';
import { useCopiedFlash } from '../../hooks/useCopiedFlash';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import type { Components, Options } from 'react-markdown';
import type { Components, Options, ExtraProps } from 'react-markdown';
import { isMarkdownFenceClosed } from '@datafe-open/markdown-chart';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
@@ -38,6 +41,8 @@ import {
} from '../../customization';
import { ErrorBoundary } from '../ErrorBoundary';
import { EnhancedMarkdownTable } from './EnhancedMarkdownTable';
import { FootnoteSection, FootnoteSup } from './FootnoteCard';
import { rehypeFootnoteCards } from './rehype-footnote-cards';
import {
DEFAULT_WEB_SHELL_MARKDOWN_CHART,
WebShellMarkdownChartProvider,
@@ -765,12 +770,47 @@ export function markdownUrlTransform(
function MarkdownLink({
href,
children,
}: {
href?: string;
children?: ReactNode;
}) {
node,
id,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
}: ComponentProps<'a'> & ExtraProps) {
const renderMode = useTranscriptRenderMode();
const openExternalLink = useExternalLinkOpener();
const footnote =
node?.properties.dataFootnoteRef !== undefined ||
node?.properties.dataFootnoteBackref !== undefined;
if (footnote && href?.startsWith('#')) {
return (
<a
id={id}
href={href}
className={styles.link}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
data-footnote-ref={
node?.properties.dataFootnoteRef !== undefined ? '' : undefined
}
data-footnote-backref={
node?.properties.dataFootnoteBackref !== undefined ? '' : undefined
}
onClick={(event) => {
const root = event.currentTarget.getRootNode() as
| Document
| ShadowRoot;
const target = root.getElementById(href.slice(1));
if (!target) return;
event.preventDefault();
target.scrollIntoView({ block: 'nearest' });
if (!target.hasAttribute('tabindex') && target.tagName !== 'BUTTON')
target.tabIndex = -1;
target.focus({ preventScroll: true });
}}
>
{children}
</a>
);
}
if (href && QWEN_SESSION_SCHEME.test(href.trim())) {
if (renderMode !== 'interactive') {
return <span className={styles.link}>{children}</span>;
@@ -896,6 +936,8 @@ function createComponents(
pre: MarkdownPre,
a: MarkdownLink,
img: MarkdownImage,
sup: FootnoteSup,
section: FootnoteSection,
table({ children }: { children?: ReactNode }) {
if (tableMode === 'advanced') {
const fallback = <PlainMarkdownTable>{children}</PlainMarkdownTable>;
@@ -956,6 +998,7 @@ export const Markdown = memo(function Markdown({
const { markdown, markdownTableMode } = useWebShellCustomization();
const theme = useTheme();
const documentMode = useTranscriptRenderMode() === 'document';
const footnotePrefix = `footnote-${useId()}-`;
const sourceMarkdown = source ? markdown : undefined;
const throttledContent = useThrottledValue(content ?? '', isStreaming);
@@ -983,14 +1026,53 @@ export const Markdown = memo(function Markdown({
}, [effectiveTableMode, renderedContent]);
const sourceComponents = sourceMarkdown?.components;
const inlineFootnoteIcon = sourceMarkdown?.getInlineFootnoteIcon;
const mountFootnotePreview = sourceMarkdown?.mountFootnotePreview;
const renderedComponents = useMemo(() => {
if (!sourceComponents) return components;
return {
if (!sourceComponents && !inlineFootnoteIcon && !mountFootnotePreview)
return components;
const rendered = {
...components,
...sourceComponents,
...(effectiveTableMode === 'advanced' ? { table: components.table } : {}),
};
}, [components, effectiveTableMode, sourceComponents]);
const SourceLink = sourceComponents?.a;
if (sourceComponents) {
rendered.a = (props) => {
const footnote =
props.node?.properties.dataFootnoteRef !== undefined ||
props.node?.properties.dataFootnoteBackref !== undefined;
if (footnote || !SourceLink) return <MarkdownLink {...props} />;
if (typeof SourceLink !== 'string')
return createElement(SourceLink, props);
const { node: _node, ...elementProps } = props;
return createElement(SourceLink, elementProps);
};
}
if (!sourceComponents?.sup) {
rendered.sup = (props) => (
<FootnoteSup
{...props}
linkComponent={SourceLink}
iconResolver={inlineFootnoteIcon}
mountPreview={mountFootnotePreview}
/>
);
rendered.section = (props) => (
<FootnoteSection
{...props}
sectionComponent={sourceComponents?.section}
/>
);
}
return rendered;
}, [
components,
effectiveTableMode,
sourceComponents,
inlineFootnoteIcon,
mountFootnotePreview,
]);
const chart =
!documentMode &&
source === 'assistant' &&
@@ -1035,10 +1117,26 @@ export const Markdown = memo(function Markdown({
}, [sourceMarkdown?.remarkPlugins]);
const rehypePlugins = useMemo(() => {
return sourceMarkdown?.rehypePlugins
? [rehypeKatex, ...sourceMarkdown.rehypePlugins]
: [rehypeKatex];
}, [sourceMarkdown?.rehypePlugins]);
return [
rehypeKatex,
...(sourceMarkdown?.rehypePlugins ?? []),
[
rehypeFootnoteCards,
{
prefix: footnotePrefix,
group: !documentMode && !sourceComponents?.sup,
safeHref: isSafeHref,
safeImage: isSafeImageSrc,
transformUrl: markdownUrlTransform,
},
],
] satisfies NonNullable<Options['rehypePlugins']>;
}, [
sourceMarkdown?.rehypePlugins,
footnotePrefix,
documentMode,
sourceComponents?.sup,
]);
const urlTransform = useMemo(
() => (url: string) => markdownUrlTransform(url, documentMode),
[documentMode],
@@ -0,0 +1,279 @@
import type { Element, ElementContent, Root, RootContent } from 'hast';
import type { WebShellFootnote } from '../../customization';
export interface FootnotePreview extends WebShellFootnote {
linkNode?: Element;
}
export interface FootnoteElement extends Element {
data?: Element['data'] & {
footnoteCards?: FootnotePreview[];
hasVisibleFootnotes?: boolean;
};
}
interface FootnoteOptions {
prefix: string;
group: boolean;
safeHref: (url: string) => boolean;
safeImage: (url: string) => boolean;
transformUrl: (url: string) => string;
}
function visit(node: Root | RootContent, fn: (node: Element) => void) {
if (node.type === 'element') fn(node);
if ('children' in node) {
for (const child of node.children) visit(child, fn);
}
}
function noteText(node: RootContent, omit?: Element): string {
if (node === omit) return '';
if (node.type === 'text') return node.value;
if (node.type !== 'element') return '';
if (node.properties.dataFootnoteBackref !== undefined) return '';
if (
node.tagName === 'span' &&
Array.isArray(node.properties.className) &&
node.properties.className.includes('katex')
) {
let annotation: Element | undefined;
visit(node, (child) => {
if (
child.tagName === 'annotation' &&
child.properties.encoding === 'application/x-tex'
)
annotation ??= child;
});
if (annotation) return noteText(annotation, omit);
}
const text = node.children.map((child) => noteText(child, omit)).join('');
return /^(p|li|br|div)$/.test(node.tagName) ? `${text} ` : text;
}
function reference(node: RootContent): Element | undefined {
if (node.type !== 'element' || node.tagName !== 'sup') return;
const child = node.children[0];
return node.children.length === 1 &&
child?.type === 'element' &&
child.tagName === 'a' &&
child.properties.dataFootnoteRef !== undefined
? child
: undefined;
}
export function rehypeFootnoteCards(options: FootnoteOptions) {
return (tree: Root, file: { value: unknown }) => {
const markdown = String(file.value);
const definitions = new Map<string, FootnotePreview>();
const anchors = new Map<string, string>();
let footer: Element | undefined;
visit(tree, (node) => {
if (node.properties.dataFootnotes) footer = node;
if (node.properties.dataFootnoteRef !== undefined) {
const id = String(node.properties.id);
anchors.set(id, options.prefix + id);
}
});
if (!footer) return;
visit(footer, (node) => {
if (node.properties.id) {
const id = String(node.properties.id);
anchors.set(id, options.prefix + id);
}
});
const list = footer.children.find(
(node): node is Element =>
node.type === 'element' && node.tagName === 'ol',
);
let number = 0;
const definitionNumbers = new Map<string, number>();
for (const node of list?.children ?? []) {
if (node.type !== 'element' || node.tagName !== 'li') continue;
number += 1;
const id = String(node.properties.id);
definitionNumbers.set(id, number);
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start === undefined || end === undefined) continue;
const definitionMarkdown = markdown.slice(start, end);
// Read the already-parsed marker: decoding a DOM ID would conflate a and %61.
const logicalId = definitionMarkdown.match(
/^\[\^((?:\\.|[^\]])+)\]:/,
)?.[1];
if (!logicalId) continue;
let link: Element | undefined;
let source: string | undefined;
let href: string | undefined;
let image: string | undefined;
let nestedReference = false;
visit(node, (child) => {
if (child.properties.dataFootnoteRef !== undefined)
nestedReference = true;
if (
child.tagName === 'a' &&
!link &&
child.properties.href &&
child.properties.dataFootnoteBackref === undefined &&
child.properties.dataFootnoteRef === undefined
) {
const url = options.transformUrl(String(child.properties.href));
if (options.safeHref(url)) {
link = child;
href = url;
const title = child.properties.title;
source =
typeof title === 'string' ? title.trim() || undefined : undefined;
}
}
if (child.tagName === 'img' && !image && child.properties.src) {
const url = options.transformUrl(String(child.properties.src));
if (options.safeImage(url)) image = url;
}
});
if (nestedReference) continue;
definitions.set(`#${id}`, {
id: logicalId,
number,
definitionMarkdown,
title: link ? noteText(link).trim() || undefined : undefined,
summary: noteText(node, link).replace(/\s+/g, ' ').trim(),
source,
href,
image,
linkNode: link,
});
}
const referencedSources = new Map<string, FootnotePreview>();
const convertedReferences = new Set<Element>();
const convertedDefinitions = new Set<string>();
function group(parent: Element) {
if (parent === footer || parent.tagName === 'a') return;
const children = parent.children;
const grouped: typeof children = [];
for (let i = 0; i < children.length; i++) {
const first = children[i];
grouped.push(first);
const ref = reference(first);
if (!ref || !definitions.has(String(ref.properties.href))) {
if (first.type === 'element') group(first);
continue;
}
const refs = [ref];
let end = i + 1;
for (let j = end; j < children.length; j++) {
const next = children[j];
if (next.type === 'text' && /^\s*$/.test(next.value)) continue;
const nextRef = reference(next);
if (!nextRef || !definitions.has(String(nextRef.properties.href)))
break;
refs.push(nextRef);
end = j + 1;
}
const previews = new Map<string, FootnotePreview>();
const id = String(ref.properties.id);
for (const item of refs) {
const preview = definitions.get(String(item.properties.href))!;
previews.set(preview.id, preview);
convertedDefinitions.add(preview.id);
convertedReferences.add(item);
anchors.set(String(item.properties.id), options.prefix + id);
}
const element = first as FootnoteElement;
element.properties.id = id;
element.data = {
...element.data,
footnoteCards: [...previews.values()],
};
// Preserve the original reference text for advanced-table extraction.
element.children = children
.slice(i, end)
.flatMap<ElementContent>((child) => {
if (child.type === 'text') return [child];
const ref = reference(child);
return ref ? [ref] : [];
});
i = end - 1;
}
parent.children = grouped;
}
if (options.group) {
for (const child of tree.children) {
if (child === footer) continue;
visit(child, (node) => {
if (node.properties.dataFootnoteRef === undefined) return;
const preview = definitions.get(String(node.properties.href));
if (preview) referencedSources.set(preview.id, preview);
});
}
for (const child of tree.children) {
if (child.type === 'element') group(child);
}
if (referencedSources.size > 0 && list) {
const definitionsWithVisibleReferences = new Set<string>();
visit(tree, (node) => {
if (
node.properties.dataFootnoteRef === undefined ||
convertedReferences.has(node)
) {
return;
}
const definition = definitions.get(String(node.properties.href));
if (definition) definitionsWithVisibleReferences.add(definition.id);
});
const enhancedIds = new Set(
[...definitions.entries()]
.filter(
([, definition]) =>
convertedDefinitions.has(definition.id) &&
!definitionsWithVisibleReferences.has(definition.id),
)
.map(([href]) => href.slice(1)),
);
list.children = list.children.filter(
(child) =>
child.type !== 'element' ||
child.tagName !== 'li' ||
!enhancedIds.has(String(child.properties.id)),
);
for (const child of list.children) {
if (child.type !== 'element' || child.tagName !== 'li') continue;
const originalNumber = definitionNumbers.get(
String(child.properties.id),
);
if (originalNumber !== undefined)
child.properties.value = String(originalNumber);
}
const hasVisibleFootnotes = list.children.some(
(child) => child.type === 'element' && child.tagName === 'li',
);
const element = footer as FootnoteElement;
element.data = {
...element.data,
hasVisibleFootnotes,
};
}
}
visit(tree, (node) => {
const { properties } = node;
if (properties.id && anchors.has(String(properties.id))) {
properties.id = anchors.get(String(properties.id));
}
if (
typeof properties.href === 'string' &&
properties.href.startsWith('#')
) {
const target = anchors.get(properties.href.slice(1));
if (target) properties.href = `#${target}`;
}
if (Array.isArray(properties.ariaDescribedBy)) {
properties.ariaDescribedBy = properties.ariaDescribedBy.map(
(id) => anchors.get(String(id)) ?? id,
);
}
});
};
}
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { LinkIcon, PlusIcon } from 'lucide-react';
import { PlusIcon } from 'lucide-react';
import type {
DaemonSessionAttachmentReference,
SessionSource,
@@ -10,7 +10,12 @@ import { useI18n } from '../../i18n';
import { DialogShell } from '../dialogs/DialogShell';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { FileTypeIcon } from '../FileTypeIcon';
import type { WebShellSource } from '../../customization';
import { SourceList } from '../sources/SourceList';
import {
getSourceEntries,
sourceLocation as entryLocation,
} from '../sources/sourceEntries';
import { Skeleton } from '../ui/skeleton';
import type { AttachmentPreviewRequest } from '../../adapters/messageTypes';
import type { ImageTabSource } from '../artifacts/ArtifactPanel';
@@ -71,20 +76,7 @@ export function SourcesSection({
return () => onDialogOpenChange?.(false);
}, [adding, onDialogOpenChange, state?.supported]);
const sources = state?.supported ? state.sources : [];
const attachmentIds = new Set(
sources.flatMap((source) =>
source.locator.type === 'attachment' ? [source.locator.attachmentId] : [],
),
);
const entries: Array<
| { type: 'source'; source: SessionSource }
| { type: 'attachment'; attachment: DaemonSessionAttachmentReference }
> = sources.map((source) => ({ type: 'source', source }));
for (const attachment of attachments) {
if (attachmentIds.has(attachment.attachmentId)) continue;
attachmentIds.add(attachment.attachmentId);
entries.push({ type: 'attachment', attachment });
}
const entries = getSourceEntries(sources, attachments);
if (
!state?.supported &&
!entries.length &&
@@ -143,50 +135,18 @@ export function SourcesSection({
</Button>
</div>
)}
<ul className={styles.attachmentFiles}>
{(expanded ? entries : entries.slice(0, 3)).map((entry) =>
entry.type === 'source' ? (
<li key={`source:${entry.source.id}`}>
<button
type="button"
className={styles.attachmentFile}
title={sourceLocation(entry.source)}
onClick={() => onOpen?.(entry.source)}
aria-label={`${t('sources.open')} ${entry.source.title}`}
>
{entry.source.kind === 'link' ? (
<LinkIcon
size={16}
strokeWidth={1.7}
className={styles.attachmentFileIcon}
/>
) : (
<FileTypeIcon
name={sourceLocation(entry.source)}
size={16}
strokeWidth={1.7}
className={styles.attachmentFileIcon}
aria-hidden="true"
/>
)}
<span className={styles.attachmentFileName}>
{entry.source.title}
</span>
</button>
</li>
) : (
<li key={`attachment:${entry.attachment.attachmentId}`}>
<AttachmentRow
attachment={entry.attachment}
onReadImage={onReadImage}
onImagePreview={onImagePreview}
onAttachmentPreview={onAttachmentPreview}
onPreviewError={onAttachmentPreviewError}
/>
</li>
),
)}
</ul>
<SourceList
entries={expanded ? entries : entries.slice(0, 3)}
onOpen={(entry) =>
openSourceEntry(entry, {
onOpen,
onReadImage,
onImagePreview,
onAttachmentPreview,
onAttachmentPreviewError,
})
}
/>
{loading && !entries.length && (
<ul
className={styles.attachmentFiles}
@@ -227,14 +187,7 @@ export function SourcesSection({
}
export function sourceLocation(source: SessionSource): string {
switch (source.locator.type) {
case 'workspace_file':
return source.locator.workspacePath;
case 'attachment':
return source.locator.attachmentId;
case 'url':
return source.locator.url;
}
return entryLocation({ type: 'source', source });
}
function AddSourceDialog({
@@ -346,57 +299,37 @@ function AddSourceDialog({
);
}
function AttachmentRow({
attachment,
onReadImage,
onImagePreview,
onAttachmentPreview,
onPreviewError,
}: {
attachment: DaemonSessionAttachmentReference;
onReadImage?: (attachmentId: string) => Promise<string>;
onImagePreview?: (src: string, alt?: string, source?: ImageTabSource) => void;
onAttachmentPreview?: (file: AttachmentPreviewRequest) => void;
onPreviewError?: (error: unknown) => void;
}) {
const isImage = attachment.type === 'image';
const openPreview = () => {
if (isImage) {
const source: ImageTabSource = {
kind: 'attachment',
attachmentId: attachment.attachmentId,
};
void onReadImage?.(attachment.attachmentId)
.then((dataUrl) => {
onImagePreview?.(dataUrl, attachment.attachmentId, source);
})
.catch((error: unknown) => onPreviewError?.(error));
return;
}
onAttachmentPreview?.({
name: attachment.attachmentId,
mimeType: attachment.mimeType,
attachmentId: attachment.attachmentId,
});
};
return (
<button
type="button"
className={styles.attachmentFile}
title={attachment.attachmentId}
onClick={openPreview}
>
<FileTypeIcon
name={attachment.attachmentId}
mimeType={attachment.mimeType}
size={16}
strokeWidth={1.7}
className={styles.attachmentFileIcon}
aria-hidden="true"
/>
<span className={styles.attachmentFileName}>
{attachment.attachmentId}
</span>
</button>
);
export function openSourceEntry(
entry: WebShellSource,
actions: Pick<
SourcesSectionProps,
| 'onOpen'
| 'onReadImage'
| 'onImagePreview'
| 'onAttachmentPreview'
| 'onAttachmentPreviewError'
>,
) {
if (entry.type === 'source') {
actions.onOpen?.(entry.source);
return;
}
const attachment = entry.attachment;
if (attachment.type === 'image') {
void actions
.onReadImage?.(attachment.attachmentId)
.then((dataUrl) =>
actions.onImagePreview?.(dataUrl, attachment.attachmentId, {
kind: 'attachment',
attachmentId: attachment.attachmentId,
}),
)
.catch((error: unknown) => actions.onAttachmentPreviewError?.(error));
return;
}
actions.onAttachmentPreview?.({
name: attachment.attachmentId,
mimeType: attachment.mimeType,
attachmentId: attachment.attachmentId,
});
}
@@ -0,0 +1,60 @@
import { LinkIcon } from 'lucide-react';
import type { WebShellSource } from '../../customization';
import { useI18n } from '../../i18n';
import { FileTypeIcon } from '../FileTypeIcon';
import { sourceKey, sourceLocation, sourceTitle } from './sourceEntries';
import styles from '../panels/EnvironmentPanel.module.css';
export function SourceList({
entries,
onOpen,
}: {
entries: readonly WebShellSource[];
onOpen?: (entry: WebShellSource) => void;
}) {
const { t } = useI18n();
return (
<ul className={styles.attachmentFiles}>
{entries.map((entry) => (
<li key={sourceKey(entry)}>
<button
type="button"
className={styles.attachmentFile}
title={sourceLocation(entry)}
aria-label={
entry.type === 'source'
? `${t('sources.open')} ${sourceTitle(entry)}`
: undefined
}
disabled={!onOpen}
onClick={() => onOpen?.(entry)}
>
{entry.type === 'source' && entry.source.kind === 'link' ? (
<LinkIcon
size={16}
strokeWidth={1.7}
className={styles.attachmentFileIcon}
/>
) : (
<FileTypeIcon
name={sourceLocation(entry)}
mimeType={
entry.type === 'attachment'
? entry.attachment.mimeType
: undefined
}
size={16}
strokeWidth={1.7}
className={styles.attachmentFileIcon}
aria-hidden="true"
/>
)}
<span className={styles.attachmentFileName}>
{sourceTitle(entry)}
</span>
</button>
</li>
))}
</ul>
);
}
@@ -0,0 +1,532 @@
// @vitest-environment jsdom
import { act, StrictMode, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import type {
DaemonTranscriptBlock,
SessionSource,
} from '@qwen-code/sdk/daemon';
import {
WebShellCustomizationProvider,
type WebShellSourceIconResolver,
} from '../../customization';
import { I18nProvider } from '../../i18n';
import { WebShellPortalRootContext } from '../../portalRoot';
import { AssistantMessage } from '../messages/AssistantMessage';
import { WebShellTranscript } from '../WebShellTranscript';
import { MessageList } from '../MessageList';
import type { Message } from '../../adapters/types';
import { getSourceEntries } from './sourceEntries';
const web: SessionSource = {
id: 'web',
title: 'Web source',
kind: 'link',
locator: { type: 'url', url: 'https://example.com/source' },
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
};
const file: SessionSource = {
...web,
id: 'file',
kind: 'file',
title: 'data.csv',
locator: { type: 'workspace_file', workspacePath: 'data.csv' },
workspaceCwd: '/workspace',
};
const entries = getSourceEntries([web, file], []);
const selector = '[data-web-shell-turn-sources-trigger]';
const popup = '[data-web-shell-turn-sources]';
let root: Root;
let container: HTMLDivElement;
function render(node: ReactNode) {
if (!container) {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
}
act(() => root.render(<I18nProvider language="en">{node}</I18nProvider>));
}
function click(node: Element | null) {
expect(node).not.toBeNull();
act(() => (node as HTMLElement).click());
}
function mask() {
return container
.querySelector(selector)
?.querySelector<HTMLElement>('[aria-hidden]')?.style.maskImage;
}
afterEach(() => {
if (root) act(() => root.unmount());
container?.remove();
container = undefined!;
vi.useRealTimers();
vi.restoreAllMocks();
});
it('does not revisit historical sources or redraw their icons for streaming text', async () => {
vi.useFakeTimers();
const readLocator = vi.fn(() => web.locator);
const source = {
...web,
get locator() {
return readLocator();
},
};
const sources = getSourceEntries([source], []);
const icon = vi.fn(() => '/sources.svg');
const customization = {
getAssistantSourcesIcon: icon,
sourceReferences: [
{ sessionId: 'session', turnId: 'first', sourceId: web.id },
],
};
const history: Message[] = [
{ id: 'first', role: 'user', content: 'First question' },
{ id: 'done', role: 'assistant', content: 'Completed report' },
{ id: 'second', role: 'user', content: 'Second question' },
];
const tree = (text: string) => (
<StrictMode>
<WebShellCustomizationProvider value={customization}>
<MessageList
messages={[
...history,
{ id: 'live', role: 'assistant', content: text, isStreaming: true },
]}
isResponding
pendingApproval={null}
sourceEntries={sources}
sourceSessionId="session"
/>
</WebShellCustomizationProvider>
</StrictMode>
);
render(tree('Partial'));
await act(async () => vi.advanceTimersByTimeAsync(100));
expect(container.querySelector(selector)?.textContent).toBe('1 source');
expect(icon).toHaveBeenCalled();
readLocator.mockClear();
icon.mockClear();
render(tree('Partial report'));
render(tree('Partial report continues'));
await act(async () => vi.advanceTimersByTimeAsync(100));
expect(container.textContent).toContain('Partial report continues');
expect(readLocator).not.toHaveBeenCalled();
expect(icon).not.toHaveBeenCalled();
});
it.each([
['inventory', []],
['references', [file.id]],
['session', [file.id]],
['workspace', [web.id]],
] as const)(
'refreshes streaming sources when %s changes',
(changed, expected) => {
let sources = getSourceEntries([web, file], []);
let references = [
{ sessionId: 'session', turnId: 'first', sourceId: web.id },
];
let sessionId = 'session';
let workspaceCwd = '/workspace';
const icon = vi.fn<WebShellSourceIconResolver>(() => '/sources.svg');
const history: Message[] = [
{ id: 'first', role: 'user', content: 'First question' },
{
id: 'registration',
role: 'tool_group',
tools: [
{
callId: 'register',
toolName: 'record_source',
status: 'completed',
args: { locator: file.locator },
},
],
},
{ id: 'done', role: 'assistant', content: 'Completed report' },
{ id: 'second', role: 'user', content: 'Second question' },
];
const tree = (text: string) => (
<WebShellCustomizationProvider
value={{ getAssistantSourcesIcon: icon, sourceReferences: references }}
>
<MessageList
messages={[
...history,
{ id: 'live', role: 'assistant', content: text, isStreaming: true },
]}
isResponding
pendingApproval={null}
sourceEntries={sources}
sourceSessionId={sessionId}
workspaceCwd={workspaceCwd}
/>
</WebShellCustomizationProvider>
);
render(tree('Partial'));
expect(container.querySelector(selector)?.textContent).toBe('2 sources');
if (changed === 'inventory') sources = [];
if (changed === 'references') references = [];
if (changed === 'session') sessionId = 'other';
if (changed === 'workspace') workspaceCwd = '/other';
render(tree('Partial report'));
if (!expected.length) expect(container.querySelector(selector)).toBeNull();
else {
expect(container.querySelector(selector)?.textContent).toBe('1 source');
expect(
icon.mock.calls
.at(-1)?.[0]
.map((entry) =>
entry.type === 'source'
? entry.source.id
: entry.attachment.attachmentId,
),
).toEqual(expected);
}
},
);
it('shows real source entries on a report without footnotes and reuses its open action', () => {
const open = vi.fn();
render(
<AssistantMessage
content="A report without footnotes."
showFooterActions
turnSources={entries}
onSourceOpen={open}
/>,
);
const sources = container.querySelector(selector)!;
expect(sources.textContent).toBe('2 sources');
expect(sources.parentElement).toBe(
container.querySelector('[aria-label="Copy"]')?.parentElement,
);
click(sources);
const list = document.querySelector(popup)!;
expect(list.textContent).toContain('Web source');
expect(list.textContent).toContain('data.csv');
expect(list.querySelector('[data-web-shell-footnote-card]')).toBeNull();
click(list.querySelector('[aria-label="Open source Web source"]'));
expect(open).toHaveBeenCalledWith(entries[0]);
});
it('returns keyboard focus to the trigger after activating a source', () => {
const open = vi.fn();
render(
<AssistantMessage
content="Report"
showFooterActions
turnSources={entries}
onSourceOpen={open}
/>,
);
const trigger = container.querySelector<HTMLButtonElement>(selector)!;
click(trigger);
const row = document.querySelector<HTMLButtonElement>(
'[aria-label="Open source Web source"]',
)!;
act(() => row.focus());
click(row);
expect(open).toHaveBeenCalledWith(entries[0]);
expect(document.querySelector(popup)).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it.each(['Escape', 'source activation'])(
'dismisses re-hovered sources after %s without losing keyboard focus',
(action) => {
vi.useFakeTimers();
render(
<AssistantMessage
content="Report"
showFooterActions
turnSources={entries}
onSourceOpen={vi.fn()}
/>,
);
const trigger = container.querySelector<HTMLButtonElement>(selector)!;
const hover = () =>
act(() => {
trigger.dispatchEvent(new MouseEvent('pointerover', { bubbles: true }));
});
const leave = () => {
act(() =>
trigger.dispatchEvent(
new MouseEvent('pointerout', {
bubbles: true,
relatedTarget: document.body,
}),
),
);
act(() => vi.advanceTimersByTime(250));
};
hover();
leave();
expect(document.querySelector(popup)).toBeNull();
act(() => trigger.focus());
const row = document.querySelector<HTMLButtonElement>(
'[aria-label="Open source Web source"]',
)!;
act(() => row.focus());
if (action === 'Escape') {
act(() =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
),
);
} else {
click(row);
}
expect(document.querySelector(popup)).toBeNull();
expect(document.activeElement).toBe(trigger);
for (let i = 0; i < 2; i++) {
hover();
expect(document.querySelector(popup)).not.toBeNull();
leave();
expect(document.querySelector(popup)).toBeNull();
expect(document.activeElement).toBe(trigger);
}
act(() => trigger.blur());
act(() => trigger.focus());
hover();
leave();
expect(document.querySelector(popup)).not.toBeNull();
expect(document.activeElement).toBe(trigger);
},
);
it('preserves focus when the host moves it into the source preview', () => {
const target = document.createElement('button');
document.body.append(target);
try {
render(
<AssistantMessage
content="Report"
showFooterActions
turnSources={entries}
onSourceOpen={() => target.focus()}
/>,
);
click(container.querySelector(selector));
const row = document.querySelector<HTMLButtonElement>(
'[aria-label="Open source Web source"]',
)!;
act(() => row.focus());
click(row);
expect(document.querySelector(popup)).toBeNull();
expect(document.activeElement).toBe(target);
} finally {
target.remove();
}
});
it('returns focus after activating a source inside a shadow portal', () => {
const host = document.createElement('div');
document.body.append(host);
const portal = document.createElement('div');
host.attachShadow({ mode: 'open' }).append(portal);
try {
render(
<WebShellPortalRootContext.Provider value={portal}>
<AssistantMessage
content="Report"
showFooterActions
turnSources={entries}
onSourceOpen={vi.fn()}
/>
</WebShellPortalRootContext.Provider>,
);
const trigger = container.querySelector<HTMLButtonElement>(selector)!;
click(trigger);
const row = portal.querySelector<HTMLButtonElement>(
'[aria-label="Open source Web source"]',
)!;
act(() => row.focus());
expect(document.activeElement).toBe(host);
click(row);
expect(portal.querySelector(popup)).toBeNull();
expect(document.activeElement).toBe(trigger);
} finally {
host.remove();
}
});
it('never substitutes footnote counts when turn sources are absent', () => {
render(
<AssistantMessage
content={'Report[^a][^b].\n\n[^a]: One.\n[^b]: Two.'}
showFooterActions
/>,
);
expect(container.querySelector(selector)).toBeNull();
expect(
container.querySelector('[data-web-shell-footnote-trigger]')?.textContent,
).toBe('2');
expect(
container.querySelector('[data-web-shell-footnote-sources-trigger]'),
).toBeNull();
});
it('passes the full source list to its icon callback independently of inline footnote icons', () => {
const icon = vi.fn<WebShellSourceIconResolver>(() => '/source-icon.svg');
const inline = vi.fn(() => '/inline-icon.svg');
render(
<WebShellCustomizationProvider
value={{
getAssistantSourcesIcon: icon,
markdown: { getInlineFootnoteIcon: inline },
}}
>
<AssistantMessage
content={'Report[^a].\n\n[^a]: Note.'}
showFooterActions
turnSources={entries}
/>
</WebShellCustomizationProvider>,
);
expect(icon).toHaveBeenCalledWith(entries);
expect(mask()).toContain('/source-icon.svg');
expect(
container
.querySelector(`${selector} span`)
?.classList.contains('-translate-y-px'),
).toBe(false);
expect(
container
.querySelector('[data-web-shell-footnote-trigger] span')
?.getAttribute('style'),
).toContain('/inline-icon.svg');
});
it.each([
undefined,
null,
'',
'javascript:alert(1)',
'data:image/svg+xml;base64,PHN2Zz4=',
])('uses the default source icon for %s', (value) => {
render(
<AssistantMessage
content="Report"
showFooterActions
turnSources={entries}
/>,
);
const fallback = mask();
render(
<WebShellCustomizationProvider
value={{ getAssistantSourcesIcon: () => value }}
>
<AssistantMessage
content="Report"
showFooterActions
turnSources={entries}
/>
</WebShellCustomizationProvider>,
);
expect(mask()).toBe(fallback);
});
it('contains icon exceptions and removes an open source view when the list disappears', () => {
const customization = {
getAssistantSourcesIcon: () => {
throw new Error('Host icon');
},
};
const tree = (withSources: boolean) => (
<WebShellCustomizationProvider value={customization}>
<AssistantMessage
content="Report"
showFooterActions
turnSources={withSources ? entries : []}
/>
</WebShellCustomizationProvider>
);
render(tree(true));
click(container.querySelector(selector));
expect(document.querySelector(popup)).not.toBeNull();
render(tree(false));
expect(container.querySelector(selector)).toBeNull();
expect(document.querySelector(popup)).toBeNull();
});
it('keeps read-only source data scoped to its turn and renders the footer only on final answers', () => {
const block = (
id: string,
kind: 'user' | 'assistant' | 'thinking',
text: string,
): DaemonTranscriptBlock =>
({
id,
kind,
text,
createdAt: 1,
updatedAt: 1,
clientReceivedAt: 1,
}) as DaemonTranscriptBlock;
const blocks = [
block('one', 'user', 'First question'),
block('step', 'assistant', 'Intermediate'),
block('thinking', 'thinking', 'Thinking'),
block('answer-one', 'assistant', 'First report'),
block('two', 'user', 'Second question'),
block('answer-two', 'assistant', 'Second report'),
];
render(
<WebShellTranscript
blocks={blocks}
collapseCompletedTurns={false}
sourceSessionId="session"
sources={[web, file]}
sourceReferences={[
{ sessionId: 'session', turnId: 'one', sourceId: web.id },
{ sessionId: 'session', turnId: 'two', sourceId: web.id },
{ sessionId: 'session', turnId: 'two', sourceId: file.id },
{ sessionId: 'foreign', turnId: 'one', sourceId: file.id },
]}
/>,
);
expect(
[...container.querySelectorAll(selector)].map((node) => node.textContent),
).toEqual(['1 source', '2 sources']);
});
it('updates cached message rows after sources arrive without another user interaction', () => {
const blocks = [
{
id: 'question',
kind: 'user',
text: 'Question',
createdAt: 1,
updatedAt: 1,
clientReceivedAt: 1,
},
{
id: 'answer',
kind: 'assistant',
text: 'Answer',
createdAt: 2,
updatedAt: 2,
clientReceivedAt: 2,
},
] as DaemonTranscriptBlock[];
const refs = [{ sessionId: 'session', turnId: 'question', sourceId: web.id }];
const tree = (sources: readonly SessionSource[]) => (
<WebShellTranscript
blocks={blocks}
sourceSessionId="session"
sources={sources}
sourceReferences={refs}
/>
);
render(tree([]));
expect(container.querySelector(selector)).toBeNull();
render(tree([web]));
expect(container.querySelector(selector)?.textContent).toBe('1 source');
render(tree([]));
expect(container.querySelector(selector)).toBeNull();
});
@@ -0,0 +1,163 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useWebShellCustomization,
type WebShellSource,
} from '../../customization';
import { useI18n } from '../../i18n';
import { cssUrlValue } from '../../utils/cssUrlVar';
import { getShadowAwareActiveElement } from '../../utils/dom';
import knowledgeIcon from '../../assets/icons/knowledge.svg';
import { isSafeImageSrc } from '../messages/Markdown';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { SourceList } from './SourceList';
export function TurnSources({
sources,
onOpen,
}: {
sources: readonly WebShellSource[];
onOpen?: (source: WebShellSource) => void;
}) {
const { t } = useI18n();
const { getAssistantSourcesIcon } = useWebShellCustomization();
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
const content = useRef<HTMLDivElement>(null);
const pinned = useRef(false);
const restoredFocus = useRef(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const icon = useMemo(() => {
try {
const value = getAssistantSourcesIcon?.(sources);
return typeof value === 'string' && isSafeImageSrc(value)
? value.trim()
: undefined;
} catch {
return undefined;
}
}, [getAssistantSourcesIcon, sources]);
const cancel = () => clearTimeout(timer.current);
const show = () => {
cancel();
setOpen(true);
};
const leave = () => {
cancel();
if (pinned.current) return;
timer.current = setTimeout(() => {
if (
(restoredFocus.current || !trigger.current?.matches(':focus-within')) &&
!content.current?.matches(':focus-within')
)
setOpen(false);
}, 200);
};
useEffect(() => () => clearTimeout(timer.current), []);
if (!sources.length) return null;
return (
<Popover
open={open}
onOpenChange={(value) => {
cancel();
if (!value) pinned.current = false;
setOpen(value);
}}
>
<PopoverTrigger asChild>
<button
ref={trigger}
type="button"
data-web-shell-turn-sources-trigger=""
className="inline-flex h-5 items-center gap-1 rounded-[5px] px-1 text-[11px] leading-[1.4] text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-ring"
onPointerEnter={(event) => {
if (event.pointerType !== 'touch') show();
}}
onPointerLeave={leave}
onFocus={() => {
restoredFocus.current = false;
show();
}}
onBlur={leave}
onClick={(event) => {
event.preventDefault();
pinned.current = true;
show();
}}
onKeyDown={(event) => {
if (!['Enter', ' ', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
restoredFocus.current = false;
show();
requestAnimationFrame(() =>
content.current
?.querySelector<HTMLButtonElement>('button:not(:disabled)')
?.focus(),
);
}}
>
<span
aria-hidden="true"
className={`size-3.5 shrink-0 bg-current ${icon ? '' : '-translate-y-px'}`}
style={{
maskImage: cssUrlValue(icon ?? knowledgeIcon),
maskSize: 'contain',
maskPosition: 'center',
maskRepeat: 'no-repeat',
}}
/>
{t('sources.count', { count: sources.length })}
</button>
</PopoverTrigger>
<PopoverContent
ref={content}
data-web-shell-turn-sources=""
side="top"
align="start"
sideOffset={6}
collisionPadding={12}
className="w-[360px] max-w-[calc(100vw-24px)] rounded-2xl p-4"
aria-label={t('sources.currentTurn')}
aria-describedby={undefined}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => {
trigger.current?.focus({ preventScroll: true });
restoredFocus.current = true;
}}
onPointerEnter={cancel}
onPointerLeave={leave}
onFocusCapture={cancel}
onBlurCapture={leave}
>
<div className="text-xs text-muted-foreground">
{t('sources.currentTurn')}
</div>
<div
className="-mx-1.5 max-h-[min(50vh,320px)] overflow-y-auto overscroll-contain px-1.5"
tabIndex={0}
>
<SourceList
entries={sources}
onOpen={
onOpen
? (source) => {
onOpen(source);
if (
content.current?.contains(
getShadowAwareActiveElement(content.current),
)
) {
trigger.current?.focus({ preventScroll: true });
restoredFocus.current = true;
}
pinned.current = false;
setOpen(false);
}
: undefined
}
/>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,283 @@
import { describe, expect, it } from 'vitest';
import type {
DaemonSessionAttachmentReference,
SessionSource,
} from '@qwen-code/sdk/daemon';
import type { ACPToolCall, Message } from '../../adapters/types';
import { getSourceEntries, getSourcesByTurn, sourceKey } from './sourceEntries';
const cwd = '/workspace';
const link: SessionSource = {
id: 'a'.repeat(64),
kind: 'link',
title: 'Web guide',
locator: { type: 'url', url: 'https://example.com/guide' },
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
};
const file: SessionSource = {
...link,
id: 'b'.repeat(64),
kind: 'file',
title: 'Guide file',
locator: { type: 'workspace_file', workspacePath: 'docs/guide.md' },
workspaceCwd: cwd,
};
const attachment: DaemonSessionAttachmentReference = {
attachmentId: 'photo.png',
type: 'image',
mimeType: 'image/png',
size: 1,
};
const user = (id: string): Message => ({
role: 'user',
id,
content: 'Question.',
});
function record(
source: SessionSource,
options: Partial<ACPToolCall> = {},
): Message {
return {
role: 'tool_group',
id: `tools-${source.id}`,
tools: [
{
callId: 'call',
toolName: 'record_source',
status: 'completed',
args: { title: source.title, locator: source.locator },
rawOutput: `Reference added: ${source.id}`,
...options,
},
],
};
}
const ids = (
map: ReadonlyMap<
string,
readonly ReturnType<typeof getSourceEntries>[number][]
>,
turn: string,
) => map.get(turn)?.map(sourceKey) ?? [];
describe('turn source associations', () => {
it('counts explicitly reused sources in every turn, in panel order, without relying on timestamps', () => {
const entries = getSourceEntries([link, file], []);
const map = getSourcesByTurn(
[
user('one'),
record(file),
record(link),
record(link),
user('two'),
record(link),
],
entries,
cwd,
'session',
);
expect(ids(map, 'one')).toEqual([link.id, file.id]);
expect(ids(map, 'two')).toEqual([link.id]);
expect(link.updatedAt).toBe('2025-01-01');
});
it('never counts a footnote, a bare link or an unassociated panel entry', () => {
const map = getSourcesByTurn(
[
user('one'),
{
role: 'assistant',
id: 'answer',
content:
'See [guide](https://example.com/guide)[^1].\n\n[^1]: A note.',
},
],
getSourceEntries([link, file], []),
cwd,
'session',
);
expect(ids(map, 'one')).toEqual([]);
});
it.each(['pending', 'in_progress', 'failed'] as const)(
'excludes %s tool registrations',
(status) => {
expect(
ids(
getSourcesByTurn(
[user('one'), record(link, { status })],
getSourceEntries([link], []),
cwd,
'session',
),
'one',
),
).toEqual([]);
},
);
it('excludes cancelled, nested and unrelated tools and records without a loaded turn', () => {
const tools = [
record(link),
user('one'),
record(link, { wasCancelled: true }),
record(link, { parentToolCallId: 'agent' }),
record(link, { toolName: 'web_search' }),
];
expect(
ids(
getSourcesByTurn(tools, getSourceEntries([link], []), cwd, 'session'),
'one',
),
).toEqual([]);
});
it('intersects old records with the current panel and never reconstructs deleted sources', () => {
expect(
ids(
getSourcesByTurn(
[user('one'), record(link)],
getSourceEntries([file], []),
cwd,
'session',
),
'one',
),
).toEqual([]);
});
it('falls back to normalized locators for forked IDs with matching workspace authority', () => {
const copied = { ...file, id: 'c'.repeat(64) };
const messages = [
user('one'),
record(file, {
args: {
locator: {
type: 'workspace_file',
workspacePath: 'docs/./extra/../guide.md',
},
},
}),
];
expect(
ids(
getSourcesByTurn(messages, getSourceEntries([copied], []), cwd, 'fork'),
'one',
),
).toEqual([copied.id]);
expect(
ids(
getSourcesByTurn(
messages,
getSourceEntries([copied], []),
'/other',
'fork',
),
'one',
),
).toEqual([]);
expect(
ids(
getSourcesByTurn(
messages,
getSourceEntries([copied], []),
undefined,
'fork',
),
'one',
),
).toEqual([]);
});
it('normalizes URL locators without stripping anchors or merging distinct locations', () => {
const copy = { ...link, id: 'd'.repeat(64) };
const messages = [
user('one'),
record(link, {
rawOutput: undefined,
args: {
locator: { type: 'url', url: 'https://EXAMPLE.com:443/guide' },
},
}),
];
expect(
ids(
getSourcesByTurn(messages, getSourceEntries([copy], []), cwd, 'fork'),
'one',
),
).toEqual([copy.id]);
expect(
ids(
getSourcesByTurn(
messages,
getSourceEntries(
[
{
...copy,
locator: {
type: 'url',
url: 'https://example.com/guide#other',
},
},
],
[],
),
cwd,
'fork',
),
'one',
),
).toEqual([]);
});
it('uses the same registered/attachment fallback deduplication as the Sources panel', () => {
const registered: SessionSource = {
...file,
locator: { type: 'attachment', attachmentId: attachment.attachmentId },
};
const orphan = { ...attachment, attachmentId: 'other.png' };
const entries = getSourceEntries(
[registered],
[attachment, attachment, orphan],
);
expect(entries).toHaveLength(2);
const messages: Message[] = [
{
...user('one'),
role: 'user',
images: [
{
data: '',
mimeType: 'image/png',
attachmentId: attachment.attachmentId,
},
{ data: '', mimeType: 'image/png', attachmentId: 'other.png' },
{ data: '', mimeType: 'image/png', attachmentId: 'unknown.png' },
],
},
];
expect(
ids(getSourcesByTurn(messages, entries, cwd, 'session'), 'one'),
).toEqual([registered.id, 'attachment:other.png']);
});
it('accepts host references only for the specified session, loaded turn and existing source', () => {
const map = getSourcesByTurn(
[user('one'), user('two')],
getSourceEntries([link, file], []),
cwd,
'session',
[
{ sessionId: 'session', turnId: 'one', sourceId: link.id },
{ sessionId: 'session', turnId: 'one', sourceId: link.id },
{ sessionId: 'other', turnId: 'two', sourceId: file.id },
{ sessionId: 'session', turnId: 'unloaded', sourceId: file.id },
{ sessionId: 'session', turnId: 'two', sourceId: 'deleted' },
],
);
expect(ids(map, 'one')).toEqual([link.id]);
expect(ids(map, 'two')).toEqual([]);
expect(map.has('unloaded')).toBe(false);
});
});
@@ -0,0 +1,171 @@
import type {
DaemonSessionAttachmentReference,
SessionSource,
} from '@qwen-code/sdk/daemon';
import type {
WebShellSource,
WebShellSourceReference,
} from '../../customization';
import type { ACPToolCall, Message } from '../../adapters/types';
export function sourceKey(entry: WebShellSource): string {
return entry.type === 'source'
? entry.source.id
: `attachment:${entry.attachment.attachmentId}`;
}
export function sourceTitle(entry: WebShellSource): string {
return entry.type === 'source'
? entry.source.title
: entry.attachment.attachmentId;
}
export function sourceLocation(entry: WebShellSource): string {
if (entry.type === 'attachment') return entry.attachment.attachmentId;
const locator = entry.source.locator;
return locator.type === 'url'
? locator.url
: locator.type === 'workspace_file'
? locator.workspacePath
: locator.attachmentId;
}
export function getSourceEntries(
sources: readonly SessionSource[],
attachments: readonly DaemonSessionAttachmentReference[],
): WebShellSource[] {
const ids = new Set(
sources.flatMap((source) =>
source.locator.type === 'attachment' ? [source.locator.attachmentId] : [],
),
);
const entries: WebShellSource[] = sources.map((source) => ({
type: 'source',
source,
}));
for (const attachment of attachments) {
if (ids.has(attachment.attachmentId)) continue;
ids.add(attachment.attachmentId);
entries.push({ type: 'attachment', attachment });
}
return entries;
}
function locatorKey(value: unknown): string | undefined {
if (!value || typeof value !== 'object') return;
const locator = value as Record<string, unknown>;
if (locator['type'] === 'url' && typeof locator['url'] === 'string') {
try {
const url = new URL(locator['url']);
if (url.protocol === 'https:' || url.protocol === 'http:')
return `url:${url.href}`;
} catch {
/* Unresolved tool input cannot identify a registered source. */
}
}
if (
locator['type'] === 'workspace_file' &&
typeof locator['workspacePath'] === 'string'
) {
const path = locator['workspacePath'].replaceAll('\\', '/');
if (path.startsWith('/') || /^[a-z]:/i.test(path)) return;
const parts: string[] = [];
for (const part of path.split('/')) {
if (!part || part === '.') continue;
if (part === '..') {
if (!parts.length) return;
parts.pop();
} else parts.push(part);
}
if (parts.length) return `file:${parts.join('/')}`;
}
return;
}
function returnedSourceId(tool: ACPToolCall): string | undefined {
const text = [
typeof tool.rawOutput === 'string' ? tool.rawOutput : '',
...(tool.content ?? []).map((part) => part.content?.text ?? ''),
].join('\n');
return /(?:^|\n)Reference added: ([a-f0-9]{64})(?:\s|$)/.exec(text)?.[1];
}
export function getSourcesByTurn(
messages: readonly Message[],
entries: readonly WebShellSource[],
workspaceCwd: string | undefined,
sessionId: string | undefined,
references: readonly WebShellSourceReference[] = [],
): ReadonlyMap<string, readonly WebShellSource[]> {
if (!entries.length) return new Map();
const byId = new Map(
entries
.filter((entry) => entry.type === 'source')
.map((entry) => [sourceKey(entry), entry]),
);
const byLocator = new Map<string, WebShellSource>();
const byAttachment = new Map<string, WebShellSource>();
for (const entry of entries) {
if (entry.type === 'attachment')
byAttachment.set(entry.attachment.attachmentId, entry);
else {
if (entry.source.locator.type === 'attachment')
byAttachment.set(entry.source.locator.attachmentId, entry);
if (
entry.source.locator.type === 'workspace_file' &&
(!workspaceCwd || entry.source.workspaceCwd !== workspaceCwd)
)
continue;
const key = locatorKey(entry.source.locator);
if (key) byLocator.set(key, entry);
}
}
const turns = new Map<string, Set<string>>();
let turnId: string | undefined;
const add = (id: string, source: WebShellSource | undefined) => {
if (source) turns.get(id)?.add(sourceKey(source));
};
for (const message of messages) {
if (message.role === 'user' || message.role === 'user_shell') {
turnId = message.id;
turns.set(turnId, new Set());
if (message.role === 'user') {
for (const attachment of [
...(message.images ?? []),
...(message.files ?? []),
]) {
if (attachment.attachmentId)
add(turnId, byAttachment.get(attachment.attachmentId));
}
}
continue;
}
if (!turnId || message.role !== 'tool_group') continue;
for (const tool of message.tools) {
if (
tool.toolName !== 'record_source' ||
tool.status !== 'completed' ||
tool.wasCancelled ||
tool.parentToolCallId
)
continue;
const id = returnedSourceId(tool);
const key = locatorKey(tool.args?.['locator']);
add(
turnId,
(id ? byId.get(id) : undefined) ??
(key ? byLocator.get(key) : undefined),
);
}
}
for (const reference of references) {
if (sessionId && reference.sessionId === sessionId)
add(reference.turnId, byId.get(reference.sourceId));
}
return new Map(
[...turns].map(([id, keys]) => [
id,
entries.filter((entry) => keys.has(sourceKey(entry))),
]),
);
}
+68 -2
View File
@@ -14,6 +14,8 @@ import type { MarkdownChartReactErrorHandler } from '@datafe-open/markdown-chart
import type {
DaemonInputAnnotation,
DaemonSessionArtifact,
DaemonSessionAttachmentReference,
SessionSource,
GoalSnapshotV2,
} from '@qwen-code/sdk/daemon';
import type { DaemonStreamingState } from '@qwen-code/web-shell/daemon-react-sdk';
@@ -68,7 +70,63 @@ export interface WebShellMarkdownChartCustomization {
chartStyle?: CSSProperties;
}
export interface WebShellFootnote {
readonly id: string;
readonly number: number;
readonly definitionMarkdown: string;
readonly title?: string;
readonly summary: string;
readonly href?: string;
readonly source?: string;
readonly image?: string;
}
/** Synchronous, side-effect-free selection for a complete group of footnotes. */
export type WebShellFootnoteIconResolver = (
footnotes: readonly WebShellFootnote[],
) => WebShellIconSource | null | undefined;
export type WebShellSource =
| { readonly type: 'source'; readonly source: SessionSource }
| {
readonly type: 'attachment';
readonly attachment: DaemonSessionAttachmentReference;
};
export interface WebShellSourceReference {
readonly sessionId: string;
readonly turnId: string;
readonly sourceId: string;
}
export type WebShellSourceIconResolver = (
sources: readonly WebShellSource[],
) => WebShellIconSource | null | undefined;
export interface WebShellFootnotePreviewInfo {
readonly footnotes: readonly WebShellFootnote[];
readonly footnote: WebShellFootnote;
readonly index: number;
readonly title: string;
readonly sourceLabel: string;
/** Place this Qwen-managed element in the layout without replacing its children. */
readonly sourceLink: HTMLElement;
}
export interface WebShellFootnotePreviewHandle {
update(info: WebShellFootnotePreviewInfo): void;
dispose(): void;
}
/** Mounts current-page content only. Keep this synchronous function stable. */
export type WebShellFootnotePreviewMount = (
container: HTMLElement,
info: WebShellFootnotePreviewInfo,
) => WebShellFootnotePreviewHandle | null | undefined;
export interface WebShellMarkdownCustomization {
getInlineFootnoteIcon?: WebShellFootnoteIconResolver;
mountFootnotePreview?: WebShellFootnotePreviewMount;
transformMarkdown?: (
markdown: string,
context: MarkdownRenderContext,
@@ -83,7 +141,9 @@ export interface WebShellMarkdownCustomization {
/**
* Custom markdown components override Web Shell's built-ins. In particular,
* `components.code` replaces the default code renderer, so `renderCodeBlock`
* will not be called for that source.
* will not be called for that source. Internal footnote references and
* backreferences keep the built-in link behavior. Providing `components.sup`
* disables footnote grouping.
*/
components?: Components;
remarkPlugins?: Options['remarkPlugins'];
@@ -152,7 +212,11 @@ export type WebShellEnvironmentPanelItem =
| 'artifacts';
export interface WebShellEnvironmentPanelOptions {
/** Sections to show. Sources includes attachments; both keys render one section. */
/**
* Panel sections to show. Sources includes attachments; both keys render one
* section. Omitting both keys does not disable the turn source footer or its
* metadata loading.
*/
items?: readonly WebShellEnvironmentPanelItem[];
}
@@ -576,6 +640,8 @@ export interface WebShellCustomization {
renderComposerTagTooltip?: ComposerTagRenderer;
onComposerTagClick?: ComposerTagClickHandler;
renderAssistantTurnFooter?: AssistantTurnFooterRenderer;
getAssistantSourcesIcon?: WebShellSourceIconResolver;
sourceReferences?: readonly WebShellSourceReference[];
renderComposerToolbarStart?: ComposerToolbarStartRenderer;
renderComposerToolbarEnd?: ComposerToolbarEndRenderer;
renderComposerToolbarRight?: ComposerToolbarRightRenderer;
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
<g transform="translate(2.06 .65) scale(.74 .82)" stroke="currentColor" stroke-width="1.6">
<rect x="1.2" y="8.8" width="6" height="6" />
<rect x="1.2" y="2.8" width="6" height="6" />
<g transform="matrix(.8660254 .5 -.5 .8660254 2.21032266 -5.24903863)">
<rect x="10.9" y="1.5" width="4.3" height="4.3" />
</g>
<rect x="7.2" y="8.8" width="6" height="6" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 473 B

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Source footnote demo</title>
</head>
<body style="margin: 0">
<div id="root"></div>
<script type="module" src="/e2e/footnote-citation-demo.tsx"></script>
</body>
</html>
@@ -0,0 +1,330 @@
import { useMemo, useState, type ComponentProps } from 'react';
import { createRoot } from 'react-dom/client';
import type {
DaemonTranscriptBlock,
SessionSource,
} from '@qwen-code/sdk/daemon';
import type { ExtraProps } from 'react-markdown';
import { WebShellTranscript } from '../components/WebShellTranscript';
import type {
WebShellFootnoteIconResolver,
WebShellSource,
WebShellSourceIconResolver,
} from '../customization';
import knowledgeIconUrl from '../assets/icons/knowledge.svg?url&no-inline';
import fileIconUrl from '../assets/icons/at-file.svg?url&no-inline';
import referencesIconUrl from './assets/citation-references.svg?url&no-inline';
import { mountDemoFootnotePreview } from './footnote-preview-host';
import { SourceList } from '../components/sources/SourceList';
import { getSourceEntries } from '../components/sources/sourceEntries';
import { I18nProvider } from '../i18n';
const getInlineFootnoteIcon: WebShellFootnoteIconResolver = (footnotes) =>
footnotes.every((note) => note.href?.startsWith(sentinelPrefix))
? knowledgeIconUrl
: fileIconUrl;
const getAssistantSourcesIcon: WebShellSourceIconResolver = () =>
referencesIconUrl;
const sentinelPrefix = 'https://citation.invalid/dataworks-knowledge#';
const report = `## 订单主题分析
订单表需要遵循统一的业务定义和状态口径。[^a][^b]
资源组规格会影响任务可用并发,需要结合运行规模选择。[^c]
[^a]: [订单业务定义](<https://citation.invalid/dataworks-knowledge#v=1&kind=semantic&kbInstanceId=instance-a&docId=kb%3Aorder> "DataWorks Knowledge") — 用户提交交易后形成的业务订单定义。
[^b]: [订单规范原文](<https://citation.invalid/dataworks-knowledge#v=1&kind=content&kbInstanceId=instance-a&sourceFileId=file-order&citationId=citation-order&relativePath=docs%2Forder.md&anchor=markdown%3Ablock%3A7> "DataWorks Knowledge") — 订单状态字段及约束说明。
[^c]: [资源组规格说明](https://example.com/resource-groups) — 不同规格对应不同并发上限。
`;
function block(
value: Omit<
DaemonTranscriptBlock,
'clientReceivedAt' | 'createdAt' | 'updatedAt'
>,
timestamp: number,
): DaemonTranscriptBlock {
return {
...value,
clientReceivedAt: timestamp,
createdAt: timestamp,
updatedAt: timestamp,
} as DaemonTranscriptBlock;
}
const demoTimestamp = Date.now();
const blocks: DaemonTranscriptBlock[] = [
block(
{ id: 'demo-user', kind: 'user', text: '生成带知识来源的订单分析。' },
demoTimestamp,
),
block(
{ id: 'demo-assistant', kind: 'assistant', text: report },
demoTimestamp + 1,
),
];
const sourceSessionId = 'demo-session';
const sources: SessionSource[] = [
{
id: 'definition',
title: '订单业务定义',
kind: 'link',
locator: { type: 'url', url: 'https://example.com/order-definition' },
description: '之前已登记,本轮再次关联。',
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
},
{
id: 'policy',
title: '订单规范原文',
kind: 'link',
locator: { type: 'url', url: 'https://example.com/order-policy' },
createdAt: '2026-09-10T00:00:00Z',
updatedAt: '2026-09-10T00:00:00Z',
},
{
id: 'resources',
title: '资源组规格说明',
kind: 'link',
locator: { type: 'url', url: 'https://example.com/resource-groups' },
createdAt: '2026-09-10T00:00:00Z',
updatedAt: '2026-09-10T00:00:00Z',
},
{
id: 'orders-data',
title: '订单样例.csv',
kind: 'file',
locator: { type: 'workspace_file', workspacePath: 'data/orders.csv' },
workspaceCwd: '/demo',
createdAt: '2026-09-10T00:00:00Z',
updatedAt: '2026-09-10T00:00:00Z',
},
{
id: 'previous-only',
title: '上一轮背景资料.pdf',
kind: 'file',
locator: { type: 'workspace_file', workspacePath: 'docs/background.pdf' },
workspaceCwd: '/demo',
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
},
];
const sourceReferences = sources.slice(0, 4).map((source) => ({
sessionId: sourceSessionId,
turnId: 'demo-user',
sourceId: source.id,
}));
const sourceEntries = getSourceEntries(sources, []);
interface ResolvedLocator {
source?: boolean;
title: string;
fields: Array<[string, string]>;
}
function App() {
const [resolved, setResolved] = useState<ResolvedLocator>();
const [showSources, setShowSources] = useState(false);
const openSource = (entry: WebShellSource) => {
if (entry.type !== 'source') return;
const source = entry.source;
setResolved({
source: true,
title: source.title,
fields: Object.entries(source.locator).map(([key, value]) => [
key,
String(value),
]),
});
};
const [customPreview, setCustomPreview] = useState(
() => new URLSearchParams(location.search).get('preview') === 'custom',
);
const markdown = useMemo(
() => ({
getInlineFootnoteIcon,
mountFootnotePreview: customPreview
? mountDemoFootnotePreview
: undefined,
components: {
a({ href, children, className }: ComponentProps<'a'> & ExtraProps) {
if (href?.startsWith(sentinelPrefix)) {
const open = () => {
const url = new URL(href);
setResolved({
title: String(children),
fields: [...new URLSearchParams(url.hash.slice(1)).entries()],
});
};
return (
<a
href="#"
role="button"
className={className}
onClick={(event) => {
event.preventDefault();
open();
}}
onAuxClick={(event) => {
if (event.button !== 1) return;
event.preventDefault();
open();
}}
>
{children}
</a>
);
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
},
},
}),
[customPreview],
);
return (
<main
style={{
minHeight: '100vh',
display: 'grid',
gridTemplateColumns:
resolved || showSources ? 'minmax(0, 1fr) 380px' : '1fr',
background: '#0b0b0c',
color: '#f6f6f6',
}}
>
<label
style={{
position: 'fixed',
top: 12,
left: 20,
zIndex: 10,
display: 'flex',
gap: 6,
alignItems: 'center',
fontSize: 12,
color: '#aeb0bb',
}}
>
<input
type="checkbox"
checked={customPreview}
onChange={(event) => setCustomPreview(event.target.checked)}
/>
使用宿主卡片
</label>
<button
type="button"
onClick={() => setShowSources((value) => !value)}
style={{
position: 'fixed',
top: 12,
left: 150,
zIndex: 10,
fontSize: 12,
color: '#aeb0bb',
}}
>
会话来源 {sources.length}
</button>
<WebShellTranscript
blocks={blocks}
theme="dark"
language="zh-CN"
collapseCompletedTurns={false}
markdown={markdown}
sourceSessionId={sourceSessionId}
sources={sources}
sourceReferences={sourceReferences}
getAssistantSourcesIcon={getAssistantSourcesIcon}
onSourceOpen={openSource}
workspaceCwd="/demo"
style={{ minHeight: '100vh' }}
/>
{(resolved || showSources) && (
<aside
data-demo-source-panel=""
style={{
borderLeft: '1px solid #303034',
background: '#151517',
padding: 24,
}}
>
<div style={{ color: '#9b9ba3', fontSize: 12 }}>
来源面板 / 宿主预览 Demo
</div>
{showSources && (
<I18nProvider language="zh-CN">
<div data-demo-sources-panel="">
<h2>会话来源 {sources.length}</h2>
<SourceList entries={sourceEntries} onOpen={openSource} />
</div>
</I18nProvider>
)}
{resolved && (
<>
<h2 style={{ margin: '12px 0 8px', fontSize: 18 }}>
{resolved.title}
</h2>
<p style={{ color: '#b8b8c0', fontSize: 14, lineHeight: 1.6 }}>
{resolved.source
? '与会话来源面板使用同一条来源记录。'
: 'OpenCode 已接管 locator。实际产品将在这里校验当前会话并拼接真实来源 URL。'}
</p>
<dl style={{ display: 'grid', gap: 10, marginTop: 24 }}>
{resolved.fields.map(([key, value]) => (
<div key={key}>
<dt style={{ color: '#85858e', fontSize: 12 }}>{key}</dt>
<dd
style={{
margin: '3px 0 0',
overflowWrap: 'anywhere',
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
fontSize: 12,
}}
>
{value}
</dd>
</div>
))}
</dl>
</>
)}
<button
type="button"
onClick={() => {
setResolved(undefined);
setShowSources(false);
}}
style={{
marginTop: 28,
border: '1px solid #3c3c42',
borderRadius: 8,
background: '#222226',
color: '#fff',
padding: '8px 12px',
cursor: 'pointer',
}}
>
关闭面板
</button>
</aside>
)}
</main>
);
}
const root = createRoot(document.getElementById('root')!);
root.render(<App />);
@@ -0,0 +1,64 @@
import type {
WebShellFootnotePreviewMount,
WebShellFootnotePreviewInfo,
} from '../customization';
let nextMountId = 0;
export const mountDemoFootnotePreview: WebShellFootnotePreviewMount = (
container,
initial,
) => {
const document = container.ownerDocument;
const content = document.createElement('article');
content.dataset['demoPreviewContent'] = '';
content.dataset['mountId'] = String(++nextMountId);
content.dataset['mountWidth'] = String(container.clientWidth);
Object.assign(content.style, {
borderLeft: '3px solid #879af0',
padding: '4px 0 4px 12px',
display: 'grid',
gap: '10px',
});
const label = document.createElement('div');
label.style.cssText = 'font-size:11px;color:#9aa6d7;letter-spacing:.04em';
label.textContent = '宿主自定义内容';
const source = document.createElement('div');
source.style.cssText = 'font-size:12px;opacity:.7';
const title = document.createElement('div');
title.style.cssText = 'font-size:16px;line-height:1.5';
const summary = document.createElement('p');
summary.style.cssText = 'margin:0;font-size:13px;line-height:1.7';
const details = document.createElement('pre');
details.style.cssText =
'margin:0;white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px;opacity:.7';
details.hidden = true;
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.textContent = '查看脚注原文';
toggle.style.cssText =
'justify-self:start;font-size:12px;color:#a9b9ff;cursor:pointer';
const onToggle = () => {
details.hidden = !details.hidden;
toggle.textContent = details.hidden ? '查看脚注原文' : '收起脚注原文';
};
toggle.addEventListener('click', onToggle);
content.append(label, source, title, summary, toggle, details);
container.append(content);
const update = (info: WebShellFootnotePreviewInfo) => {
content.dataset['index'] = String(info.index);
content.dataset['ids'] = info.footnotes.map((note) => note.id).join(',');
source.textContent = info.sourceLabel;
title.replaceChildren(info.sourceLink);
summary.textContent = info.footnote.summary;
details.textContent = info.footnote.definitionMarkdown;
};
update(initial);
return {
update,
dispose() {
toggle.removeEventListener('click', onToggle);
content.remove();
},
};
};
@@ -0,0 +1,844 @@
import {
expect,
test,
type Locator,
type Page,
type TestInfo,
} from '@playwright/test';
import {
assistantTextEvent,
createWebShellDaemonScenario,
installMockDaemon,
replayCompleteEvent,
turnCompleteEvent,
userTextEvent,
} from './utils/mockDaemon';
const fixture = `## Citation preview acceptance
Adjacent sources[^1][^ticket] [^1][^说明]. Separated sources[^1], text[^ticket].
| Dimension | Conclusion |
| --- | --- |
| Official news | Current tour information[^1][^ticket][^说明] |
| Public appearances | Single source[^ticket] |
Ordinary explanation[^note].
[^1]: [Macao Government Tourism Office](https://tourism.example.test/event) Official concert details and final show dates. ![Concert poster](/e2e/footnote-concert.png)
[^ticket]: [Official ticket website](https://tickets.example.test/show) Tickets and venue information.
[^说明]: This is a plain note without a link or image.
[^note]: This ordinary footnote also has a preview.
`;
const triggerSelector = '[data-web-shell-footnote-trigger]';
const cardSelector = '[data-web-shell-footnote-card]';
async function openFixture(
page: Page,
testInfo: TestInfo,
options: {
text?: string;
theme?: 'light' | 'dark';
language?: 'en' | 'zh-CN';
shadow?: boolean;
secondMessage?: boolean;
narrow?: boolean;
brokenImage?: boolean;
} = {},
) {
await page.route('**/workspace/models', async (route) => {
if (route.request().method() === 'GET')
await route.fulfill({ json: { models: [] } });
else await route.fallback();
});
await page.setViewportSize(
options.narrow
? { width: 390, height: 844 }
: { width: 1365, height: 1100 },
);
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (
message.type() === 'error' &&
!(
options.brokenImage &&
message.location().url.includes('/e2e/footnote-concert.png')
)
) {
errors.push(message.text());
}
});
await page.route('**/e2e/footnote-concert.png', async (route) => {
if (options.brokenImage) {
await route.abort('failed');
return;
}
await route.fulfill({
contentType: 'image/png',
body: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6ZQAAAABJRU5ErkJggg==',
'base64',
),
});
});
const scenario = createWebShellDaemonScenario({
events: [
userTextEvent('Show citation preview acceptance.', { id: 1 }),
assistantTextEvent(options.text ?? fixture, { id: 2 }),
turnCompleteEvent('prompt-footnotes', { id: 3 }),
...(options.secondMessage
? [
userTextEvent('Another message with the same note ID.', { id: 4 }),
assistantTextEvent(
'Second message source[^1].\n\n[^1]: **Second source** Second definition.',
{ id: 5 },
),
turnCompleteEvent('prompt-footnotes-second', { id: 6 }),
]
: []),
],
});
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
if (options.shadow) {
await page.goto('/e2e/session-overview-shadow-dom.html');
await page
.getByRole('button', {
name: `${scenario.displayName} More actions`,
exact: true,
})
.click();
} else {
await page.goto(
`/session/${encodeURIComponent(scenario.sessionId)}?theme=${options.theme ?? 'dark'}&lang=${options.language ?? 'en'}`,
);
}
const connection = await daemon.sse.waitForConnection(scenario.sessionId);
await daemon.sendEvent(
replayCompleteEvent({
sessionId: connection.sessionId,
replayedCount: scenario.events.length,
}),
);
await expect(page.getByText('Loading...', { exact: true })).toHaveCount(0);
await expect(
page.getByRole('heading', { name: 'Citation preview acceptance' }),
).toBeVisible();
return { daemon, scenario, errors };
}
test('footnote baseline before grouped previews', async ({
page,
}, testInfo) => {
test.skip(
process.env['FOOTNOTE_BASELINE'] !== '1',
'Before-change capture only.',
);
const { errors } = await openFixture(page, testInfo);
await expect(page.locator('sup a[href^="#user-content-fn-"]')).toHaveCount(
11,
);
await expect(page.locator('[data-footnotes] li')).toHaveCount(4);
await page.locator('sup a[href^="#user-content-fn-"]').first().hover();
await expect(page.getByRole('dialog')).toHaveCount(0);
await page.screenshot({
path: testInfo.outputPath('footnotes-before-dark.png'),
fullPage: true,
animations: 'disabled',
});
expect(errors).toEqual([]);
});
test.describe('footnote source previews', () => {
test.skip(
process.env['FOOTNOTE_BASELINE'] === '1',
'After-change acceptance only.',
);
test('groups paragraph and table references and paginates on hover', async ({
page,
}, testInfo) => {
const { errors } = await openFixture(page, testInfo);
const triggers = page.locator(triggerSelector);
await expect(triggers).toHaveCount(6);
await expect(triggers).toHaveText(['3', '', '', '3', '', '']);
await expect(page.locator('[data-footnotes] li')).toHaveCount(0);
await expect(
page.locator('[data-web-shell-turn-sources-trigger]'),
).toHaveCount(0);
await triggers.first().hover();
const card = page.locator(cardSelector);
await expect(card).toBeVisible();
await expect(
card.getByRole('link', { name: 'Macao Government Tourism Office' }),
).toHaveAttribute('href', 'https://tourism.example.test/event');
await expect(card).toContainText('tourism.example.test');
await expect(card).toContainText(
'Official concert details and final show dates.',
);
await expect(card.locator('img')).toBeVisible();
await expect
.poll(() =>
card
.locator('img')
.evaluate((image) => (image as HTMLImageElement).naturalWidth),
)
.toBeGreaterThan(0);
await expect(
card.getByRole('button', { name: /Previous/i }),
).toBeDisabled();
await card.hover();
await page.waitForTimeout(300);
await expect(card).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('footnotes-after-dark.png'),
fullPage: true,
animations: 'disabled',
});
await card.getByRole('button', { name: /Next/i }).click();
await expect(card).toContainText('Official ticket website');
await expect(card).not.toContainText('Macao Government Tourism Office');
await expect(card.locator('img')).toHaveCount(0);
await card.getByRole('button', { name: /Next/i }).click();
await expect(card).toContainText('Footnote 3');
await expect(card).toContainText(
'This is a plain note without a link or image.',
);
await expect(card.getByRole('button', { name: /Next/i })).toBeDisabled();
await card.getByRole('button', { name: /Previous/i }).click();
await expect(card).toContainText('Official ticket website');
await page.keyboard.press('Escape');
await expect(card).toHaveCount(0);
await page.mouse.move(0, 0);
await triggers.first().hover();
await expect(card).toContainText('Macao Government Tourism Office');
await page.mouse.move(0, 0);
await page
.getByRole('heading', { name: 'Citation preview acceptance' })
.click();
await expect(card).toHaveCount(0);
const tableTrigger = page
.getByRole('table')
.locator(triggerSelector)
.first();
await tableTrigger.hover();
await expect(card).toContainText('Macao Government Tourism Office');
await card.getByRole('button', { name: /Next/i }).click();
await expect(card).toContainText('Official ticket website');
await page.screenshot({
path: testInfo.outputPath('footnotes-table-page-two.png'),
fullPage: true,
animations: 'disabled',
});
expect(errors).toEqual([]);
});
test('keyboard opening and message citation lists stay isolated', async ({
page,
}, testInfo) => {
const { errors } = await openFixture(page, testInfo, {
secondMessage: true,
});
const triggers = page.locator(triggerSelector);
await expect(triggers).toHaveCount(7);
await triggers.first().focus();
const card = page.locator(cardSelector);
await expect(card).toBeVisible();
await page.keyboard.press('Escape');
await expect(card).toHaveCount(0);
await triggers.nth(1).click();
await expect(card).toBeVisible();
await expect(
card.getByRole('button', { name: /Next|Previous/i }),
).toHaveCount(0);
await page.keyboard.press('Escape');
await page.locator('body').click({ position: { x: 1, y: 1 } });
await expect(card).toHaveCount(0);
const ids = await triggers.evaluateAll((elements) =>
elements.map((element) => element.id),
);
expect(new Set(ids).size).toBe(ids.length);
await expect(page.locator('[data-footnotes]')).toHaveCount(0);
await triggers.last().click();
await expect(card).toContainText('Second definition.');
await expect(card).not.toContainText('Official concert details');
expect(errors).toEqual([]);
});
for (const key of ['Enter', 'ArrowDown', 'Space']) {
test(`keyboard reaches pagination after opening a reference with ${key}`, async ({
page,
}, testInfo) => {
const { errors } = await openFixture(page, testInfo);
await page.locator(triggerSelector).first().focus();
const initialCard = page.locator(cardSelector);
await expect(initialCard).toBeVisible();
const cardId = await initialCard.getAttribute('id');
const card = page.locator(`[id="${cardId}"]`);
await page.keyboard.press(key);
await page.keyboard.press('Tab');
await expect
.poll(() =>
card.evaluateAll((elements) =>
elements.some((element) => element.matches(':focus-within')),
),
)
.toBe(true);
const next = card.getByRole('button', { name: /Next/i });
for (
let attempts = 0;
attempts < 3 &&
!(await next.evaluate((element) => element.matches(':focus')));
attempts++
) {
await page.keyboard.press('Tab');
}
await expect(next).toBeFocused();
await page.keyboard.press('Enter');
await expect(card).toContainText('Official ticket website');
expect(errors).toEqual([]);
});
}
test('streaming definitions create a group and preserve a selected source', async ({
page,
}, testInfo) => {
const { daemon, errors } = await openFixture(page, testInfo);
const editor = page.locator('[data-web-shell-composer-editor] .cm-content');
await editor.fill('Stream the next answer.');
await page.locator('[data-web-shell-composer-submit]').click();
await expect.poll(() => daemon.promptRequests().length).toBe(1);
await daemon.sse.split(
assistantTextEvent(
'Streaming citations[^source-first][^source-second][^source-third].',
{
id: 4,
},
),
);
const list = page.locator('[data-web-shell-message-list]');
await expect(list).toContainText(
'Streaming citations[^source-first][^source-second][^source-third].',
);
await daemon.sse.split(
assistantTextEvent(
'\n\n[^source-first]: [First source](https://first.example.test) First summary.\n[^source-second]: [Second source](https://second.example.test) Second summary.',
{ id: 5 },
),
);
const streamTrigger = page.locator(triggerSelector).last();
await expect(streamTrigger).toHaveText('2');
await streamTrigger.hover();
const card = page.locator(cardSelector);
await expect(card).toContainText('First source');
await card.getByRole('button', { name: /Next/i }).click();
await expect(card).toContainText('Second source');
await daemon.sse.split(
assistantTextEvent(
'\n[^source-third]: [Third source](https://third.example.test) Third summary.',
{ id: 6 },
),
);
await expect(streamTrigger).toHaveText('3');
await expect(card).toContainText('Second source');
await card.getByRole('button', { name: /Next/i }).click();
await expect(card).toContainText('Third source');
await daemon.sse.split(turnCompleteEvent('prompt-e2e', { id: 7 }));
expect(errors).toEqual([]);
});
test('light and narrow cards stay within the viewport and load the SVG', async ({
page,
}, testInfo) => {
const { errors } = await openFixture(page, testInfo, {
theme: 'light',
narrow: true,
});
const trigger = page.locator(triggerSelector).first();
await trigger.click();
const card = page.locator(cardSelector);
await expect(card).toBeVisible();
await expectInsideViewport(page, card);
const icon = await trigger
.locator('[aria-hidden="true"]')
.first()
.evaluate(async (element) => {
const mask = getComputedStyle(element).maskImage;
const image = new Image();
const width = await new Promise<number>((resolve) => {
image.onload = () => resolve(image.naturalWidth);
image.onerror = () => resolve(0);
image.src = mask.slice(4, -1).replace(/^"|"$/g, '');
});
return { mask, width };
});
expect(icon.mask).toMatch(/svg|data:image/);
expect(icon.width).toBeGreaterThan(0);
await page.screenshot({
path: testInfo.outputPath('footnotes-after-light-narrow.png'),
fullPage: true,
animations: 'disabled',
});
expect(errors).toEqual([]);
});
test('touch taps open and paginate sources and the title opens its URL', async ({
browser,
}, testInfo) => {
const context = await browser.newContext({
baseURL: String(testInfo.project.use.baseURL),
hasTouch: true,
isMobile: true,
viewport: { width: 390, height: 844 },
});
try {
await context.route('https://tickets.example.test/**', async (route) => {
await route.fulfill({
contentType: 'text/html',
body: '<h1>Opened official ticket source</h1>',
});
});
const page = await context.newPage();
const { errors } = await openFixture(page, testInfo, { narrow: true });
await page.locator(triggerSelector).first().tap();
const card = page.locator(cardSelector);
await expect(card).toBeVisible();
await card.getByRole('button', { name: /Next/i }).tap();
await expect(card).toContainText('Official ticket website');
await expectInsideViewport(page, card);
await page.screenshot({
path: testInfo.outputPath('footnotes-touch-page-two.png'),
fullPage: true,
animations: 'disabled',
});
const popupPromise = page.waitForEvent('popup');
await card.getByRole('link', { name: 'Official ticket website' }).tap();
const popup = await popupPromise;
await expect(
popup.getByRole('heading', { name: 'Opened official ticket source' }),
).toBeVisible();
expect(popup.url()).toBe('https://tickets.example.test/show');
expect(errors).toEqual([]);
} finally {
await context.close();
}
});
test('Chinese language labels cover the trigger, pagination and plain-note title', async ({
page,
}, testInfo) => {
const { errors } = await openFixture(page, testInfo, { language: 'zh-CN' });
await page
.getByRole('button', { name: '查看 3 条引用', exact: true })
.first()
.hover();
const card = page.getByRole('dialog', { name: '引用预览', exact: true });
await expect(card).toBeVisible();
await expect(
card.getByRole('button', { name: '上一条引用', exact: true }),
).toBeDisabled();
const next = card.getByRole('button', { name: '下一条引用', exact: true });
await next.click();
await expect(card).toContainText('Official ticket website');
await next.click();
await expect(card).toContainText('脚注 3');
await expect(card).toContainText(
'This is a plain note without a link or image.',
);
await expect(next).toBeDisabled();
await page.screenshot({
path: testInfo.outputPath('footnotes-chinese-page-three.png'),
fullPage: true,
animations: 'disabled',
});
expect(errors).toEqual([]);
});
test('plain multiline notes preserve complete scrollable descriptions', async ({
page,
}, testInfo) => {
const explanation =
'A complete explanation with additional context. '.repeat(100);
const { errors } = await openFixture(page, testInfo, {
text: `## Citation preview acceptance\n\nDetails[^说明].\n\n[^说明]: First paragraph.\n\n ${explanation}End of the explanation.`,
});
const trigger = page.locator(triggerSelector);
await trigger.click();
const card = page.locator(cardSelector);
await expect(card).toContainText('Footnote 1');
await expect(card.locator('a')).toHaveCount(0);
const summary = card.locator('[data-web-shell-footnote-summary]');
await expect(summary).toContainText('First paragraph.');
await expect(summary).toContainText('End of the explanation.');
await expect(summary).toHaveCSS('overflow-y', 'auto');
expect(
await summary.evaluate(
(element) => element.scrollHeight > element.clientHeight,
),
).toBe(true);
await summary.focus();
await page.keyboard.press('End');
await expect
.poll(() => summary.evaluate((element) => element.scrollTop))
.toBeGreaterThan(0);
await expectInsideViewport(page, card);
expect(errors).toEqual([]);
});
test('failed thumbnails collapse and previewing sources does not fetch website metadata', async ({
page,
}, testInfo) => {
const sourceRequests: string[] = [];
page.on('request', (request) => {
if (/https:\/\/(tourism|tickets)\.example\.test/.test(request.url()))
sourceRequests.push(request.url());
});
const { errors } = await openFixture(page, testInfo, { brokenImage: true });
await page.locator(triggerSelector).first().hover();
const card = page.locator(cardSelector);
await expect(card).toBeVisible();
await expect(card.locator('img')).toHaveCount(0);
await expect(card).toContainText(
'Official concert details and final show dates.',
);
await card.getByRole('button', { name: /Next/i }).click();
await expect(card).toContainText('Official ticket website');
expect(sourceRequests).toEqual([]);
expect(errors).toEqual([]);
});
test('shadow DOM basic-table previews use the shell portal', async ({
page,
}, testInfo) => {
test.skip(
process.env['FOOTNOTE_BUILT'] === '1',
'The source-only shadow harness is not a production entrypoint.',
);
const { errors } = await openFixture(page, testInfo, { shadow: true });
await page.getByRole('table').locator(triggerSelector).first().hover();
const card = page.locator(cardSelector);
await expect(card).toBeVisible();
expect(
await card.evaluate(
(element) => element.getRootNode() instanceof ShadowRoot,
),
).toBe(true);
expect(
await card.evaluate((element) =>
Boolean(element.closest('[data-web-shell-portal-root]')),
),
).toBe(true);
await card.getByRole('button', { name: /Next/i }).click();
await expect(card).toContainText('Official ticket website');
await page.keyboard.press('Escape');
await expect(card).toHaveCount(0);
expect(errors).toEqual([]);
});
});
async function expectInsideViewport(page: Page, locator: Locator) {
const bounds = await locator.boundingBox();
const viewport = page.viewportSize();
expect(bounds).not.toBeNull();
expect(viewport).not.toBeNull();
expect(bounds!.x).toBeGreaterThanOrEqual(10);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport!.width - 10);
expect(bounds!.y).toBeGreaterThanOrEqual(0);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(viewport!.height);
}
test('source locator demo delegates the card link to its host panel', async ({
page,
}, testInfo) => {
test.skip(
process.env['FOOTNOTE_BUILT'] === '1',
'The interactive host demo is a source-only development entrypoint.',
);
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto('/e2e/footnote-citation-demo.html');
await expect(
page.getByRole('heading', { name: '订单主题分析' }),
).toBeVisible();
const triggers = page.locator(triggerSelector);
await expect(triggers).toHaveCount(2);
await expect(triggers.first()).toHaveText('2');
await expect(triggers.nth(1)).toHaveText('');
const footerSelectorForDemo = '[data-web-shell-turn-sources-trigger]';
const footerTrigger = page.locator(footerSelectorForDemo);
await expect(footerTrigger).toHaveText('4 个来源');
const assistantFooter = footerTrigger.locator('..');
await expect(assistantFooter).toHaveCSS('opacity', '0');
await expect(page.locator('[data-footnotes]')).toHaveCount(0);
const footerMetrics = await footerTrigger.evaluate((element) => {
const footer = element.parentElement!;
const citationIcon = element.querySelector<HTMLElement>(
'[aria-hidden="true"]',
)!;
const copyIcon = footer.querySelector<SVGElement>(
'button[aria-label="复制"] svg',
)!;
const time = footer.querySelector<HTMLElement>(
':scope > span[aria-hidden="true"]',
)!;
const center = (target: Element) => {
const rect = target.getBoundingClientRect();
return rect.top + rect.height / 2;
};
return {
citationFontSize: getComputedStyle(element).fontSize,
citationIconWidth: citationIcon.getBoundingClientRect().width,
copyIconWidth: copyIcon.getBoundingClientRect().width,
iconCenterOffset: center(citationIcon) - center(copyIcon),
textCenterDelta: Math.abs(center(element) - center(time)),
timeFontSize: getComputedStyle(time).fontSize,
};
});
expect(footerMetrics).toMatchObject({
citationFontSize: '11px',
citationIconWidth: 14,
copyIconWidth: 14,
timeFontSize: '11px',
});
expect(footerMetrics.iconCenterOffset).toBe(0);
expect(footerMetrics.textCenterDelta).toBeLessThan(0.1);
const painted = await assistantFooter.evaluate(async (footer) => {
const copy = footer.querySelector<SVGElement>(
'button[aria-label="复制"] svg',
)!;
const citation = footer.querySelector<HTMLElement>(
'[data-web-shell-turn-sources-trigger] > span',
)!;
const mask = getComputedStyle(citation).maskImage;
const maskUrl = mask.slice(4, -1).replace(/^"|"$/g, '');
const measure = async (markup: string, element: Element) => {
const svg = new DOMParser().parseFromString(
markup,
'image/svg+xml',
).documentElement;
const size = 512;
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(size));
const url = URL.createObjectURL(
new Blob([new XMLSerializer().serializeToString(svg)], {
type: 'image/svg+xml',
}),
);
try {
const image = new Image();
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () =>
reject(new Error('Cannot rasterize footer icon'));
image.src = url;
});
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const context = canvas.getContext('2d')!;
context.drawImage(image, 0, 0, size, size);
const pixels = context.getImageData(0, 0, size, size).data;
let minX = size,
minY = size,
maxX = -1,
maxY = -1,
alpha = 0,
momentY = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const opacity = pixels[(y * size + x) * 4 + 3];
if (opacity >= 16) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
alpha += opacity;
momentY += (y + 0.5) * opacity;
}
}
const rect = element.getBoundingClientRect();
return {
width: ((maxX - minX + 1) / size) * rect.width,
height: ((maxY - minY + 1) / size) * rect.height,
centerY: rect.y + ((minY + maxY + 1) / 2 / size) * rect.height,
centroidY: rect.y + (momentY / alpha / size) * rect.height,
};
} finally {
URL.revokeObjectURL(url);
}
};
return {
copy: await measure(new XMLSerializer().serializeToString(copy), copy),
citation: await measure(await (await fetch(maskUrl)).text(), citation),
};
});
expect(painted.copy.width).toBeGreaterThan(0);
expect(painted.citation.width).toBeGreaterThan(0);
expect(Math.abs(painted.citation.width - painted.copy.width)).toBeLessThan(
0.5,
);
expect(Math.abs(painted.citation.height - painted.copy.height)).toBeLessThan(
0.5,
);
expect(
Math.abs(painted.citation.centerY - painted.copy.centerY),
).toBeLessThan(0.4);
expect(
Math.abs(painted.citation.centroidY - painted.copy.centroidY),
).toBeLessThan(0.4);
const inlineMetrics = await triggers.nth(1).evaluate((element) => {
const icon = element.querySelector<HTMLElement>('[aria-hidden="true"]')!;
const triggerRect = element.getBoundingClientRect();
const iconRect = icon.getBoundingClientRect();
return {
centerDelta: Math.abs(
triggerRect.top +
triggerRect.height / 2 -
(iconRect.top + iconRect.height / 2),
),
verticalAlign: getComputedStyle(element).verticalAlign,
};
});
expect(inlineMetrics).toEqual({ centerDelta: 0, verticalAlign: 'middle' });
const iconResources = await page
.locator(`${triggerSelector} > span, ${footerSelectorForDemo} > span`)
.evaluateAll(async (elements) =>
Promise.all(
elements.map(async (element) => {
const mask = getComputedStyle(element).maskImage;
const image = new Image();
const width = await new Promise<number>((resolve) => {
image.onload = () => resolve(image.naturalWidth);
image.onerror = () => resolve(0);
image.src = mask.slice(4, -1).replace(/^"|"$/g, '');
});
return { mask, width };
}),
),
);
expect(iconResources).toHaveLength(3);
expect(new Set(iconResources.map((icon) => icon.mask)).size).toBe(3);
expect(iconResources.every((icon) => icon.width > 0)).toBe(true);
await page.screenshot({
path: testInfo.outputPath('source-footnote-host-demo-idle.png'),
fullPage: true,
animations: 'disabled',
});
await triggers.first().click();
const card = page.locator(cardSelector);
const sourceLink = card.getByRole('button', { name: '订单业务定义' });
await expect(sourceLink).toHaveAttribute('href', '#');
await sourceLink.click();
const hostPanel = page.locator('[data-demo-source-panel]');
await expect(hostPanel).toBeVisible();
await expect(hostPanel).toContainText('OpenCode 已接管 locator');
await expect(hostPanel).toContainText('semantic');
await expect(hostPanel).toContainText('instance-a');
await expect(hostPanel).toContainText('kb:order');
await page.keyboard.press('Escape');
await expect(card).toHaveCount(0);
await footerTrigger.hover();
await expect(assistantFooter).toHaveCSS('opacity', '1');
const sources = page.locator('[data-web-shell-turn-sources]');
await expect(sources).toBeVisible();
await expect(sources).toContainText('订单样例.csv');
await expect(sources).not.toContainText('上一轮背景资料.pdf');
await expect(sources.locator('li')).toHaveCount(4);
await page.screenshot({
path: testInfo.outputPath('source-footnote-host-demo.png'),
fullPage: true,
animations: 'disabled',
});
});
test('source locator demo custom page slot keeps grouping, pagination and host links', async ({
page,
}, testInfo) => {
test.skip(
process.env['FOOTNOTE_BUILT'] === '1',
'Production app does not include the host demo; use the built-library demo fixture.',
);
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.setViewportSize({ width: 1100, height: 760 });
await page.goto('/e2e/footnote-citation-demo.html?preview=custom');
const triggers = page.locator(triggerSelector);
await expect(triggers).toHaveText(['2', '']);
await expect(page.locator('[data-demo-preview-content]')).toHaveCount(0);
await triggers.first().hover();
const card = page.locator(cardSelector);
const content = card.locator('[data-demo-preview-content]');
await expect(content).toBeVisible();
expect(
Number(await content.getAttribute('data-mount-width')),
).toBeGreaterThan(0);
await expect(content).toHaveAttribute('data-ids', 'a,b');
await expect(content).toHaveAttribute('data-index', '0');
const mountId = await content.getAttribute('data-mount-id');
await content.getByRole('button', { name: '查看脚注原文' }).click();
await expect(content.locator('pre')).toContainText('[^a]:');
await card.getByRole('button', { name: '下一条引用' }).click();
await expect(content).toHaveAttribute('data-mount-id', mountId!);
await expect(content).toHaveAttribute('data-index', '1');
await expect(content.locator('pre')).toContainText('[^b]:');
await expect(card).toContainText('2 / 2');
await page.mouse.move(0, 0);
await page.waitForTimeout(300);
await expect(card).toBeVisible();
const source = content.getByRole('button', { name: '订单规范原文' });
await expect(source).toHaveAttribute('href', '#');
await source.click();
await expect(page.locator('[data-demo-source-panel]')).toContainText(
'file-order',
);
await expect(page.locator('[data-demo-source-panel]')).toContainText(
'markdown:block:7',
);
await page.keyboard.press('Escape');
await expect(content).toHaveCount(0);
await page.getByRole('button', { name: '关闭面板' }).click();
await triggers.nth(1).click();
await expect(content).toHaveAttribute('data-ids', 'c');
await expect(
content.getByRole('link', { name: '资源组规格说明' }),
).toHaveAttribute('href', 'https://example.com/resource-groups');
await page.keyboard.press('Escape');
await expect(card).toHaveCount(0);
await page.locator('[data-web-shell-turn-sources-trigger]').hover();
const sourceList = page.locator('[data-web-shell-turn-sources]');
await expect(sourceList).toBeVisible();
await expect(sourceList.locator('li')).toHaveCount(4);
await expect(page.locator('[data-demo-preview-content]')).toHaveCount(0);
await page.screenshot({
path: testInfo.outputPath('host-footnote-preview-content.png'),
fullPage: true,
animations: 'disabled',
});
await page.keyboard.press('Escape');
await page.mouse.move(0, 0);
await page.locator('body').click({ position: { x: 1, y: 1 } });
await expect(sourceList).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 });
await triggers.first().focus();
await page.keyboard.press('Enter');
await expect(
content.getByRole('button', { name: '订单业务定义' }),
).toBeFocused();
await expectInsideViewport(page, card);
await expect(content.locator('..')).toHaveCSS('overflow-y', 'auto');
await page.keyboard.press('Escape');
await expect(card).toHaveCount(0);
await page.getByRole('checkbox', { name: '使用宿主卡片' }).uncheck();
await triggers.first().click();
await expect(card.locator('[data-demo-preview-content]')).toHaveCount(0);
await expect(card.locator('[data-web-shell-footnote-summary]')).toBeVisible();
expect(errors).toEqual([]);
});
@@ -0,0 +1,326 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import type {
DaemonEvent,
DaemonSessionAttachmentReference,
SessionSource,
} from '@qwen-code/sdk/daemon';
import {
assistantTextEvent,
createWebShellDaemonScenario,
installMockDaemon,
replayCompleteEvent,
toolCallEvent,
turnCompleteEvent,
userTextEvent,
} from './utils/mockDaemon';
test.beforeEach(async ({ page }) => {
await page.route('**/workspace/models', async (route) => {
if (route.request().method() === 'GET')
await route.fulfill({ json: { models: [] } });
else await route.fallback();
});
});
const shared: SessionSource = {
id: 'a'.repeat(64),
kind: 'link',
title: 'Shared source',
description: 'A source registered before this turn and reused explicitly.',
locator: { type: 'url', url: 'https://example.test/shared' },
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
};
const current: SessionSource = {
...shared,
id: 'b'.repeat(64),
title: 'Current source',
description: 'The second source for this turn.',
locator: { type: 'url', url: 'https://example.test/current' },
};
const unrelated: SessionSource = {
...shared,
id: 'c'.repeat(64),
title: 'Unassociated source',
locator: { type: 'url', url: 'https://example.test/unassociated' },
};
const footer = '[data-web-shell-turn-sources-trigger]';
const popup = '[data-web-shell-turn-sources]';
async function openSources(page: Page, info: TestInfo) {
let sources = [shared, current, unrelated];
let revision = 1;
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
const record = (id: string, source: SessionSource, eventId: number) =>
toolCallEvent(
id,
'record_source',
{ title: source.title, locator: source.locator },
{ id: eventId, rawOutput: { text: `Reference added: ${source.id}` } },
);
const scenario = createWebShellDaemonScenario({
events: [
userTextEvent('First question', { id: 1 }),
record('first-register', shared, 2),
assistantTextEvent('First report without footnotes.', { id: 3 }),
turnCompleteEvent('one', { id: 4 }),
userTextEvent('Second question', { id: 5 }),
record('reuse-shared', shared, 6),
record('current-register', current, 7),
assistantTextEvent(
'Second report has one note[^a].\n\n[^a]: An explanatory footnote, separate from the source registry.',
{ id: 8 },
),
turnCompleteEvent('two', { id: 9 }),
],
});
scenario.capabilities.features.push(
'session_sources',
'session_attachment_list',
);
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(info.project.use.baseURL),
});
await page.route('**/session/*/sources*', async (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ revision, sources }),
}),
);
await page.route('**/session/*/attachments', async (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ attachments: [] }),
}),
);
await page.goto(`/session/${scenario.sessionId}?theme=dark&lang=en`);
const connection = await daemon.sse.waitForConnection(scenario.sessionId);
await daemon.sendEvent(
replayCompleteEvent({
sessionId: connection.sessionId,
replayedCount: scenario.events.length,
}),
);
await expect(page.getByText('First report without footnotes.')).toBeVisible();
await expect(page.locator(footer)).toHaveText(['1 source', '2 sources']);
return {
errors,
async replace(next: SessionSource[]) {
sources = next;
revision += 1;
await daemon.sendEvent({
v: 1,
type: 'source_changed',
data: { sessionId: scenario.sessionId, revision },
});
},
};
}
test('turn sources count explicit reuse independently of footnotes and open the existing preview', async ({
page,
}, info) => {
await page.setViewportSize({ width: 1400, height: 1000 });
const { errors } = await openSources(page, info);
const button = page.locator(footer).last();
await page.mouse.move(0, 0);
await expect(button.locator('..')).toHaveCSS('opacity', '0');
await button.hover();
await expect(button.locator('..')).toHaveCSS('opacity', '1');
const list = page.locator(popup);
await expect(list).toBeVisible();
await expect(list.locator('li')).toHaveCount(2);
await expect(list).toContainText(shared.title);
await expect(list).toContainText(current.title);
await expect(list).not.toContainText(unrelated.title);
expect(
await list
.locator('[tabindex="0"]')
.evaluate((element) => element.scrollWidth - element.clientWidth),
).toBe(0);
await expect(page.locator('[data-web-shell-footnote-trigger]')).toHaveCount(
1,
);
await page.screenshot({
path: info.outputPath('turn-sources-list.png'),
fullPage: true,
animations: 'disabled',
});
await button.focus();
await button.press('ArrowDown');
const row = list.getByRole('button', {
name: 'Open source Shared source',
exact: true,
});
await expect(row).toBeFocused();
await row.press('Enter');
await expect(button).toBeFocused();
await expect(list).toHaveCount(0);
await expect(
page.getByRole('heading', { name: shared.title, exact: true }),
).toBeVisible();
await expect(
page.getByText(shared.description!, { exact: true }),
).toBeVisible();
expect(errors).toEqual([]);
});
test('click-opened turn sources stay open when clicking does not transfer focus', async ({
page,
}, info) => {
const { errors } = await openSources(page, info);
const button = page.locator(footer).last();
await page.evaluate(() => (document.activeElement as HTMLElement)?.blur());
await button.evaluate((element) =>
element.addEventListener('mousedown', (event) => event.preventDefault(), {
once: true,
}),
);
await button.click();
await expect(button).not.toBeFocused();
await expect(page.locator(popup)).toBeVisible();
await page.mouse.move(0, 0);
await page.waitForTimeout(350);
await expect(page.locator(popup)).toBeVisible();
await page.locator('body').click({ position: { x: 1, y: 1 } });
await expect(page.locator(popup)).toHaveCount(0);
expect(errors).toEqual([]);
});
test('source refresh drops deleted entries without borrowing unassociated sources or footnotes', async ({
page,
}, info) => {
const { errors, replace } = await openSources(page, info);
await page.locator(footer).last().click();
await expect(page.locator(popup).locator('li')).toHaveCount(2);
await replace([shared, unrelated]);
await expect(page.locator(footer)).toHaveText(['1 source', '1 source']);
await expect(page.locator(popup).locator('li')).toHaveCount(1);
await expect(page.locator(popup)).not.toContainText(current.title);
await replace([unrelated]);
await expect(page.locator(footer)).toHaveCount(0);
await expect(page.locator(popup)).toHaveCount(0);
await expect(page.locator('[data-web-shell-footnote-trigger]')).toHaveCount(
1,
);
expect(errors).toEqual([]);
});
test('host sources match the session panel while the report keeps independent footnotes', async ({
page,
}, info) => {
test.skip(
process.env['FOOTNOTE_BUILT'] === '1',
'Host demo runs through the separately built transcript consumer.',
);
await page.goto('/e2e/footnote-citation-demo.html');
const button = page.locator(footer);
await expect(button).toHaveText('4 个来源');
await expect(page.locator('[data-web-shell-footnote-trigger]')).toHaveText([
'2',
'',
]);
await button.hover();
await expect(page.locator(popup).locator('li')).toHaveCount(4);
await expect(page.locator(popup)).toContainText('订单样例.csv');
await expect(page.locator(popup)).not.toContainText('上一轮背景资料.pdf');
await page
.locator(popup)
.getByRole('button', { name: '打开来源 订单样例.csv', exact: true })
.click();
await expect(page.locator('[data-demo-source-panel]')).toContainText(
'data/orders.csv',
);
await expect(page.locator('[data-demo-source-panel]')).toContainText(
'与会话来源面板使用同一条来源记录',
);
await page.getByRole('button', { name: '会话来源 5', exact: true }).click();
await expect(page.locator('[data-demo-sources-panel] li')).toHaveCount(5);
await page.screenshot({
path: info.outputPath('turn-sources-host-demo.png'),
fullPage: true,
animations: 'disabled',
});
});
test('attachment sources load and refresh without opening the environment panel', async ({
page,
}, info) => {
const attachment = (
attachmentId: string,
): DaemonSessionAttachmentReference => ({
type: 'resource',
attachmentId,
mimeType: 'text/plain',
size: 5,
});
const first = attachment('first.txt');
const second = attachment('second.txt');
let attachments = [first];
const uploaded = (
content: DaemonSessionAttachmentReference,
id: number,
): DaemonEvent => ({
id,
v: 1,
type: 'session_update',
data: { update: { sessionUpdate: 'user_message_chunk', content } },
});
const scenario = createWebShellDaemonScenario({
events: [
userTextEvent('Read the first attachment', { id: 1 }),
uploaded(first, 2),
assistantTextEvent('The first attachment report.', { id: 3 }),
turnCompleteEvent('one', { id: 4 }),
],
});
scenario.capabilities.features.push('session_attachment_list');
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(info.project.use.baseURL),
});
await page.route('**/session/*/attachments', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ attachments }),
}),
);
await page.goto(`/session/${scenario.sessionId}?theme=dark&lang=en`);
await daemon.sse.waitForConnection(scenario.sessionId);
await daemon.sendEvent(
replayCompleteEvent({
sessionId: scenario.sessionId,
replayedCount: scenario.events.length,
}),
);
await expect(page.locator(footer)).toHaveText(['1 source']);
await page.locator(footer).hover();
await expect(page.locator(popup)).toContainText('first.txt');
await page.keyboard.press('Escape');
await expect(page.locator(popup)).toHaveCount(0);
attachments = [first, second];
await daemon.sendEvent(
userTextEvent('Reuse the first and read the second', { id: 5 }),
);
await daemon.sendEvent(uploaded(first, 6));
await daemon.sendEvent(uploaded(second, 7));
await daemon.sendEvent(
assistantTextEvent('Both attachments report.', { id: 8 }),
);
await daemon.sendEvent(turnCompleteEvent('two', { id: 9 }));
await expect(page.locator(footer)).toHaveText(['1 source', '2 sources']);
await page.locator(footer).last().hover();
await expect(page.locator(popup).locator('li')).toHaveCount(2);
await expect(page.locator(popup)).toContainText('second.txt');
for (const panel of await page.getByTestId('environment-panel').all()) {
await expect(panel).not.toBeVisible();
}
await page.screenshot({
path: info.outputPath('turn-attachment-sources.png'),
animations: 'disabled',
});
});
+18
View File
@@ -16,6 +16,13 @@ type MessageValue =
type Messages = Record<string, MessageValue>;
const EN: Messages = {
'footnotes.note': (v) => `Footnote ${v?.number ?? ''}`,
'footnotes.references': (v) => `View ${v?.count ?? ''} references`,
'footnotes.preview': 'Reference preview',
'footnotes.previous': 'Previous reference',
'footnotes.next': 'Next reference',
'footnotes.citations': (v) =>
`${v?.count ?? 0} ${v?.count === 1 ? 'citation' : 'citations'}`,
'daemon.capacity.exhausted':
'The service has reached its concurrent capacity limit and cannot start this session. Try again later or cancel this operation.',
'git.currentBranch': (v) => `Current Git branch: ${v?.branch ?? ''}`,
@@ -3021,6 +3028,9 @@ const EN: Messages = {
'workflow.loadFailed': 'Failed to load agent workflow',
'environment.unavailable': 'Unavailable',
'sources.title': 'Sources',
'sources.count': (v) =>
`${v?.count ?? 0} ${(v?.count ?? 0) === 1 ? 'source' : 'sources'}`,
'sources.currentTurn': 'Sources for this turn',
'sources.add': 'Add source',
'sources.empty': 'Add files or links for reference.',
'sources.explanation':
@@ -3804,6 +3814,12 @@ const EN: Messages = {
const ZH: Messages = {
...EN,
'footnotes.note': (v) => `脚注 ${v?.number ?? ''}`,
'footnotes.references': (v) => `查看 ${v?.count ?? ''} 条引用`,
'footnotes.preview': '引用预览',
'footnotes.previous': '上一条引用',
'footnotes.next': '下一条引用',
'footnotes.citations': (v) => `${v?.count ?? 0} 个引用`,
'daemon.capacity.exhausted':
'已达到当前服务的并发容量上限,暂时无法启动此会话。请稍后重试,或取消本次操作。',
'git.currentBranch': (v) => `当前 Git 分支:${v?.branch ?? ''}`,
@@ -6668,6 +6684,8 @@ const ZH: Messages = {
'workflow.loadFailed': '工作流加载失败',
'environment.unavailable': '不可用',
'sources.title': '来源',
'sources.count': (v) => `${v?.count ?? 0} 个来源`,
'sources.currentTurn': '本轮来源',
'sources.add': '添加来源',
'sources.empty': '添加文件或链接作为参考。',
'sources.explanation': '添加引用不会将内容发送给助手。',
+8
View File
@@ -244,6 +244,14 @@ export type {
WebShellCodeBlockRenderInfo,
WebShellMarkdownChartCustomization,
WebShellMarkdownCustomization,
WebShellFootnote,
WebShellSource,
WebShellSourceReference,
WebShellSourceIconResolver,
WebShellFootnoteIconResolver,
WebShellFootnotePreviewInfo,
WebShellFootnotePreviewHandle,
WebShellFootnotePreviewMount,
WebShellAssistantMessageInfo,
WebShellAssistantTurnFooterRenderInfo,
ArtifactImageRenderer,
+12
View File
@@ -32,3 +32,15 @@
export { WebShellTranscript } from './components/WebShellTranscript';
export type { WebShellTranscriptProps } from './components/WebShellTranscript';
export type {
WebShellFootnote,
WebShellSource,
WebShellSourceReference,
WebShellSourceIconResolver,
WebShellFootnoteIconResolver,
WebShellFootnotePreviewInfo,
WebShellFootnotePreviewHandle,
WebShellFootnotePreviewMount,
WebShellIconSource,
WebShellMarkdownCustomization,
} from './customization';
+1
View File
@@ -82,6 +82,7 @@
"@playwright/test": "1.61.1",
"@qwen-code/sdk": "file:../sdk-typescript",
"@tailwindcss/vite": "^4.3.2",
"@types/hast": "^3.0.5",
"@types/node": "^22.0.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.0.0",
+29 -26
View File
@@ -1433,6 +1433,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.3.2
version: 4.3.2([email protected](@types/[email protected])([email protected]))
'@types/hast':
specifier: ^3.0.5
version: 3.0.5
'@types/node':
specifier: ^22.0.0
version: 22.20.1
@@ -4641,8 +4644,8 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==}
'@types/[email protected].4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/[email protected].5':
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
'@types/[email protected]':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
@@ -13029,7 +13032,7 @@ snapshots:
'@shikijs/engine-oniguruma': 1.29.2
'@shikijs/types': 1.29.2
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-to-html: 9.0.5
'@shikijs/[email protected]':
@@ -13054,7 +13057,7 @@ snapshots:
'@shikijs/[email protected]':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@shikijs/[email protected]': {}
@@ -13485,7 +13488,7 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected].4':
'@types/[email protected].5':
dependencies:
'@types/unist': 3.0.3
@@ -16322,20 +16325,20 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hastscript: 9.0.1
web-namespaces: 2.0.1
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-from-dom: 5.0.1
hast-util-from-html: 2.0.3
unist-util-remove-position: 5.0.0
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
devlop: 1.1.0
hast-util-from-parse5: 8.0.3
parse5: 7.3.0
@@ -16344,7 +16347,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/unist': 3.0.3
devlop: 1.1.0
hastscript: 9.0.1
@@ -16355,15 +16358,15 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
@@ -16378,7 +16381,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/estree': 1.0.9
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/unist': 3.0.3
comma-separated-tokens: 2.0.3
devlop: 1.1.0
@@ -16397,18 +16400,18 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/unist': 3.0.3
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.2.0
@@ -17162,7 +17165,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
devlop: 1.1.0
highlight.js: 11.11.1
@@ -17325,7 +17328,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
longest-streak: 3.1.0
@@ -17338,7 +17341,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3([email protected])
@@ -17349,7 +17352,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
@@ -17366,7 +17369,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3([email protected])
@@ -17381,7 +17384,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
'@ungap/structured-clone': 1.3.0
devlop: 1.1.0
@@ -18643,7 +18646,7 @@ snapshots:
[email protected](@types/[email protected])([email protected])([email protected]):
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
'@types/react': 19.2.10
devlop: 1.1.0
@@ -18803,7 +18806,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/katex': 0.16.8
hast-util-from-html-isomorphic: 2.0.0
hast-util-to-text: 4.0.2
@@ -18853,7 +18856,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
mdast-util-to-hast: 13.2.1
unified: 11.0.5
@@ -19212,7 +19215,7 @@ snapshots:
'@shikijs/themes': 1.29.2
'@shikijs/types': 1.29.2
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@types/hast': 3.0.5
[email protected]:
dependencies: