dsh-quick-toc 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.en.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to **dsh-quick-toc** are documented here. Chinese version: [CHANGELOG.md](CHANGELOG.md).
4
4
 
5
+ ## [0.4.0] - 2026-09-11
6
+
7
+ ### Added
8
+ - **Sticky group headers**: while scrolling the outline, the header of the turn you are inside stays pinned to the top of the panel, flush against the toolbar above it.
9
+ - **Collapsible level-filter row**: the **层级 (levels)** button at the left of the magnifier hides/shows the `1`–`6` row (the choice is remembered; the filter keeps applying while it is hidden).
10
+ - **Position breadcrumb**: the top of the panel shows the path of the section you are reading; click it to jump there. The rule is "the deepest heading above the viewport's middle line" — the section that fills most of the screen — and since a jump also lands above that line, the breadcrumb still shows the same heading after you click it.
11
+ - **Heading level filter**: the `1`–`6` chips at the top of the panel are independent switches, so any combination works (e.g. H1 and H3 with H2 hidden) and the choice is remembered; switching off the last remaining level restores all six.
12
+ - **Search result list**: while a query is present the outline becomes a result list — one row per match, showing the matched heading or message with the keyword highlighted, its heading path, the turn time and a repeat count (plus a context snippet in full-text scope). Rows are chronological with the **newest hit at the bottom**, a fresh search starts at the newest hit and you scroll up to reach earlier ones; **clicking a row** makes that hit current, i.e. the same path as Enter stepping — it highlights the keyword in the conversation and scrolls there. `n/N` Enter-stepping is kept.
13
+
14
+ ### Changed
15
+ - **Compatibility**: verified against and declared for DSH **0.1.5-rc.1 and 0.1.5-rc.2** (`engines.dsh` keeps its floor of `>=0.1.5-rc.1`). The host interface is identical in both — the session-scoped slot `conversation.input.overlay`, the `useChat` hook from `dsh-client-ui-chat` and the client-module seed table were all checked against the installed bundles.
16
+ - **Performance**: heading parsing and text extraction are now cached per node, so a streaming update only re-processes the node that changed instead of the whole history; the cache drops nodes that left the conversation, keeping it bounded.
17
+ - `countOccurrences` now guards an empty needle (an empty query made `indexOf` spin in place — an infinite loop; every current call site is guarded, so this is hardening).
18
+
19
+ ### Fixed
20
+ - The outline did not jump to the newest turn when content first appeared: that effect's dependency array was written above the `groups` declaration, so it always evaluated to `0` during render and the effect only ever ran on mount. The effect now sits below `groups` and depends on `groups.length`.
21
+ - Result rows always reported a repeat count of 1 (the `×N` badge never appeared), which also made the "row holding the current match" highlight land on the next row when one heading matched twice. Each row now records its real occurrence count.
22
+ - Dragging the top bar to move the panel persisted the position from **before** the drag (the closure kept the value captured at pointer-down), so the panel jumped back to its old spot after a reload. It now stores the position the drag ended at.
23
+ - In full-text scope, clicking a row whose hit was in a message body only scrolled and never highlighted anything (the click used the jump-only path while highlighting only happened while Enter-stepping). Clicking a result row now shares the stepping path: highlight that hit, scroll to it, and update both `n/N` and the highlighted row.
24
+ - When the same keyword occurred more than once inside one text node, the in-chat highlight wrapped only the FIRST occurrence there, so the later one was neither tinted nor eligible to be marked as the current hit — stepping to it found no current mark and degraded to a plain scroll with no distinct highlight. Every occurrence inside each text node is now wrapped in order, so the in-conversation hit order lines up with `n/N`.
25
+ - Breadcrumb and jump were inconsistent: detection used "the last heading above the viewport top" while a jump puts the heading 20 px below the top, so clicking the breadcrumb switched it to the previous heading. Both now use the viewport's **middle line**, which both reflects the section filling most of the screen and stays consistent with every jump target.
26
+ - The level filter used to be a "show up to level N" prefix that could not skip an intermediate level; it is now a per-level switch.
27
+ - The turn header (the time row) jumped to the turn's **user message**; it now jumps to the **start of that turn's model reply**, and turns without any headings — which have only this row in the panel — are clickable too.
28
+ - `# comment` / `## example` lines inside a ``` fenced code block were treated as headings and leaked into the outline: they have no element to jump to, and they shifted the index of the real headings after them so those jumped to the wrong place. Heading parsing now skips fenced blocks (``` and ~~~, info strings, longer closing fences, up to 3 spaces of indentation).
29
+ - There was a gap between the pinned group header and the toolbar above it: the scroll container's 6 px top padding is a wall a sticky row cannot pass. The top padding is gone, so the header butts against the toolbar; spacing between groups comes from the divider.
30
+
5
31
  ## [0.3.3] - 2026-09-10
6
32
 
7
33
  ### Added
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  **dsh-quick-toc** 的重要变更都记录在这里。英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
4
4
 
5
+ ## [0.4.0] - 2026-09-11
6
+
7
+ ### 新增
8
+ - **组头吸顶**:滚动大纲时,当前所在回合的组头固定在面板顶部,并与上方工具条紧贴
9
+ - **层级筛选行可收起**:标题栏放大镜左边的 **层级** 按钮收起/展开 `1`–`6` 那一行(选择会记住;收起后筛选设置仍然生效)
10
+ - **位置面包屑**:面板顶部显示你正在读的章节路径,点击可跳到该标题。判定规则是"视口中线之上最深的标题"(画面里占得最多的那一节);跳转落点也在中线之上,所以点完后面包屑显示的仍是同一个标题
11
+ - **标题层级筛选**:面板顶部 `1`–`6` 芯片是各自独立的开关,可任意组合(例如只留 H1 与 H3、关掉中间的 H2),选择会记住;把最后一个亮着的层级也关掉时自动恢复显示全部六级
12
+ - **搜索结果列表**:有搜索词时大纲切换为结果列表,逐条列出命中(标题关键字高亮、层级路径、回合时间、重复次数,全文模式附上下文片段)。列表按时间排列、**最新的命中在底部**,开始搜索时自动定位到最新命中,向上滚回看更早的命中;点击任一行会把该命中设为当前命中——和回车逐处跳同一条路径,会在对话里高亮并滚过去;`n/N` 回车逐处跳保留
13
+
14
+ ### 变更
15
+ - **兼容性**:在 DSH **0.1.5-rc.1 与 0.1.5-rc.2** 上验证并声明兼容(`engines.dsh` 下限仍为 `>=0.1.5-rc.1`)。两版的宿主接口一致——会话级槽位 `conversation.input.overlay`、`dsh-client-ui-chat` 的 `useChat` hook 与客户端模块 seed 表都经实测核对相同。
16
+ - **性能**:标题解析与文本提取改为按节点缓存——流式更新只重算真正变化的节点,不再每次重算整段历史;缓存会清理已离开会话的节点,保持有界
17
+ - `countOccurrences` 增加空查询守卫(空查询会让 `indexOf` 原地打转导致死循环;现有调用点都有守卫,此举为加固)
18
+
19
+ ### 修复
20
+ - 大纲首次载入内容时没有自动定位到最新回合:该副作用的依赖数组写在 `groups` 声明之前,渲染时读到的永远是 `0`,导致副作用只在挂载时执行一次。已把副作用移到 `groups` 之后并直接依赖 `groups.length`。
21
+ - 搜索结果行的重复次数恒为 1(`×N` 从不显示),并连带使"当前命中所属行"在重复命中时定位到下一行。现在每行记录真实的出现次数。
22
+ - 拖动顶部横条调整面板垂直位置后,记录的仍是**拖动前**的位置(写入的是拖动开始时闭包里的旧值),刷新后面板跳回原位。现在写入拖动结束时的实际位置。
23
+ - 全文搜索时,正文命中行点击后只滚动、不高亮(点击走的是"仅跳转"路径,而高亮只在回车逐处跳里做)。现在点击结果行与回车逐处跳共用同一条路径:先高亮该处,再滚过去,同时更新 `n/N` 与当前行高亮。
24
+ - 同一段文本里同一关键字出现多次时,对话内高亮只包住**该文本节点里的第一处**:第二处既不染色,也无法被标成"当前命中",于是跳到第二处时找不到当前标记,退化成"只滚动、没有高亮"。现在每个文本节点内的所有出现都会依次包住,对话内命中序号与 `n/N` 完全对齐。
25
+ - 面包屑与跳转错位:判定用"视口顶部之上最后一个标题",而跳转把标题放在"顶部 +20px",于是点完面包屑就会换成上一个标题。现在统一以**视口中线**为基准(既反映画面里占得最多的那一节,又与所有跳转落点自洽)。
26
+ - 层级筛选原本是"显示到第 N 级"的前缀语义,无法跳过中间层级;现在改为逐级开关。
27
+ - 回合组头(时间那一行)原本跳到该回合的**用户消息**位置;现在跳到**该回合模型回答的开头**,并且没有标题的回合(面板里只剩时间行)也能点。
28
+ - ``` 围栏代码块里的 `# 注释` / `## 示例` 会被当成标题混进大纲(既没有可跳转的目标,又会让它后面那些真标题的序号错位、跳到错误位置)。现在解析标题时会跳过围栏代码块(支持 ``` 与 ~~~、带 info string 的开栏、更长的闭栏、最多 3 空格缩进)。
29
+ - 吸顶的组头上边缘与上方工具条之间有一道空隙:滚动容器的上内边距(6px)把粘性行"顶"不下去。现在去掉该上内边距,组头紧贴工具条,组与组之间的间距由分隔线提供。
30
+
5
31
  ## [0.3.3] - 2026-09-10
6
32
 
7
33
  ### 新增
package/README.en.md CHANGED
@@ -6,26 +6,32 @@ A conversation TOC plugin for [DeepSeek Harness](https://github.com/deepseek-ai/
6
6
 
7
7
  ## Features
8
8
 
9
- - **Turn-grouped outline** — each user message plus its following AI replies form one group; the group header shows the turn's end time and a first-line preview, and clicking it jumps to the start of the turn
9
+ - **Turn-grouped outline** — each user message plus its following AI replies form one group; the group header shows the turn's end time and a first-line preview, and clicking it jumps to the **start of that turn's model reply** (a turn with no headings has only this row and is still clickable)
10
10
  - **Keyword search** — the magnifier in the panel header opens a search box; press **Enter** to jump to the next match (wrapping), **Esc** to close, with an `n/N` match counter
11
+ - **Search result list** — once a query is present the outline becomes a result list: each row is a matched heading or message (keyword highlighted) showing its heading path, the turn time and a repeat count, plus a context snippet in full-text scope. Rows run in chronological order (**newest hit at the bottom**) and a fresh search starts at the newest hit — scroll up to walk back through earlier ones; **clicking a row** makes that hit the current one, i.e. exactly what Enter stepping does: it highlights the keyword in the conversation and scrolls there
11
12
  - **Search scope toggle** — the **title / full-text** button in the search box switches between searching heading titles only and also searching user messages plus AI reply text
12
13
  - **In-chat highlighting** — matched keywords are highlighted in the conversation; the current match is highlighted distinctly and scrolled into the upper part of the viewport; every occurrence inside a message counts toward `n/N`
14
+ - **Sticky group headers** — while scrolling the outline, the header of the turn you are inside stays pinned to the top of the panel, flush against the toolbar above it
15
+ - **Position breadcrumb** — the top of the panel shows the path of the section you are reading (e.g. `Deploy › Docker`). The rule is "the deepest heading above the viewport's middle line", i.e. the section that fills most of the screen; clicking it keeps showing that same heading afterwards, because a jump also lands above the middle line. When you do not want that row, the **位置 (position)** button just left of the magnifier collapses it (the choice is remembered)
16
+ - **Heading level filter** — the `1`–`6` chips in the panel header are **independent switches**, so any combination is possible (e.g. H1 and H3 with H2 hidden); the choice is remembered. Switching off the last remaining level restores all six, so the panel is never empty. That row itself can be collapsed: the **层级 (levels)** button at the left of the magnifier hides/shows it (the choice is remembered), and the filter keeps applying while it is hidden
17
+ - **Code blocks are not headings** — `# comment` / `## example` lines inside a ``` fenced code block never become outline entries (they used to leak into the outline and made the real headings after them jump to the wrong place)
13
18
  - **Auto-follow highlighting** — while scrolling the conversation, the turns visible in the viewport light up in the outline (several at once) and the rest dim; the outline loads and scrolls so the group being read stays visible
14
- - **Jumping** — click a heading to jump to its position in the conversation, or click a group header's time/preview to jump to the start of that turn
19
+ - **Jumping** — click a heading to jump to its position in the conversation, or click a group header's time/preview to jump to the **start of that turn's model reply**
15
20
  - **Dockable and resizable** — drag the top bar to move the panel, use ◀ / ▶ to dock it left or right, drag an edge or corner to resize, and collapse it into an edge handle (click it to expand); the panel's position and size are remembered
16
21
  - **Paging and loading older** — the most recent groups show first; scrolling up inside the outline loads older groups
17
22
  - **Markdown-aware titles** — inline markup in headings (`**bold**`, `*italic*`, `` `code` ``, `[links](url)`, `~~strike~~`) is stripped before display
18
23
  - **Chat view only** — when the center column switches to another view (trajectory, context, …), the panel and its edge handle fade out
19
- - The panel hides itself when the conversation has no headings; it adapts to the dark and light themes (the inner-shadow card follows the theme)
24
+ - When the conversation has no headings at all, the outline lists clickable turn-time entries only; it hides itself when not even a time can be read. It adapts to the dark and light themes (the inner-shadow card follows the theme)
20
25
 
21
26
  ## Compatibility
22
27
 
23
28
  | Plugin | Supported DSH |
24
29
  | --- | --- |
25
- | **0.3.3** (latest) | 0.1.5-rc.1 |
26
- | 0.3.2 | 0.1.2-rc.1 |
30
+ | **0.4.0** (latest) | 0.1.5-rc.1, 0.1.5-rc.2 |
31
+ | 0.3.3 | 0.1.5-rc.1 |
32
+ | 0.3.0 – 0.3.2 | ≥ 0.1.2-rc.1 |
27
33
 
28
- The latest version has only been verified against **DSH 0.1.5-rc.1**, so that is the only version it declares; older DSH versions are unverified and therefore not claimed. On an older DSH, the newest usable plugin version is **0.3.2**. On install or update, the DSH market pre-flights host compatibility from `engines.dsh`, `dsh.compatibility.dshReleases` and `peerDependencies` in `package.json`.
34
+ `engines.dsh` has a floor of **0.1.5-rc.1** (`>=`): from that release on, the plugin uses the host's session-scoped slot injection. 0.4.0 has been verified against **0.1.5-rc.1 and 0.1.5-rc.2** and declares exactly those; older or newer DSH versions are unverified and therefore not claimed. On an older DSH, the newest usable plugin version is **0.3.2**. On install or update, the DSH market pre-flights host compatibility from `engines.dsh`, `dsh.compatibility.dshReleases` and `peerDependencies` in `package.json`.
29
35
 
30
36
  ## Install
31
37
 
@@ -51,8 +57,9 @@ After installing, restart DSH and open the Web UI. The panel starts collapsed; c
51
57
 
52
58
  ## Usage
53
59
 
54
- - **Jumping**: click a heading in the outline to jump to its position; click a group header's time or first-line preview to jump to the start of that turn
55
- - **Search**: click the magnifier in the header to open the search box, type a keyword and press Enter to step through matches (`n/N` shows the current position and the total); press Esc or the magnifier again to close it; use the **title / full-text** button to change the search scope
60
+ - **Jumping**: click a heading in the outline to jump to its position; click a group header's time or first-line preview to jump to the **start of that turn's model reply**; click the breadcrumb at the top to jump to the heading you are inside
61
+ - **Search**: click the magnifier in the header to open the search box; once you type a keyword the outline becomes a result list — click any row to locate and highlight that hit, or press Enter to step through the matches (`n/N` shows the current position and the total, and the row holding the current match is highlighted); press Esc or the magnifier again to close and return to the outline; use the **title / full-text** button to change the search scope
62
+ - **Level filter**: the `1`–`6` chips are independent switches, so any combination works; the **层级 (levels)** button in the header collapses or expands that row
56
63
  - **Moving and docking**: drag the top bar to move the panel; use ◀ / ▶ to switch between left and right docking
57
64
  - **Resizing**: drag the right edge for width, the bottom edge for height, or the bottom-right corner for both
58
65
  - **Loading older turns**: scroll up inside the outline to load older groups; when the conversation itself offers a "load older messages" button, the outline also triggers it on reaching the top
package/README.md CHANGED
@@ -6,26 +6,32 @@
6
6
 
7
7
  ## 功能
8
8
 
9
- - **按回合分组** —— 每条用户消息 + 其后续 AI 回复为一组;组头显示该组最后一条消息的结束时间,并附带该回合首行预览,点击可跳到回合开头
9
+ - **按回合分组** —— 每条用户消息 + 其后续 AI 回复为一组;组头显示该组最后一条消息的结束时间,并附带该回合首行预览,点击跳到**该回合模型回答的开头**(没有标题的回合只剩这一行,同样可点)
10
10
  - **关键字搜索** —— 标题栏放大镜打开搜索框;输入后按 **回车** 跳到下一处命中(循环),按 **Esc** 关闭;命中计数以 `n/N` 显示
11
+ - **搜索结果列表** —— 输入关键字后,大纲切换为结果列表:每行是命中的标题或消息(关键字高亮),并显示层级路径、回合时间与重复次数(全文搜索时附上下文片段)。列表按时间顺序排(**最新的命中在底部**),开始搜索时自动定位到最新命中,向上滚即可回看更早的命中;**点击任一行**把该命中设为当前命中——与回车逐处跳走同一条路径,会在对话里高亮并滚动过去
11
12
  - **搜索范围切换** —— 搜索框内的 **标题 / 全文** 按钮切换范围:仅标题,或同时搜索用户消息与 AI 回复正文
12
13
  - **对话内高亮** —— 命中的关键字在对话中高亮,当前命中单独标亮并滚动到视口上部;同一条消息中的多次命中都会计入 `n/N`
14
+ - **组头吸顶** —— 滚动大纲时,当前所在回合的组头固定在面板顶部(与上方工具条紧贴,中间不留空隙),始终知道自己在看哪一轮
15
+ - **位置提示(面包屑)** —— 面板顶部显示你正在读的章节路径(如 `部署 › Docker`)。判定规则是"**视口中线之上最深的那个标题**",也就是画面里占得最多的那一节;点击它跳过去后仍显示同一个标题(跳转落点也在中线之上,规则自洽)
16
+ - **层级筛选** —— 面板顶部的 `1`–`6` 芯片是**各自独立的开关**,可任意组合(例如只留 H1 和 H3、关掉中间的 H2);选择会被记住。把最后一个还亮着的层级也关掉时,会自动恢复显示全部六级(面板不会变空)。这一行本身也能收起:标题栏里放大镜左边的 **层级** 按钮收起/展开它(选择会记住),收起后筛选设置仍然生效
17
+ - **代码块不算标题** —— ``` 围栏代码块里的 `# 注释`、`## 示例` 不会被当成大纲标题(以前会混进大纲,还会让它后面那些真标题跳错位置)
13
18
  - **自动跟随高亮** —— 滚动对话时,视口内可见的回合在大纲中自动点亮(可同时点亮多组),其余组变暗;大纲自动加载并滚动,使正在阅读的组保持可见
14
19
  - **跳转** —— 点击标题跳到对话中该标题所在位置;点击组头的时间或预览跳到该回合开头
15
20
  - **可停靠、可缩放** —— 拖顶部横条移动,按 ◀ / ▶ 切换左右停靠,拖边缘或角部调整宽高,收起后成为边缘把手(点击展开);面板位置与尺寸会被记住
16
21
  - **分页与加载更早** —— 默认显示最近的若干组;在大纲中向上滚动(或滚动到顶部)会加载更早的组
17
22
  - **Markdown 清理** —— 标题中的行内标记(`**加粗**`、`*斜体*`、`` `代码` ``、`[链接](url)`、`~~删除线~~`)会被剥离后显示
18
23
  - **仅对话视图显示** —— 中间列切到轨迹、上下文等其他视图时,面板与边缘把手渐隐
19
- - 对话中没有标题时面板自动隐藏;适配深色与浅色主题(内阴影质感随主题切换)
24
+ - 对话中没有任何标题时,大纲只列出可点击跳转的回合时间条目;连时间也识别不到时面板自动隐藏。适配深色与浅色主题(内阴影质感随主题切换)
20
25
 
21
26
  ## 兼容性
22
27
 
23
28
  | 插件版本 | 支持的 DSH 版本 |
24
29
  | --- | --- |
25
- | **0.3.3**(最新) | 0.1.5-rc.1 |
26
- | 0.3.2 | 0.1.2-rc.1 |
30
+ | **0.4.0**(最新) | 0.1.5-rc.1、0.1.5-rc.2 |
31
+ | 0.3.3 | 0.1.5-rc.1 |
32
+ | 0.3.0 – 0.3.2 | ≥ 0.1.2-rc.1 |
27
33
 
28
- 最新版目前只在 **DSH 0.1.5-rc.1** 上验证过,因此仅声明该版本兼容;更早的 DSH 版本未经验证,不作声明。使用更早版本 DSH 时,可用的最新插件版本是 **0.3.2**。安装或更新时,DSH 市场会依据 `package.json` 中的 `engines.dsh`、`dsh.compatibility.dshReleases` 与 `peerDependencies` 做宿主兼容预检。
34
+ `engines.dsh` 下限为 **0.1.5-rc.1**(`>=`,该版本起插件改用会话级槽位注入的宿主接口);0.4.0 在 **0.1.5-rc.1 与 0.1.5-rc.2** 上验证过并声明兼容,更早或更新的 DSH 版本未经验证,不作声明。在更早的 DSH 上,可安装的最新插件版本是 **0.3.2**。安装或更新时,DSH 市场会依据 `package.json` 中的 `engines.dsh`、`dsh.compatibility.dshReleases` 与 `peerDependencies` 做宿主兼容预检。
29
35
 
30
36
  ## 安装
31
37
 
@@ -51,8 +57,9 @@ dsh plugin --profile web add <插件目录路径>
51
57
 
52
58
  ## 使用
53
59
 
54
- - **跳转**:点击大纲标题跳到该标题所在位置;点击组头的时间或首行预览跳到该回合开头
55
- - **搜索**:点击标题栏的放大镜展开搜索框,输入关键字后按回车逐处跳转(`n/N` 显示当前位置与命中总数),按 Esc 或再次点击放大镜关闭;用 **标题 / 全文** 按钮切换搜索范围
60
+ - **跳转**:点击大纲标题跳到该标题所在位置;点击组头的时间或首行预览跳到**该回合模型回答的开头**;点击顶部的面包屑跳到当前所在标题
61
+ - **搜索**:点击标题栏的放大镜展开搜索框,输入关键字后大纲切换为结果列表——直接点某一条定位并高亮,或按回车逐处循环(`n/N` 显示当前位置与命中总数,当前命中所属行会高亮);按 Esc 或再次点击放大镜关闭并回到大纲;用 **标题 / 全文** 按钮切换搜索范围
62
+ - **层级筛选**:`1`–`6` 芯片是各自独立的开关,可任意组合;点标题栏的 **层级** 按钮收起/展开这一行
56
63
  - **移动与停靠**:拖顶部横条移动面板,按 ◀ / ▶ 在左右之间切换停靠
57
64
  - **调整大小**:拖右边缘调宽度、拖下边缘调高度、拖右下角同时调整
58
65
  - **加载更早**:在大纲中向上滚动即可加载更早的回合;对话本身有"加载更早消息"按钮时,大纲滚到顶部也会触发它
package/lib/client.js CHANGED
@@ -78,12 +78,28 @@ window.__ModuleLoader__.load({
78
78
  return first;
79
79
  }
80
80
 
81
+ // Headings are collected line by line so FENCED CODE BLOCKS can be skipped:
82
+ // a "```" block that documents markdown (or shows a shell comment like
83
+ // "# install") contains lines that look like headings but are code — they
84
+ // must not become outline entries (they have no element to jump to, and they
85
+ // shifted the index of the real headings).
81
86
  function parseHeadings(text) {
82
87
  var items = [];
83
- var re = /^(#{1,6})\s+(.+?)\s*#*\s*$/gm;
84
- var m;
85
- while ((m = re.exec(text)) !== null) {
86
- items.push({ level: m[1].length, title: cleanTitle(m[2].trim()) });
88
+ if (!text) return items;
89
+ var lines = text.split("\n");
90
+ var fence = null; // { char, len } while inside a fenced code block
91
+ for (var i = 0; i < lines.length; i++) {
92
+ var line = lines[i];
93
+ var fm = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
94
+ if (fence !== null) {
95
+ // a closing fence uses the same character, is at least as long and has
96
+ // no info string after it
97
+ if (fm && fm[1].charAt(0) === fence.char && fm[1].length >= fence.len && fm[2].trim() === "") fence = null;
98
+ continue;
99
+ }
100
+ if (fm) { fence = { char: fm[1].charAt(0), len: fm[1].length }; continue; }
101
+ var m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
102
+ if (m !== null) items.push({ level: m[1].length, title: cleanTitle(m[2].trim()) });
87
103
  }
88
104
  return items;
89
105
  }
@@ -101,6 +117,74 @@ window.__ModuleLoader__.load({
101
117
  return root.children;
102
118
  }
103
119
 
120
+ // ---- per-node extraction cache ----
121
+ // The conversation store keeps the same node objects for unchanged messages,
122
+ // so a streaming update only re-joins/re-parses the node that actually
123
+ // changed instead of every message in the history.
124
+ var EMPTY_HEADINGS = [];
125
+ var nodeInfoCache = new Map();
126
+ function nodeInfo(key, node) {
127
+ var isUser = node.kind === "user";
128
+ var blocks = node.data ? node.data.blocks : undefined;
129
+ var cached = nodeInfoCache.get(key);
130
+ if (cached !== undefined &&
131
+ (cached.node === node || (blocks !== undefined && cached.blocks === blocks && cached.user === isUser))) {
132
+ return cached;
133
+ }
134
+ var text = isUser ? extractUserText(node) : extractReplyText(node);
135
+ var info = {
136
+ node: node,
137
+ blocks: blocks,
138
+ user: isUser,
139
+ text: text,
140
+ headings: node.kind === "assistant-step" ? parseHeadings(text) : EMPTY_HEADINGS
141
+ };
142
+ nodeInfoCache.set(key, info);
143
+ return info;
144
+ }
145
+ // Drop cache records whose node left the conversation (bounded growth).
146
+ function pruneNodeInfo(seen) {
147
+ if (nodeInfoCache.size <= 200) return;
148
+ nodeInfoCache.forEach(function (value, key) {
149
+ if (!seen[key]) nodeInfoCache.delete(key);
150
+ });
151
+ }
152
+
153
+ // stable id for one heading occurrence (a message can hold several)
154
+ function headingId(h) {
155
+ return h.key + "#" + h.idx;
156
+ }
157
+
158
+ // Split `text` into runs around case-insensitive occurrences of `q`, so the
159
+ // caller can render the hits distinctly. Returns [{ text, hit }, ...].
160
+ function highlightParts(text, q) {
161
+ var parts = [];
162
+ if (!text) return parts;
163
+ if (!q) return [{ text: text, hit: false }];
164
+ var lower = text.toLowerCase();
165
+ var from = 0;
166
+ for (;;) {
167
+ var at = lower.indexOf(q, from);
168
+ if (at === -1) break;
169
+ if (at > from) parts.push({ text: text.slice(from, at), hit: false });
170
+ parts.push({ text: text.slice(at, at + q.length), hit: true });
171
+ from = at + q.length;
172
+ }
173
+ if (from < text.length) parts.push({ text: text.slice(from), hit: false });
174
+ return parts;
175
+ }
176
+
177
+ // A one-line window around the first hit of `q` in `text` (for search results).
178
+ function snippetAround(text, q, span) {
179
+ if (!text) return "";
180
+ var flat = text.replace(/\s+/g, " ").trim();
181
+ var at = flat.toLowerCase().indexOf(q);
182
+ if (at === -1) return previewText(flat, span * 2);
183
+ var start = Math.max(0, at - span);
184
+ var end = Math.min(flat.length, at + q.length + span);
185
+ return (start > 0 ? "…" : "") + flat.slice(start, end) + (end < flat.length ? "…" : "");
186
+ }
187
+
104
188
  // strict lookup: exact dataset match or CSS-escaped selector only —
105
189
  // no fuzzy contains matching (avoids landing on the wrong row);
106
190
  // hidden rows (zero rect, e.g. duplicate/hidden copies) are skipped
@@ -145,6 +229,10 @@ window.__ModuleLoader__.load({
145
229
 
146
230
  // count every occurrence of q (case-insensitive) inside text
147
231
  function countOccurrences(text, q) {
232
+ // an empty needle would make indexOf return 0 forever and freeze the tab;
233
+ // every caller passes a trimmed non-empty query today, but the guard keeps
234
+ // a future call site from hanging the UI.
235
+ if (!text || !q) return 0;
148
236
  var lower = text.toLowerCase();
149
237
  var count = 0;
150
238
  var idx = 0;
@@ -245,26 +333,36 @@ window.__ModuleLoader__.load({
245
333
  var text = node.nodeValue;
246
334
  if (!text) continue;
247
335
  var lower = text.toLowerCase();
248
- var idx = lower.indexOf(q);
249
- if (idx < 0) continue;
336
+ if (lower.indexOf(q) < 0) continue;
250
337
  var frag = document.createDocumentFragment();
251
- var before = text.slice(0, idx);
252
- if (before) frag.appendChild(document.createTextNode(before));
253
- var mark = document.createElement("span");
254
- if (occ === currentOcc) {
255
- mark.className = "dqt-current";
256
- mark.style.background = "rgba(79,140,255,0.55)";
257
- mark.style.boxShadow = "0 0 0 1px rgba(79,140,255,0.85)";
258
- } else {
259
- mark.style.background = "rgba(79,140,255,0.32)";
338
+ // EVERY occurrence inside this text node must be wrapped, in order: the
339
+ // caller addresses hits by their global index (`occ`), which is counted
340
+ // over the message text. Wrapping only the first one per text node made
341
+ // a second hit on the same line un-markable, so stepping to it found no
342
+ // `.dqt-current` and fell back to a plain scroll with no highlight.
343
+ var from = 0;
344
+ var idx = lower.indexOf(q, from);
345
+ while (idx !== -1) {
346
+ var before = text.slice(from, idx);
347
+ if (before) frag.appendChild(document.createTextNode(before));
348
+ var mark = document.createElement("span");
349
+ if (occ === currentOcc) {
350
+ mark.className = "dqt-current";
351
+ mark.style.background = "rgba(79,140,255,0.55)";
352
+ mark.style.boxShadow = "0 0 0 1px rgba(79,140,255,0.85)";
353
+ } else {
354
+ mark.style.background = "rgba(79,140,255,0.32)";
355
+ }
356
+ mark.style.borderRadius = "2px";
357
+ mark.style.color = "inherit";
358
+ mark.textContent = text.slice(idx, idx + q.length);
359
+ frag.appendChild(mark);
360
+ highlightSpans.push(mark);
361
+ occ++;
362
+ from = idx + q.length;
363
+ idx = lower.indexOf(q, from);
260
364
  }
261
- mark.style.borderRadius = "2px";
262
- mark.style.color = "inherit";
263
- mark.textContent = text.slice(idx, idx + q.length);
264
- frag.appendChild(mark);
265
- highlightSpans.push(mark);
266
- occ++;
267
- var after = text.slice(idx + q.length);
365
+ var after = text.slice(from);
268
366
  if (after) frag.appendChild(document.createTextNode(after));
269
367
  node.parentNode.replaceChild(frag, node);
270
368
  }
@@ -475,6 +573,72 @@ window.__ModuleLoader__.load({
475
573
  var panelH = _s8[0];
476
574
  var setPanelH = _s8[1];
477
575
 
576
+ // ---- heading level filter (persisted) ----
577
+ // An arbitrary SET of levels, not a "show up to N" prefix: every chip is an
578
+ // independent on/off switch, so H1 + H3 without H2 is a valid view. Turning
579
+ // the last remaining level off restores all six (the panel is never empty).
580
+ var LEVELS_KEY = "dsh-quick-toc.levels.v1";
581
+ var OLD_MAX_LEVEL_KEY = "dsh-quick-toc.maxLevel.v1";
582
+ var ALL_LEVELS = [1, 2, 3, 4, 5, 6];
583
+ var _sLv = react.useState(function () {
584
+ try {
585
+ var raw = localStorage.getItem(LEVELS_KEY);
586
+ if (raw !== null) {
587
+ var arr = JSON.parse(raw);
588
+ if (Array.isArray(arr)) {
589
+ var kept = ALL_LEVELS.filter(function (lv) { return arr.indexOf(lv) !== -1; });
590
+ if (kept.length > 0) return kept;
591
+ }
592
+ }
593
+ // migrate the "show up to N" value written by earlier builds
594
+ var v = Number(localStorage.getItem(OLD_MAX_LEVEL_KEY));
595
+ if (isFinite(v) && v >= 1 && v <= 6) {
596
+ return ALL_LEVELS.filter(function (lv) { return lv <= v; });
597
+ }
598
+ } catch (e) {}
599
+ return ALL_LEVELS.slice();
600
+ });
601
+ var levels = _sLv[0];
602
+ var setLevels = _sLv[1];
603
+ var levelSet = react.useMemo(function () {
604
+ var m = {};
605
+ for (var i = 0; i < levels.length; i++) m[levels[i]] = true;
606
+ return m;
607
+ }, [levels]);
608
+ var toggleLevel = function (lv) {
609
+ var next;
610
+ if (levelSet[lv]) {
611
+ next = levels.filter(function (x) { return x !== lv; });
612
+ if (next.length === 0) next = ALL_LEVELS.slice(); // never leave the panel empty
613
+ } else {
614
+ next = ALL_LEVELS.filter(function (x) { return x === lv || levelSet[x]; });
615
+ }
616
+ setLevels(next);
617
+ try { localStorage.setItem(LEVELS_KEY, JSON.stringify(next)); } catch (e) {}
618
+ };
619
+
620
+ // ---- level-filter row (the H1–H6 chips) can be collapsed away; the toggle
621
+ // sits next to the search button in the panel header and the choice is
622
+ // remembered. The filter itself keeps applying while the row is hidden.
623
+ var LEVELS_ROW_KEY = "dsh-quick-toc.levelsRow.v1";
624
+ var _sLr = react.useState(function () {
625
+ try { return localStorage.getItem(LEVELS_ROW_KEY) !== "0"; } catch (e) { return true; }
626
+ });
627
+ var showLevelsRow = _sLr[0];
628
+ var setShowLevelsRow = _sLr[1];
629
+ var toggleLevelsRow = function () {
630
+ var next = !showLevelsRow;
631
+ setShowLevelsRow(next);
632
+ try { localStorage.setItem(LEVELS_ROW_KEY, next ? "1" : "0"); } catch (e) {}
633
+ };
634
+
635
+ // ---- breadcrumb: the heading the reader is currently inside ----
636
+ var _sCrumb = react.useState(null);
637
+ var crumb = _sCrumb[0];
638
+ var setCrumb = _sCrumb[1];
639
+ var crumbRef = react.useRef(null);
640
+ var headingPathsRef = react.useRef({});
641
+
478
642
  // ---- pagination: show the latest N groups; scrolling to the top loads older ----
479
643
  var PAGE_SIZE = 6;
480
644
  var _s9 = react.useState(PAGE_SIZE);
@@ -593,13 +757,6 @@ window.__ModuleLoader__.load({
593
757
  var setActiveGroup = _s10[1];
594
758
  var activeSigRef = react.useRef("");
595
759
 
596
- // first time content appears, scroll the list to the bottom (newest)
597
- react.useEffect(function () {
598
- if (didInitScroll.current || !listRef.current || !groups || groups.length === 0) return;
599
- didInitScroll.current = true;
600
- listRef.current.scrollTop = listRef.current.scrollHeight;
601
- }, [groups ? groups.length : 0]);
602
-
603
760
  // load older outline groups; when everything is loaded, click the
604
761
  // conversation's own "load older" button so older turns keep appearing
605
762
  var loadOlderOutline = function (el) {
@@ -760,6 +917,7 @@ window.__ModuleLoader__.load({
760
917
  var turnUserText = {};
761
918
  var turnUserFull = {};
762
919
  if (!order || !nodes) return result;
920
+ var seen = {};
763
921
  // first pass: per-turn time — the LAST message of the turn wins (end time);
764
922
  // also remember each turn's user message key + first-line preview + full text
765
923
  for (var i = 0; i < order.length; i++) {
@@ -769,10 +927,12 @@ window.__ModuleLoader__.load({
769
927
  var l0 = n0.location;
770
928
  var tid0 = l0 && (l0.kind === "turn" || l0.kind === "step") && l0.turn ? l0.turn.turn : null;
771
929
  if (tid0 === null) continue;
930
+ seen[k0] = true;
931
+ var info0 = nodeInfo(k0, n0);
772
932
  if (n0.kind === "user" && turnUserKey[tid0] === undefined) {
773
933
  turnUserKey[tid0] = k0;
774
- turnUserText[tid0] = previewText(extractUserText(n0), 30);
775
- turnUserFull[tid0] = extractUserText(n0);
934
+ turnUserText[tid0] = previewText(info0.text, 30);
935
+ turnUserFull[tid0] = info0.text;
776
936
  }
777
937
  var t0 = getNodeTime(n0);
778
938
  if (t0) turnTimes[tid0] = t0;
@@ -797,22 +957,37 @@ window.__ModuleLoader__.load({
797
957
  };
798
958
  result.push(current);
799
959
  }
800
- var text = extractReplyText(node);
801
- current.msgs.push({ key: key, text: text });
802
- var parsed = parseHeadings(text);
803
- var hIdx = 0;
804
- for (var k = 0; k < parsed.length; k++) {
805
- current.headings.push({ level: parsed[k].level, title: parsed[k].title, key: key, idx: hIdx });
806
- hIdx++;
960
+ seen[key] = true;
961
+ var info = nodeInfo(key, node);
962
+ current.msgs.push({ key: key, text: info.text });
963
+ for (var k = 0; k < info.headings.length; k++) {
964
+ current.headings.push({ level: info.headings[k].level, title: info.headings[k].title, key: key, idx: k });
807
965
  }
808
966
  }
967
+ pruneNodeInfo(seen);
809
968
  // keep a turn when it has headings OR a time — turns without headings
810
969
  // still get a standalone time entry in the outline (click to jump)
811
970
  return result.filter(function (g) { return g.headings.length > 0 || g.time !== ""; });
812
971
  }, [order, nodes]);
972
+ // first time content appears, scroll the list to the bottom (newest).
973
+ // NOTE: must stay BELOW the `groups` memo — a deps array is evaluated
974
+ // during render, so reading `groups` before that `var` is assigned would
975
+ // freeze the deps at 0 and the effect would only ever run on mount.
976
+ react.useEffect(function () {
977
+ if (didInitScroll.current || !listRef.current || groups.length === 0) return;
978
+ didInitScroll.current = true;
979
+ listRef.current.scrollTop = listRef.current.scrollHeight;
980
+ }, [groups.length]);
813
981
  var groupTrees = react.useMemo(function () {
814
- return groups.map(function (g) { return buildTree(g.headings); });
815
- }, [groups]);
982
+ return groups.map(function (g) {
983
+ if (levels.length === 6) return buildTree(g.headings);
984
+ var kept = [];
985
+ for (var i = 0; i < g.headings.length; i++) {
986
+ if (levelSet[g.headings[i].level]) kept.push(g.headings[i]);
987
+ }
988
+ return buildTree(kept);
989
+ });
990
+ }, [groups, levels, levelSet]);
816
991
  // pagination slice: the latest `visibleCount` groups
817
992
  var shownGroups = groups.slice(Math.max(0, groups.length - visibleCount));
818
993
  var shownTrees = groupTrees.slice(groupTrees.length - shownGroups.length);
@@ -828,6 +1003,80 @@ window.__ModuleLoader__.load({
828
1003
  }
829
1004
  return m;
830
1005
  }, [groups]);
1006
+
1007
+ // ancestor path of every heading occurrence (breadcrumb + result rows):
1008
+ // id -> { path, title, level, gi }
1009
+ var headingPaths = react.useMemo(function () {
1010
+ var byId = {};
1011
+ var lastTitle = {};
1012
+ for (var gi = 0; gi < groups.length; gi++) {
1013
+ var g = groups[gi];
1014
+ for (var j = 0; j < g.headings.length; j++) {
1015
+ var h = g.headings[j];
1016
+ for (var deeper = h.level + 1; deeper <= 6; deeper++) lastTitle[deeper] = undefined;
1017
+ var parts = [];
1018
+ for (var up = 1; up < h.level; up++) {
1019
+ if (lastTitle[up]) parts.push(lastTitle[up]);
1020
+ }
1021
+ parts.push(h.title);
1022
+ byId[headingId(h)] = { path: parts.join(" › "), title: h.title, level: h.level, gi: gi };
1023
+ lastTitle[h.level] = h.title;
1024
+ }
1025
+ }
1026
+ return byId;
1027
+ }, [groups]);
1028
+
1029
+ // search result rows: one row per matched heading/message (with an
1030
+ // occurrence count), carrying its heading path, turn time and a snippet.
1031
+ var resultRows = react.useMemo(function () {
1032
+ var q = query.trim().toLowerCase();
1033
+ if (!q) return [];
1034
+ var rows = [];
1035
+ var index = {};
1036
+ var push = function (row, n) {
1037
+ var id = row.key + "#" + (row.idx === undefined ? "-" : row.idx);
1038
+ var known = index[id];
1039
+ if (known !== undefined) { known.count += n; return; }
1040
+ row.count = n;
1041
+ index[id] = row;
1042
+ rows.push(row);
1043
+ };
1044
+ for (var gi = 0; gi < groups.length; gi++) {
1045
+ var g = groups[gi];
1046
+ for (var j = 0; j < g.headings.length; j++) {
1047
+ var h = g.headings[j];
1048
+ var nh = countOccurrences(h.title, q);
1049
+ if (nh > 0) {
1050
+ var hp = headingPaths[headingId(h)];
1051
+ push({
1052
+ gi: gi, key: h.key, idx: h.idx, title: h.title, level: h.level,
1053
+ path: hp ? hp.path : "", time: g.time, snippet: ""
1054
+ }, nh);
1055
+ }
1056
+ }
1057
+ if (searchScope === "full") {
1058
+ var nu = g.userKey && g.userFull ? countOccurrences(g.userFull, q) : 0;
1059
+ if (nu > 0) {
1060
+ push({
1061
+ gi: gi, key: g.userKey, idx: undefined, title: previewText(g.userFull, 40),
1062
+ level: 0, path: "", time: g.time, snippet: snippetAround(g.userFull, q, 36)
1063
+ }, nu);
1064
+ }
1065
+ for (var m = 0; m < g.msgs.length; m++) {
1066
+ var msg = g.msgs[m];
1067
+ if (!msg.text) continue;
1068
+ var nm = countOccurrences(msg.text, q);
1069
+ if (nm === 0) continue;
1070
+ push({
1071
+ gi: gi, key: msg.key, idx: undefined, title: previewText(msg.text, 40),
1072
+ level: 0, path: "", time: g.time, snippet: snippetAround(msg.text, q, 36)
1073
+ }, nm);
1074
+ }
1075
+ }
1076
+ }
1077
+ return rows;
1078
+ }, [groups, query, searchScope, headingPaths]);
1079
+ headingPathsRef.current = headingPaths;
831
1080
  react.useEffect(function () {
832
1081
  var sp = document.querySelector("[data-conversation-scroll]");
833
1082
  if (!sp) return;
@@ -874,6 +1123,43 @@ window.__ModuleLoader__.load({
874
1123
  }
875
1124
  }
876
1125
  }
1126
+ // breadcrumb: the heading of the section that occupies most of the
1127
+ // viewport — i.e. the deepest heading above the viewport's MIDDLE line.
1128
+ // Walking backwards lets a heading we cannot map (e.g. one inside a tool
1129
+ // result) fall back to the nearest mapped ancestor heading.
1130
+ //
1131
+ // The middle line is what makes this consistent with every jump: a jump
1132
+ // (outline click, breadcrumb click, match stepping) always lands its
1133
+ // target ABOVE the middle, so clicking the breadcrumb never changes
1134
+ // which heading the breadcrumb shows.
1135
+ var crumbLine = vTop + lr.height * 0.5;
1136
+ var heads = sp.querySelectorAll("h1,h2,h3,h4,h5,h6");
1137
+ var nextCrumb = null;
1138
+ var paths = headingPathsRef.current;
1139
+ for (var hi = heads.length - 1; hi >= 0; hi--) {
1140
+ var hr = heads[hi].getBoundingClientRect();
1141
+ if (hr.top > crumbLine) continue;
1142
+ var rowEl = heads[hi].closest ? heads[hi].closest("[data-chat-anchor-key]") : null;
1143
+ if (!rowEl || !rowEl.dataset) continue;
1144
+ var rowKey = rowEl.dataset.chatAnchorKey;
1145
+ var rowHeads = rowEl.querySelectorAll("h1,h2,h3,h4,h5,h6");
1146
+ var idxInRow = -1;
1147
+ for (var rh = 0; rh < rowHeads.length; rh++) {
1148
+ if (rowHeads[rh] === heads[hi]) { idxInRow = rh; break; }
1149
+ }
1150
+ var meta = idxInRow >= 0 ? paths[rowKey + "#" + idxInRow] : undefined;
1151
+ if (meta !== undefined) {
1152
+ nextCrumb = { path: meta.path, key: rowKey, idx: idxInRow };
1153
+ break;
1154
+ }
1155
+ }
1156
+ var prev = crumbRef.current;
1157
+ var same = (prev === null && nextCrumb === null) ||
1158
+ (prev !== null && nextCrumb !== null && prev.path === nextCrumb.path && prev.key === nextCrumb.key && prev.idx === nextCrumb.idx);
1159
+ if (!same) {
1160
+ crumbRef.current = nextCrumb;
1161
+ setCrumb(nextCrumb);
1162
+ }
877
1163
  };
878
1164
  update();
879
1165
  sp.addEventListener("scroll", update, { passive: true });
@@ -918,6 +1204,46 @@ window.__ModuleLoader__.load({
918
1204
  return out;
919
1205
  }, [groups, query, searchScope]);
920
1206
 
1207
+ // which result row holds the current (n/N) occurrence (needs `matches`)
1208
+ var activeResultRow = -1;
1209
+ if (resultRows.length > 0 && matches.length > 0) {
1210
+ var acc = 0;
1211
+ var target = matchIdx % matches.length;
1212
+ for (var ri = 0; ri < resultRows.length; ri++) {
1213
+ acc += resultRows[ri].count;
1214
+ if (target < acc) { activeResultRow = ri; break; }
1215
+ }
1216
+ }
1217
+
1218
+ // The result list follows the outline's convention: OLDEST hit at the top,
1219
+ // NEWEST at the bottom, and a fresh search starts scrolled to the bottom —
1220
+ // scroll UP from there to walk back through earlier hits. The newest hit is
1221
+ // also made the current one, so the highlighted row is the one on screen
1222
+ // (otherwise n/N points at a row that is off-view).
1223
+ var searchPosRef = react.useRef("");
1224
+ react.useEffect(function () {
1225
+ var q = query.trim();
1226
+ if (searchPosRef.current === q) return;
1227
+ searchPosRef.current = q;
1228
+ if (!q) return;
1229
+ var el = listRef.current;
1230
+ if (el) el.scrollTop = el.scrollHeight;
1231
+ if (matches.length > 0) setMatchIdx(matches.length - 1);
1232
+ }, [query, matches.length]);
1233
+
1234
+ // keep the current row in view while stepping with Enter
1235
+ react.useEffect(function () {
1236
+ if (!query.trim() || activeResultRow < 0) return;
1237
+ var el = listRef.current;
1238
+ if (!el || !el.querySelector) return;
1239
+ var node = el.querySelector('[data-result-idx="' + activeResultRow + '"]');
1240
+ if (!node) return;
1241
+ var er = node.getBoundingClientRect();
1242
+ var lr2 = el.getBoundingClientRect();
1243
+ if (er.top < lr2.top + 2) el.scrollTop += er.top - lr2.top - 2;
1244
+ else if (er.bottom > lr2.bottom - 2) el.scrollTop += er.bottom - lr2.bottom + 2;
1245
+ }, [activeResultRow, query]);
1246
+
921
1247
  // ---- nothing to show without headings ----
922
1248
  if (groups.length === 0) return null;
923
1249
 
@@ -949,15 +1275,19 @@ window.__ModuleLoader__.load({
949
1275
  if (e.button !== 0) return;
950
1276
  var startY = e.clientY;
951
1277
  var origY = panelY;
1278
+ var lastY = panelY;
952
1279
  var move = function (ev) {
953
1280
  var next = origY + (ev.clientY - startY);
954
1281
  next = Math.max(-(viewport ? viewport.top : 0), Math.min(window.innerHeight - 90, next));
1282
+ lastY = next;
955
1283
  setPanelY(next);
956
1284
  };
957
1285
  var up = function () {
958
1286
  window.removeEventListener("pointermove", move);
959
1287
  window.removeEventListener("pointerup", up);
960
- try { localStorage.setItem(PANEL_Y_KEY, String(panelY)); } catch (e2) {}
1288
+ // persist the DRAGGED value: `panelY` here is the value captured when
1289
+ // the drag started (the state setter has not re-rendered this closure)
1290
+ try { localStorage.setItem(PANEL_Y_KEY, String(lastY)); } catch (e2) {}
961
1291
  };
962
1292
  window.addEventListener("pointermove", move);
963
1293
  window.addEventListener("pointerup", up);
@@ -1033,6 +1363,15 @@ window.__ModuleLoader__.load({
1033
1363
  }, 150);
1034
1364
  };
1035
1365
 
1366
+ // clicking result row `i` = make its FIRST occurrence the current match, so
1367
+ // it highlights + scrolls exactly like Enter stepping does (rows are deduped
1368
+ // one-per-message, so row i starts at the sum of the previous rows' counts)
1369
+ var goToRow = function (i) {
1370
+ var start = 0;
1371
+ for (var k = 0; k < i && k < resultRows.length; k++) start += resultRows[k].count;
1372
+ goToMatch(start);
1373
+ };
1374
+
1036
1375
  // ---- circular icon button (glyph optical offset: ox/oy px) ----
1037
1376
  var iconBtn = function (onClick, tip, glyph, fontSize, offset) {
1038
1377
  var ox = offset ? (offset.x || 0) : 0;
@@ -1201,6 +1540,29 @@ window.__ModuleLoader__.load({
1201
1540
  react_jsx_runtime.jsx("div", {
1202
1541
  style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
1203
1542
  children: [
1543
+ // collapse/expand the H1–H6 level-filter row (sits directly
1544
+ // left of the search button). Labelled, not a bare icon.
1545
+ react_jsx_runtime.jsx("button", {
1546
+ onClick: toggleLevelsRow,
1547
+ title: showLevelsRow ? "收起 H1–H6 层级筛选那一行" : "展开 H1–H6 层级筛选那一行",
1548
+ style: {
1549
+ height: 20,
1550
+ padding: "0 6px",
1551
+ border: "none",
1552
+ borderRadius: 5,
1553
+ cornerShape: "round",
1554
+ fontFamily: "inherit",
1555
+ fontSize: 11,
1556
+ lineHeight: "20px",
1557
+ cursor: "pointer",
1558
+ flex: "none",
1559
+ background: showLevelsRow
1560
+ ? "rgba(79,140,255,0.16)"
1561
+ : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
1562
+ color: showLevelsRow ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted
1563
+ },
1564
+ children: "层级"
1565
+ }),
1204
1566
  // magnifier button (SVG, matches the other buttons' style)
1205
1567
  react_jsx_runtime.jsx("button", {
1206
1568
  onClick: function () { searchOpen ? closeSearch() : openSearch(); },
@@ -1292,6 +1654,67 @@ window.__ModuleLoader__.load({
1292
1654
  })
1293
1655
  ]
1294
1656
  }),
1657
+ // level filter row (H1–H6): can be collapsed away with the 层级 button
1658
+ // in the panel header
1659
+ showLevelsRow ? react_jsx_runtime.jsx("div", {
1660
+ style: {
1661
+ flex: "none",
1662
+ display: "flex",
1663
+ alignItems: "center",
1664
+ justifyContent: "space-between",
1665
+ flexWrap: "wrap",
1666
+ rowGap: 2,
1667
+ gap: 6,
1668
+ padding: "3px 10px 3px",
1669
+ borderBottom: crumb ? "none" : "1px solid " + C.panelBorder
1670
+ },
1671
+ children: [
1672
+ react_jsx_runtime.jsx("div", {
1673
+ style: { display: "flex", alignItems: "center", gap: 2, flex: "none" },
1674
+ children: [1, 2, 3, 4, 5, 6].map(function (lv) {
1675
+ var active = !!levelSet[lv];
1676
+ return react_jsx_runtime.jsx("button", {
1677
+ onClick: function () { toggleLevel(lv); },
1678
+ title: active ? "隐藏 H" + lv : "显示 H" + lv,
1679
+ style: {
1680
+ width: 17,
1681
+ height: 17,
1682
+ padding: 0,
1683
+ border: "none",
1684
+ borderRadius: 4,
1685
+ cornerShape: "round",
1686
+ cursor: "pointer",
1687
+ fontSize: 10,
1688
+ lineHeight: "17px",
1689
+ background: active
1690
+ ? "rgba(79,140,255,0.22)"
1691
+ : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
1692
+ color: active ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
1693
+ fontWeight: active ? 600 : 400
1694
+ },
1695
+ children: lv
1696
+ }, "lv" + lv);
1697
+ })
1698
+ })
1699
+ ]
1700
+ }) : null,
1701
+ // breadcrumb: the heading path you are currently reading
1702
+ crumb ? react_jsx_runtime.jsx("div", {
1703
+ onClick: function () { jump(crumb.key, crumb.idx); },
1704
+ title: crumb.path,
1705
+ style: {
1706
+ flex: "none",
1707
+ padding: "0 10px 4px",
1708
+ borderBottom: "1px solid " + C.panelBorder,
1709
+ fontSize: 10,
1710
+ color: C.muted,
1711
+ whiteSpace: "nowrap",
1712
+ overflow: "hidden",
1713
+ textOverflow: "ellipsis",
1714
+ cursor: "pointer"
1715
+ },
1716
+ children: crumb.path
1717
+ }) : null,
1295
1718
  // search input row: outer grid row animates 0fr<->1fr so the row
1296
1719
  // collapses/expands to its EXACT natural height (no max-height
1297
1720
  // overshoot stutter); the outline below moves smoothly with it
@@ -1450,7 +1873,11 @@ window.__ModuleLoader__.load({
1450
1873
  className: "dqt-list",
1451
1874
  style: {
1452
1875
  overflowY: "auto",
1453
- padding: "6px 8px",
1876
+ // NO top padding: a sticky child is confined to its containing
1877
+ // block, so a padded scroll container would hold the pinned group
1878
+ // header that many pixels below the toolbar (a visible gap). Spacing
1879
+ // between groups comes from the per-group divider instead.
1880
+ padding: "0 8px 6px",
1454
1881
  flex: "1 1 auto",
1455
1882
  minHeight: 0,
1456
1883
  direction: dockRight ? "ltr" : "rtl"
@@ -1459,7 +1886,9 @@ window.__ModuleLoader__.load({
1459
1886
  onWheel: onListWheel,
1460
1887
  children: react_jsx_runtime.jsx("div", {
1461
1888
  style: { direction: "ltr" },
1462
- children: renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup)
1889
+ children: query.trim()
1890
+ ? renderResults(resultRows, query.trim().toLowerCase(), C, goToRow, activeResultRow)
1891
+ : renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup)
1463
1892
  })
1464
1893
  }),
1465
1894
  // resize handles (right edge: width, bottom edge: height)
@@ -1571,6 +2000,12 @@ window.__ModuleLoader__.load({
1571
2000
  }
1572
2001
 
1573
2002
  function renderItem(n, depth, jump, C, uid) {
2003
+ var hasChildren = !!(n.children && n.children.length);
2004
+ var parts = [];
2005
+ parts.push(react_jsx_runtime.jsx("span", {
2006
+ style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" },
2007
+ children: n.title
2008
+ }, uid + "-l"));
1574
2009
  return react_jsx_runtime.jsx(
1575
2010
  "div",
1576
2011
  {
@@ -1578,8 +2013,11 @@ window.__ModuleLoader__.load({
1578
2013
  "data-jump-key": n.key,
1579
2014
  "data-jump-idx": n.idx !== undefined ? String(n.idx) : "0",
1580
2015
  style: {
2016
+ display: "flex",
2017
+ alignItems: "center",
2018
+ gap: 2,
1581
2019
  padding: "2px 6px",
1582
- paddingLeft: 8 + (n.level - 1) * 12,
2020
+ paddingLeft: (hasChildren ? 2 : 6) + (n.level - 1) * 12,
1583
2021
  margin: "1px 0",
1584
2022
  borderRadius: 6,
1585
2023
  cursor: "pointer",
@@ -1589,13 +2027,12 @@ window.__ModuleLoader__.load({
1589
2027
  lineHeight: "18px",
1590
2028
  height: 22,
1591
2029
  whiteSpace: "nowrap",
1592
- overflow: "hidden",
1593
- textOverflow: "ellipsis"
2030
+ overflow: "hidden"
1594
2031
  },
1595
2032
  onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
1596
2033
  onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
1597
2034
  title: n.title,
1598
- children: n.title
2035
+ children: parts
1599
2036
  },
1600
2037
  uid + "-" + n.level + "-" + (n.key || "")
1601
2038
  );
@@ -1613,23 +2050,130 @@ window.__ModuleLoader__.load({
1613
2050
  return out;
1614
2051
  }
1615
2052
 
1616
- // a group's header: clicking the time jumps to that turn's start.
2053
+ // ---- search results view ----
2054
+ // While a query is present the list shows every matched heading/message
2055
+ // (grouped per message, with an occurrence count) instead of the outline, so
2056
+ // search is "see them all, then jump" rather than stepping blindly.
2057
+ function hitSpans(text, q) {
2058
+ var parts = highlightParts(text, q);
2059
+ return parts.map(function (p, i) {
2060
+ return p.hit
2061
+ ? react_jsx_runtime.jsx("span", {
2062
+ style: { background: "rgba(255,196,0,0.32)", borderRadius: 2, color: "inherit" },
2063
+ children: p.text
2064
+ }, "hp" + i)
2065
+ : p.text;
2066
+ });
2067
+ }
2068
+
2069
+ function renderResultRow(r, i, q, C, onRow, isActive) {
2070
+ var meta = [];
2071
+ if (r.time) meta.push(r.time);
2072
+ if (r.path) meta.push(r.path);
2073
+ if (r.count > 1) meta.push("×" + r.count);
2074
+ var children = [
2075
+ react_jsx_runtime.jsx("div", {
2076
+ style: {
2077
+ fontSize: 12,
2078
+ fontWeight: r.level > 0 && r.level <= 2 ? 600 : 400,
2079
+ color: r.level > 0 ? C.text : C.muted,
2080
+ whiteSpace: "nowrap",
2081
+ overflow: "hidden",
2082
+ textOverflow: "ellipsis"
2083
+ },
2084
+ children: hitSpans(r.title, q)
2085
+ }, "t" + i)
2086
+ ];
2087
+ if (r.snippet) {
2088
+ children.push(react_jsx_runtime.jsx("div", {
2089
+ style: {
2090
+ fontSize: 11,
2091
+ color: C.muted,
2092
+ marginTop: 1,
2093
+ whiteSpace: "nowrap",
2094
+ overflow: "hidden",
2095
+ textOverflow: "ellipsis"
2096
+ },
2097
+ children: hitSpans(r.snippet, q)
2098
+ }, "s" + i));
2099
+ }
2100
+ if (meta.length) {
2101
+ children.push(react_jsx_runtime.jsx("div", {
2102
+ style: { fontSize: 10, color: C.muted, opacity: 0.8, marginTop: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
2103
+ children: meta.join(" · ")
2104
+ }, "m" + i));
2105
+ }
2106
+ return react_jsx_runtime.jsx("div", {
2107
+ // clicking a row makes that hit the CURRENT one: it highlights the
2108
+ // keyword in the conversation and scrolls there, exactly like Enter
2109
+ // stepping — a row that only scrolled (no highlight) was the old bug
2110
+ onClick: function () { onRow(i); },
2111
+ "data-result-idx": i,
2112
+ onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
2113
+ onMouseLeave: function (e) { e.currentTarget.style.background = isActive ? "rgba(79,140,255,0.16)" : "transparent"; },
2114
+ title: r.path || r.title,
2115
+ style: {
2116
+ padding: "4px 6px",
2117
+ margin: "1px 0",
2118
+ borderRadius: 6,
2119
+ cursor: "pointer",
2120
+ minWidth: 0,
2121
+ background: isActive ? "rgba(79,140,255,0.16)" : "transparent",
2122
+ transition: "background 0.15s ease"
2123
+ },
2124
+ children: children
2125
+ }, "res-" + i);
2126
+ }
2127
+
2128
+ function renderResults(rows, q, C, onRow, activeRow) {
2129
+ if (!rows || rows.length === 0) {
2130
+ return react_jsx_runtime.jsx("div", {
2131
+ style: { padding: "12px 8px", fontSize: 12, color: C.muted, textAlign: "center" },
2132
+ children: "没有匹配"
2133
+ });
2134
+ }
2135
+ var out = [];
2136
+ for (var i = 0; i < rows.length; i++) {
2137
+ out.push(renderResultRow(rows[i], i, q, C, onRow, i === activeRow));
2138
+ }
2139
+ return out;
2140
+ }
2141
+
2142
+ // a group's header (the time row): clicking it jumps to the TOP OF THE
2143
+ // MODEL'S REPLY for that turn — the first assistant step — which also makes
2144
+ // heading-less turns jumpable, since they only have this row.
1617
2145
  // Implemented as a function (not an inline closure in a loop) so each
1618
2146
  // header captures its own (g, gi) — the var-in-loop closure bug would
1619
2147
  // otherwise make every header jump to the last group.
1620
2148
  function renderGroupHeader(g, gi, jump, C) {
2149
+ var replyKey = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : "";
2150
+ var targetKey = replyKey || g.userKey;
1621
2151
  var jumpToTurn = function (e) {
1622
2152
  e.stopPropagation();
1623
- if (!g.userKey) return;
1624
- var r0 = findRowStrict(g.userKey);
1625
- if (r0) jump(g.userKey);
1626
- else if (g.headings.length > 0) jump(g.headings[0].key, g.headings[0].idx);
2153
+ // prefer the model's reply; fall back to the user message, then to the
2154
+ // turn's first heading, so the row always does something useful
2155
+ if (replyKey && findRow(replyKey)) { jump(replyKey); return; }
2156
+ if (g.userKey && findRow(g.userKey)) { jump(g.userKey); return; }
2157
+ if (g.headings.length > 0) jump(g.headings[0].key, g.headings[0].idx);
1627
2158
  };
1628
2159
  return react_jsx_runtime.jsx("div", {
1629
- style: { padding: "1px 4px 2px", height: 18, display: "flex", alignItems: "center", minWidth: 0 },
2160
+ style: {
2161
+ padding: "1px 4px 2px",
2162
+ height: 18,
2163
+ display: "flex",
2164
+ alignItems: "center",
2165
+ minWidth: 0,
2166
+ // sticky: while you scroll the outline, the header of the group you are
2167
+ // inside stays pinned at the top (background must be opaque to cover
2168
+ // the rows scrolling underneath).
2169
+ position: "sticky",
2170
+ top: 0,
2171
+ zIndex: 2,
2172
+ background: C.panelBg
2173
+ },
1630
2174
  children: react_jsx_runtime.jsx("span", {
1631
2175
  onClick: jumpToTurn,
1632
- title: g.userKey ? "跳转到该回合开头" : "",
2176
+ title: replyKey ? "跳转到该回合的模型回答开头" : "跳转到该回合开头",
1633
2177
  onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
1634
2178
  onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
1635
2179
  style: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-quick-toc",
3
- "version": "0.3.3",
4
- "description": "Conversation TOC for DeepSeek Harness (DSH): turn-grouped Markdown heading outline with keyword search (title/full-text scope), in-chat match highlighting, auto-follow and smooth jump navigation",
3
+ "version": "0.4.0",
4
+ "description": "Conversation TOC for DeepSeek Harness (DSH): turn-grouped Markdown heading outline with keyword search (title/full-text scope), in-chat match highlighting, per-level heading filtering, auto-follow and smooth jump navigation",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [
@@ -35,7 +35,8 @@
35
35
  },
36
36
  "compatibility": {
37
37
  "dshReleases": {
38
- "0.1.5-rc.1": "compatible"
38
+ "0.1.5-rc.1": "compatible",
39
+ "0.1.5-rc.2": "compatible"
39
40
  }
40
41
  }
41
42
  },