dsh-quick-toc 0.5.0 → 0.5.1

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,24 @@
2
2
 
3
3
  All notable changes to **dsh-quick-toc** are documented here. Chinese version: [CHANGELOG.md](CHANGELOG.md).
4
4
 
5
+ ## [0.5.1] - 2026-09-13
6
+
7
+ ### Added
8
+ - **"Back to the newest row" button**: a round button in the outline list's lower-right corner. It fades in whenever the list is not at its bottom and fades out once it is; clicking scrolls the outline back to the newest entry (smooth for a short distance, instant when the list is far up).
9
+ - **Turn stamps carry the day**: the time shown in group headers, search result rows and hover cards is no longer a bare `HH:MM` — yesterday reads `昨天 15:04`, the day before `前天 15:04`, anything older `25-09-11 15:04` (two-digit year; decided by calendar day, not a 24-hour difference).
10
+ - **Failures are reported**: a turn that produced no reply at all (request timeout, upstream error) now shows a `请求失败` row under its group header with the host's own error text; clicking it jumps to that error in the conversation, and the error text is searchable in full-text scope (its result row is tagged 请求失败).
11
+
12
+ ### Changed
13
+ - **The header now holds two buttons per side**: the heading-level filter and the dock toggle on the left, search and collapse on the right, with a spacer pinning the pairs to the two ends; the three-bar identity mark on the left is gone.
14
+ - **The level filter popup is left-aligned with its button** (it used to be right-aligned).
15
+ - **The mount diagnostic is silent by default**: the console no longer prints `[dsh-quick-toc] panel mounted …`. To diagnose, run `localStorage.setItem("dsh-quick-toc.debug", "1")` and reload — the line then reports `turnOutline` and the jump loader's stage-by-stage state, while normal use stays quiet.
16
+ - **Long jumps land instantly**: a jump across a long history no longer "stops halfway" (short jumps still glide smoothly); after landing it re-checks for ~0.6s, which corrects a target that moved while content was mounting.
17
+
18
+ ### Fixed
19
+ - **Failed or aborted turns were mislabelled 未加载**: those turns have no model reply, so they carried neither a time nor headings and were re-listed as unloaded turns. A loaded user message now counts as "this turn is loaded", failures additionally show their error row, and a user-aborted turn simply shows as a heading-less turn.
20
+ - **Scrolling the outline up to load older messages bounced the transcript back to the newest turn**, so paging up never got anywhere: the host force-scrolls to the bottom whenever a scroll is not attributed to the reader while it still believes the reader sits at the bottom, and a page-in triggered from the outline matches exactly that condition. The plugin now lifts the transcript just off the bottom stick zone before clicking the host's "load earlier", so the page-in keeps its place.
21
+ - **A heading-less turn's group header was formatted unlike every other header**: it used to be enlarged (12px / 600 / primary colour), which made it read as a different kind of entry. All group headers now share one style (11px, secondary colour, one row box); a heading-less turn is simply a group with no rows under its header.
22
+
5
23
  ## [0.5.0] - 2026-09-12
6
24
 
7
25
  ### Added
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  **dsh-quick-toc** 的重要变更都记录在这里。英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
4
4
 
5
+ ## [0.5.1] - 2026-09-13
6
+
7
+ ### 新增
8
+ - **「回到底部」浮动按钮**:大纲列表右下角的圆形按钮。列表不在底部时渐显,滚到底后渐隐;点击把大纲滚回最新一条(近距离平滑滚动,长距离即时到位)。
9
+ - **回合时间带日期**:组头、搜索结果行与悬停卡片里的时间不再只有 `HH:MM`——昨天显示 `昨天 15:04`,前天显示 `前天 15:04`,更早显示 `25-09-11 15:04`(年份取后两位;按自然日判定,不是按 24 小时差)。
10
+ - **失败回合的报错**:请求超时、上游报错这类没有产出任何回复的回合,现在在组头下方显示一行 `请求失败` 与宿主提供的报错原文;点击跳到对话中该报错的位置,报错原文同样能被全文搜索命中(结果行带「请求失败」标记)。
11
+
12
+ ### 变更
13
+ - **标题栏改为一边两个按钮**:左侧「层级筛选 + 停靠切换」,右侧「搜索 + 收起」,中间留白把两组顶到两端;并去掉左侧的三条杠装饰标识。
14
+ - **层级筛选弹层改为左对齐按钮**(此前在按钮右侧对齐)。
15
+ - **挂载诊断日志默认静默**:控制台不再打印 `[dsh-quick-toc] panel mounted …`。需要排查时先执行 `localStorage.setItem("dsh-quick-toc.debug", "1")` 再刷新即可看到该行(`turnOutline` 与跳转加载器的分阶段状态),正常使用不再有噪音。
16
+ - **长距离跳转改为即时定位**:跨越很长历史的跳转不再"滚到一半停下"(近距离仍是平滑滚动),落地后还有约 0.6 秒的校验,用于纠正内容高度在飞行途中变化的情况。
17
+
18
+ ### 修复
19
+ - **失败或中止的回合被误标成「未加载」**:这类回合没有模型回复,既没有时间也没有标题,被当成尚未加载的回合重新列了一遍。现在"该回合的用户消息已加载"本身就是已加载的依据,失败回合另加报错行;被用户中止的回合按普通无标题回合显示。
20
+ - **从大纲往上翻加载更早消息时,对话区会被弹回最新一条**,越翻越回到底部:宿主在"这次滚动不是读者发起、且它仍认为读者停在底部"时会强制滚回底部,而从大纲触发的翻页恰好命中该条件。现在点击宿主的「加载更早」之前会先把对话区抬离贴底区,翻页保持原位。
21
+ - **无标题回合的组头与其它组头格式不一致**:它此前被整体放大(12px / 600 / 正文色),与常规组头并列时像另一种条目。现在所有组头共用同一套样式(11px、次要色、同一行盒),无标题回合只是"组头下面没有行"。
22
+
5
23
  ## [0.5.0] - 2026-09-12
6
24
 
7
25
  ### 新增
package/README.en.md CHANGED
@@ -8,6 +8,8 @@ A conversation TOC plugin for [DeepSeek Harness](https://github.com/deepseek-ai/
8
8
 
9
9
  - **Turn-grouped outline** — each user message plus its following AI replies form one group; the group header shows the turn's time and a first-line preview, and clicking it jumps to the start of that turn's model reply
10
10
  - **Whole-session coverage** — turns the conversation window has not loaded are listed too (tagged 未加载, with previews); clicking one loads that turn and jumps to it
11
+ - **Failures are reported** — a turn with no reply at all (request timeout, upstream error) shows `请求失败` plus the host's error text, and clicking it jumps to that error in the conversation
12
+ - **Turn stamps carry the day** — yesterday reads `昨天 15:04`, the day before `前天 15:04`, anything older `25-09-11 15:04`, so times never blur together in a session spanning days
11
13
  - **Row subtitles** — under each heading, the first sentence of that section, so identically-titled headings can be told apart
12
14
  - **Hover previews** — hovering a heading row shows the section's opening, the turn time and the heading path
13
15
  - **Search** — title / full-text scopes; click any result row to locate and highlight that hit, `n/N` Enter stepping, Esc to close
@@ -17,6 +19,7 @@ A conversation TOC plugin for [DeepSeek Harness](https://github.com/deepseek-ai/
17
19
  - **Heading level filter** — the round levels button in the header pops down H1–H6 switches for any combination
18
20
  - **Auto-follow** — the turn being read lights up in a closed blue box while you scroll the conversation; the outline follows on its own
19
21
  - **Jumping** — click a heading to jump to its position in the conversation
22
+ - **Back to the newest row** — after paging far up, a floating button in the list's lower-right corner returns to the newest entry in one click (it appears when the list is not at its bottom and disappears once it is)
20
23
  - **Dockable and resizable** — drag the top bar to move the panel, ◀ / ▶ to dock left or right, drag an edge to resize, and collapse it into an edge handle; position and size are remembered
21
24
  - **Paging** — the most recent groups show first; scrolling up both expands the index and loads older conversation
22
25
  - **Edge hints** — a brief hint at the bottom of the panel when you keep scrolling past the first or the last entry
@@ -28,13 +31,14 @@ A conversation TOC plugin for [DeepSeek Harness](https://github.com/deepseek-ai/
28
31
 
29
32
  | Plugin | Supported DSH |
30
33
  | --- | --- |
31
- | **0.5.0** (latest) | 0.1.5-rc.1, 0.1.5-rc.2 |
34
+ | **0.5.1** (latest) | 0.1.5-rc.1, 0.1.5-rc.2 |
35
+ | 0.5.0 | 0.1.5-rc.1, 0.1.5-rc.2 |
32
36
  | 0.4.1 | 0.1.5-rc.1, 0.1.5-rc.2 |
33
37
  | 0.4.0 | 0.1.5-rc.1, 0.1.5-rc.2 |
34
38
  | 0.3.3 | 0.1.5-rc.1 |
35
39
  | 0.3.0 – 0.3.2 | ≥ 0.1.2-rc.1 |
36
40
 
37
- `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.5.0 was tested on **0.1.5-rc.2**, and the host interfaces it newly relies on (the `turnOutline` projection, the session-scoped `useProjection`, the client `sessions` service and its `loadThrough` jump loader) were checked one by one against the installed **0.1.5-rc.1** bundles, so both are declared. 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`.
41
+ `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.5.1 was tested on **0.1.5-rc.2**, and the host interfaces it relies on the `turnOutline` projection, the session-scoped `useProjection`, the client `sessions` service and its `loadThrough` jump loader (since 0.5.0), and the `turn-error` node used for failed turns (since 0.5.1) — were checked one by one against the installed **0.1.5-rc.1** bundles, so both are declared. 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`.
38
42
 
39
43
  ## Install
40
44
 
@@ -60,15 +64,26 @@ After installing, restart DSH and open the Web UI. The panel starts collapsed; c
60
64
 
61
65
  ## Usage
62
66
 
63
- - **Jumping**: click an outline heading or a group header to jump to it; entries tagged 未加载 load that turn first
67
+ - **Jumping**: click an outline heading or a group header to jump to it; entries tagged 未加载 load that turn first, and a failed turn's error row jumps to that error in the conversation
64
68
  - **Search**: open the box with the magnifier, click a result to locate and highlight it, Enter to step through matches, Esc to close
65
69
  - **Search tolerance**: full-width/half-width, case and whitespace differences match automatically; for looser matching turn on the "fuzzy" switch next to the search box
66
70
  - **Level filter**: click the round levels button in the header to pop down the H1–H6 switches
67
71
  - **Moving and docking**: drag the top bar to move, ◀ / ▶ to switch sides
68
72
  - **Resizing**: drag the right edge, bottom edge or bottom-right corner
69
73
  - **Loading older turns**: scroll up inside the outline (it both expands the index and loads older conversation)
74
+ - **Back to the newest row**: after paging far up, click the floating button in the list's lower-right corner
70
75
  - **Reading position**: scroll the conversation and the turn you are reading is boxed in blue; the outline follows
71
76
 
77
+ ## Diagnostics
78
+
79
+ Mounting the panel prints **nothing** by default. To check the host facilities, run this once in the browser console:
80
+
81
+ ```
82
+ localStorage.setItem("dsh-quick-toc.debug", "1")
83
+ ```
84
+
85
+ then reload: the console shows one line, `[dsh-quick-toc] panel mounted · turnOutline=… · jumpLoader=…`. A missing `turnOutline` means the host has no such projection, and the staged `jumpLoader` wording (`no-sessions-service` / `no-binding-api` / `no-binding-for-session` / `no-loadThrough` / `binding-threw`) pinpoints which link of the jump bridge is broken. Turn it off again with `localStorage.removeItem("dsh-quick-toc.debug")`.
86
+
72
87
  ## Development
73
88
 
74
89
  - `lib/client.js` — all UI logic (browser side)
@@ -76,6 +91,7 @@ After installing, restart DSH and open the Web UI. The panel starts collapsed; c
76
91
  - `cordis.patch.yml` — loader patch (official DSH bundle format)
77
92
  - The panel registers into the session-scoped `conversation.input.overlay` slot so it receives session-scoped hooks (`useChat`, `useSession`, `sessionId`, …), and renders itself through `createPortal` into `document.body` as a fixed floating dock; conversation data comes from `props.useChat` (`ChatSnapshot.order` and `nodes`; node shape: `kind: user/assistant-step`, `location.turn`, `data.blocks`)
78
93
  - The "unloaded turn" capability needs two host facilities: the `turnOutline` projection (the whole-session turn index, each entry carrying its `turn/start` seq) and the turn-jump loader (the client `sessions` service's `binding(sessionId).session.loadThrough(seq)`, reached through the client ctx's declaration-free `ctx.get("sessions")` lookup). Each degrades on its own: without the projection the outline lists loaded turns only, without the loader unloaded entries are shown but not jumped to, and nothing else in the panel is affected.
94
+ - The failure row reads the host's `turn-error` conversation node (published when `turn/end` carries an `error` reason, with `message` and an optional `code`); on a host without that node the row is simply absent and nothing else changes.
79
95
  - Changes to `lib/client.js` show up after a page refresh (client modules are served under a content hash and DSH's client HMR pushes reloads); restart DSH only if that does not take
80
96
 
81
97
  ## License
package/README.md CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  - **按回合分组** —— 每条用户消息 + 其后续 AI 回复为一组,组头显示回合时间与首行预览,点击跳到该回合模型回答的开头
10
10
  - **覆盖整段会话** —— 未加载进对话窗口的回合也在列表里(带「未加载」标记与预览);点击即加载该回合并跳过去
11
+ - **失败回合的报错** —— 请求超时 / 上游报错这类没有回复的回合显示 `请求失败` 与报错原文,点击跳到对话中的报错位置
12
+ - **回合时间带日期** —— 昨天 `昨天 15:04`、前天 `前天 15:04`、更早 `25-09-11 15:04`,跨天的会话里时间不再重影
11
13
  - **标题行副标题** —— 每行标题下方显示该节正文的第一句,同名标题一眼可辨
12
14
  - **悬停预览** —— 悬停标题行显示该节开头、回合时间与层级路径
13
15
  - **搜索** —— 标题 / 全文两种范围,结果列表点击定位并高亮,`n/N` 回车逐处跳,Esc 关闭
@@ -17,6 +19,7 @@
17
19
  - **层级筛选** —— 标题栏的圆形层级按钮弹出 H1–H6 开关,任意组合显示
18
20
  - **自动跟随** —— 滚动对话时正在阅读的回合自动点亮(封闭蓝框),大纲自动跟随
19
21
  - **跳转** —— 点击标题跳到对话中该标题的位置;面板与对话双向定位
22
+ - **回到底部** —— 往上翻远之后,列表右下角的浮动按钮一步回到最新一条(不在底部时出现、到底后消失)
20
23
  - **可停靠、可缩放** —— 拖顶部横条移动,◀ / ▶ 切换左右停靠,拖边缘调宽高,收起后成为边缘把手;位置与尺寸会被记住
21
24
  - **分页** —— 默认显示最近的若干组,向上滚动既展开索引也加载更早的对话
22
25
  - **边界提示** —— 滚到最早或最新再继续滚动时,面板底部短暂提示
@@ -28,13 +31,14 @@
28
31
 
29
32
  | 插件版本 | 支持的 DSH 版本 |
30
33
  | --- | --- |
31
- | **0.5.0**(最新) | 0.1.5-rc.1、0.1.5-rc.2 |
34
+ | **0.5.1**(最新) | 0.1.5-rc.1、0.1.5-rc.2 |
35
+ | 0.5.0 | 0.1.5-rc.1、0.1.5-rc.2 |
32
36
  | 0.4.1 | 0.1.5-rc.1、0.1.5-rc.2 |
33
37
  | 0.4.0 | 0.1.5-rc.1、0.1.5-rc.2 |
34
38
  | 0.3.3 | 0.1.5-rc.1 |
35
39
  | 0.3.0 – 0.3.2 | ≥ 0.1.2-rc.1 |
36
40
 
37
- `engines.dsh` 下限为 **0.1.5-rc.1**(`>=`,该版本起插件改用会话级槽位注入的宿主接口)。0.5.0 在 **0.1.5-rc.2** 上实测通过;它新依赖的宿主接口(`turnOutline` 投影、会话级槽位的 `useProjection`、客户端 `sessions` 服务与 `loadThrough` 跳转加载器)已在 **0.1.5-rc.1** 的安装包中逐个核对存在,因此两版都声明兼容。更早或更新的 DSH 版本未经验证,不作声明;在更早的 DSH 上可安装的最新插件版本是 **0.3.2**。安装或更新时,DSH 市场会依据 `package.json` 中的 `engines.dsh`、`dsh.compatibility.dshReleases` 与 `peerDependencies` 做宿主兼容预检。
41
+ `engines.dsh` 下限为 **0.1.5-rc.1**(`>=`,该版本起插件改用会话级槽位注入的宿主接口)。0.5.1 在 **0.1.5-rc.2** 上实测通过;它依赖的宿主接口——`turnOutline` 投影、会话级槽位的 `useProjection`、客户端 `sessions` 服务与 `loadThrough` 跳转加载器(0.5.0 起)、以及失败回合所用的 `turn-error` 节点(0.5.1 起)——已在 **0.1.5-rc.1** 的安装包中逐个核对存在,因此两版都声明兼容。更早或更新的 DSH 版本未经验证,不作声明;在更早的 DSH 上可安装的最新插件版本是 **0.3.2**。安装或更新时,DSH 市场会依据 `package.json` 中的 `engines.dsh`、`dsh.compatibility.dshReleases` 与 `peerDependencies` 做宿主兼容预检。
38
42
 
39
43
  ## 安装
40
44
 
@@ -60,15 +64,26 @@ dsh plugin --profile web add <插件目录路径>
60
64
 
61
65
  ## 使用
62
66
 
63
- - **跳转**:点击大纲标题或组头跳到对应位置;带「未加载」标记的条目会先把该回合加载进来再跳
67
+ - **跳转**:点击大纲标题或组头跳到对应位置;带「未加载」标记的条目会先把该回合加载进来再跳;失败回合点报错行跳到对话中的报错位置
64
68
  - **搜索**:放大镜打开搜索框,输入后点结果定位并高亮,回车逐处跳,Esc 关闭
65
69
  - **搜索容错**:全角/半角、大小写、空格差异会自动匹配;需要更宽松时点搜索框右侧的「模糊」开关
66
70
  - **层级筛选**:点标题栏的圆形层级按钮弹出 H1–H6 开关,选择显示的层级
67
71
  - **移动与停靠**:拖顶部横条移动,◀ / ▶ 切换左右停靠
68
72
  - **调整大小**:拖右边缘、下边缘或右下角
69
73
  - **加载更早**:在大纲中向上滚动(既展开索引,也加载更早的对话)
74
+ - **回到底部**:翻远了之后点列表右下角的浮动按钮回到最新一条
70
75
  - **阅读位置**:滚动对话,正在阅读的回合会以蓝框标出;大纲会自动跟随
71
76
 
77
+ ## 诊断
78
+
79
+ 面板挂载时默认**不打印**任何日志。需要排查宿主能力时,在浏览器控制台执行一次:
80
+
81
+ ```
82
+ localStorage.setItem("dsh-quick-toc.debug", "1")
83
+ ```
84
+
85
+ 再刷新页面,控制台会出现一行 `[dsh-quick-toc] panel mounted · turnOutline=… · jumpLoader=…`:`turnOutline` 缺失说明宿主没有该投影,`jumpLoader` 的分阶段措辞(`no-sessions-service` / `no-binding-api` / `no-binding-for-session` / `no-loadThrough` / `binding-threw`)可直接定位跳转桥断在哪一环。用完执行 `localStorage.removeItem("dsh-quick-toc.debug")` 关掉。
86
+
72
87
  ## 开发
73
88
 
74
89
  - `lib/client.js` —— 全部 UI 逻辑(浏览器端)
@@ -76,6 +91,7 @@ dsh plugin --profile web add <插件目录路径>
76
91
  - `cordis.patch.yml` —— loader patch(符合官方 bundle 规范)
77
92
  - 面板注册进会话级 `conversation.input.overlay` 槽以取得会话级 hook(`useChat`、`useSession`、`sessionId` 等),面板本体通过 `createPortal` 渲染到 `document.body` 成为固定浮层;对话数据来自 `props.useChat`(`ChatSnapshot.order` 与 `nodes`,节点形状:`kind: user/assistant-step`、`location.turn`、`data.blocks`)
78
93
  - 「未加载回合」能力依赖宿主的两样东西:`turnOutline` 投影(整段会话的回合索引,每条含 `turn/start` 的 seq)与会话跳转加载器(客户端 `sessions` 服务的 `binding(sessionId).session.loadThrough(seq)`,通过客户端 ctx 的 `ctx.get("sessions")` 免声明查找取得)。两者各自独立降级:没有投影时只列已加载回合,没有加载器时未加载条目只展示、不跳转,面板其余功能不受影响。
94
+ - 失败回合的报错行读的是宿主的 `turn-error` 会话节点(宿主在 `turn/end` 的原因为 `error` 时发布,含 `message` 与可选的 `code`);宿主不提供该节点时只是不显示这一行,其余功能不受影响。
79
95
  - 修改 `lib/client.js` 后刷新页面即可看到变化(客户端模块按内容哈希发版,DSH 的客户端 HMR 也会推送重载);若未生效再重启 DSH
80
96
 
81
97
  ## License
package/lib/client.js CHANGED
@@ -52,11 +52,16 @@ window.__ModuleLoader__.load({
52
52
  "search.hintMore": "向上滚动可加载更早的消息",
53
53
  "search.hintOldest": "已经是最早的消息",
54
54
  "hint.bottom": "已经到底了",
55
+ "outline.bottom": "回到底部",
55
56
  "turn.jumpReply": "跳转到该回合的模型回答开头",
56
57
  "turn.jumpTurn": "跳转到该回合开头",
57
58
  "turn.loading": "正在加载这个回合…",
58
59
  "turn.unloaded": "未加载",
59
60
  "turn.loadTip": "点击加载这个回合并跳转过去",
61
+ "turn.failed": "请求失败",
62
+ "turn.jumpFailed": "跳转到该回合的报错位置",
63
+ "time.yesterday": "昨天",
64
+ "time.beforeYesterday": "前天",
60
65
  "preview.truncated": "预览由宿主提供,可能被截断",
61
66
  "resize.w": "拖拽调整宽度",
62
67
  "resize.h": "拖拽调整高度",
@@ -592,6 +597,9 @@ window.__ModuleLoader__.load({
592
597
  text: "var(--dsw-alias-label-primary, #e8eaee)",
593
598
  muted: "var(--dsw-alias-label-secondary, #9aa0ab)",
594
599
  accent: "var(--dsw-alias-brand-primary, #4f8cff)",
600
+ // terminal turn failures (a reply that never arrived) get the host's own
601
+ // error colour, so the outline speaks the same language as the transcript
602
+ error: "var(--dsw-alias-state-error-primary, #e5534b)",
595
603
  hover: "var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08))",
596
604
  // ONE source for every "this control is on / this button is open / this match
597
605
  // is current" tint, so the panel cannot drift into five near-identical blues.
@@ -952,6 +960,41 @@ window.__ModuleLoader__.load({
952
960
  var lastScrollTopRef = react.useRef(0);
953
961
  var lastHostClickRef = react.useRef(0); // throttle for the "load earlier" click
954
962
 
963
+ // ---- "back to the newest row" button ------------------------------------
964
+ // The list can be scrolled far up into history (that is the whole point of
965
+ // paging), and the newest turns are then a long scroll away. The button
966
+ // appears whenever the list is NOT parked at its bottom and disappears once it
967
+ // is. `atBottomRef` mirrors the state so the scroll handler can tell "changed"
968
+ // from "same value" without depending on a stale render closure.
969
+ var _sAtBottom = react.useState(true);
970
+ var atBottom = _sAtBottom[0];
971
+ var setAtBottom = _sAtBottom[1];
972
+ var atBottomRef = react.useRef(true);
973
+ // a few px of slack: scrollTop is fractional and sub-pixel remainders are
974
+ // normal at the end of a smooth scroll
975
+ var syncAtBottom = function (el) {
976
+ if (!el) return;
977
+ var max = el.scrollHeight - el.clientHeight;
978
+ var next = max <= 4 || el.scrollTop >= max - 4;
979
+ if (next !== atBottomRef.current) {
980
+ atBottomRef.current = next;
981
+ setAtBottom(next);
982
+ }
983
+ };
984
+ var scrollToBottom = function (e) {
985
+ if (e && e.stopPropagation) e.stopPropagation();
986
+ outlineTouchRef.current = Date.now();
987
+ hoverEnd();
988
+ var el = listRef.current;
989
+ if (!el) return;
990
+ // optimistic: the list is on its way to the newest row, so the button starts
991
+ // fading at once; if the user interrupts the scroll the handler brings it back
992
+ atBottomRef.current = true;
993
+ setAtBottom(true);
994
+ if (typeof el.scrollTo === "function") el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
995
+ else el.scrollTop = el.scrollHeight;
996
+ };
997
+
955
998
  // ---- "scroll up to load earlier messages" hint (search mode) -----------
956
999
  // Armed by a search when older history is still reachable; dismissed the
957
1000
  // moment the reader scrolls up (which is when the older page starts loading,
@@ -1163,9 +1206,26 @@ window.__ModuleLoader__.load({
1163
1206
  }
1164
1207
  return false;
1165
1208
  };
1209
+ var liftOffBottom = function () {
1210
+ // DSH re-pins the transcript to the newest message whenever it sees a
1211
+ // scroll it does not attribute to the reader while it still believes the
1212
+ // reader is at the bottom (`toBottom`). Paging history in from the OUTLINE
1213
+ // is exactly that case: the prepend compensation DSH applies is a
1214
+ // programmatic scroll, `atBottomRef` was never cleared (the reader never
1215
+ // touched the transcript), so the page-in is immediately undone and the
1216
+ // whole view — and with it the outline's follow — snaps back to the newest
1217
+ // turn. Nudging the transcript just past DSH's 25px stick zone first makes
1218
+ // that scroll read as a reader movement, so the page-in keeps its place.
1219
+ // (Same trick `glideTo` uses so a jump is not yanked back.)
1220
+ var sp = document.querySelector("[data-conversation-scroll]");
1221
+ if (!sp) return;
1222
+ var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
1223
+ if (floor - sp.scrollTop <= 25) sp.scrollTop = Math.max(0, floor - 26);
1224
+ };
1166
1225
  var viaHost = function () {
1167
1226
  var btn = findLoadOlderButton();
1168
1227
  if (!btn || btn.disabled) return false;
1228
+ liftOffBottom();
1169
1229
  btn.click();
1170
1230
  // once the conversation loads more, expand the outline window too
1171
1231
  setTimeout(function () {
@@ -1240,6 +1300,7 @@ window.__ModuleLoader__.load({
1240
1300
  var onListScroll = function (e) {
1241
1301
  outlineTouchRef.current = Date.now();
1242
1302
  var el = e.currentTarget;
1303
+ syncAtBottom(el);
1243
1304
  var upward = el.scrollTop < lastScrollTopRef.current - 2;
1244
1305
  if (upward) {
1245
1306
  if (hint === "more") setHint("");
@@ -1332,24 +1393,39 @@ window.__ModuleLoader__.load({
1332
1393
  window.addEventListener("pointerup", up);
1333
1394
  };
1334
1395
 
1335
- // ---- node timestamp -> HH:MM (handles ms / s epochs / ISO strings) ----
1336
- var fmtTime = function (t) {
1337
- if (t === undefined || t === null) return "";
1396
+ // ---- node timestamp -> Date (handles ms / s epochs / ISO strings) ----
1397
+ var toDate = function (t) {
1398
+ if (t === undefined || t === null) return null;
1338
1399
  if (typeof t === "string") {
1339
1400
  var d0 = new Date(t);
1340
- if (isNaN(d0.getTime())) return "";
1341
- var hh0 = ("0" + d0.getHours()).slice(-2);
1342
- var mm0 = ("0" + d0.getMinutes()).slice(-2);
1343
- return hh0 + ":" + mm0;
1401
+ return isNaN(d0.getTime()) ? null : d0;
1344
1402
  }
1345
1403
  var n = Number(t);
1346
- if (!isFinite(n) || n <= 0) return "";
1404
+ if (!isFinite(n) || n <= 0) return null;
1347
1405
  if (n < 1e12) n = n * 1000; // epoch seconds -> ms
1348
1406
  var d = new Date(n);
1349
- if (isNaN(d.getTime())) return "";
1350
- var hh = ("0" + d.getHours()).slice(-2);
1351
- var mm = ("0" + d.getMinutes()).slice(-2);
1352
- return hh + ":" + mm;
1407
+ return isNaN(d.getTime()) ? null : d;
1408
+ };
1409
+ var pad2 = function (n) { return ("0" + n).slice(-2); };
1410
+ // ---- node timestamp -> HH:MM ----
1411
+ var fmtTime = function (t) {
1412
+ var d = toDate(t);
1413
+ return d ? pad2(d.getHours()) + ":" + pad2(d.getMinutes()) : "";
1414
+ };
1415
+
1416
+ // ---- turn stamp: the clock alone is ambiguous in a session that spans days,
1417
+ // so today stays "HH:MM", yesterday and the day before get a word, and anything
1418
+ // older gets its date as YY-MM-DD (weekday-free, sortable, short).
1419
+ var startOfDay = function (d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); };
1420
+ var fmtStamp = function (t) {
1421
+ var d = toDate(t);
1422
+ if (!d) return "";
1423
+ var clock = pad2(d.getHours()) + ":" + pad2(d.getMinutes());
1424
+ var days = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
1425
+ if (days <= 0) return clock; // today (a future skew also reads as today)
1426
+ if (days === 1) return T("time.yesterday") + " " + clock;
1427
+ if (days === 2) return T("time.beforeYesterday") + " " + clock;
1428
+ return pad2(d.getFullYear() % 100) + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + clock;
1353
1429
  };
1354
1430
 
1355
1431
  // ---- best-effort node time across common field names ----
@@ -1357,7 +1433,7 @@ window.__ModuleLoader__.load({
1357
1433
  var d = node && node.data;
1358
1434
  if (!d) return "";
1359
1435
  var t = d.time !== undefined ? d.time : (d.createdAt !== undefined ? d.createdAt : d.timestamp);
1360
- return fmtTime(t);
1436
+ return fmtStamp(t);
1361
1437
  };
1362
1438
 
1363
1439
  // ---- group headings by conversation turn (node.location.turn) ----
@@ -1370,12 +1446,18 @@ window.__ModuleLoader__.load({
1370
1446
  var turnUserFull = {};
1371
1447
  if (!order || !nodes) return result;
1372
1448
  var seen = {};
1449
+ // A turn's group is anchored by EVERY node that belongs to it — the user
1450
+ // message, its assistant steps, and the terminal `turn-error` the host
1451
+ // publishes when a request never produced a reply. Without the error node a
1452
+ // timed-out turn had no time and no headings, so it was dropped here and the
1453
+ // host outline re-listed it as "未加载" even though it was fully loaded.
1454
+ var anchorKind = function (kind) { return kind === "user" || kind === "assistant-step" || kind === "turn-error"; };
1373
1455
  // first pass: per-turn time — the LAST message of the turn wins (end time);
1374
1456
  // also remember each turn's user message key + first-line preview + full text
1375
1457
  for (var i = 0; i < order.length; i++) {
1376
1458
  var k0 = order[i];
1377
1459
  var n0 = nodes.get(k0);
1378
- if (!n0 || (n0.kind !== "user" && n0.kind !== "assistant-step")) continue;
1460
+ if (!n0 || !anchorKind(n0.kind)) continue;
1379
1461
  var l0 = n0.location;
1380
1462
  var tid0 = l0 && (l0.kind === "turn" || l0.kind === "step") && l0.turn ? l0.turn.turn : null;
1381
1463
  if (tid0 === null) continue;
@@ -1389,11 +1471,12 @@ window.__ModuleLoader__.load({
1389
1471
  var t0 = getNodeTime(n0);
1390
1472
  if (t0) turnTimes[tid0] = t0;
1391
1473
  }
1392
- // second pass: group assistant headings + full reply texts by turn
1474
+ // second pass: group assistant headings + full reply texts by turn, and
1475
+ // record a turn's terminal failure so the outline can say WHY it is empty
1393
1476
  for (var j = 0; j < order.length; j++) {
1394
1477
  var key = order[j];
1395
1478
  var node = nodes.get(key);
1396
- if (!node || node.kind !== "assistant-step") continue;
1479
+ if (!node || !anchorKind(node.kind)) continue;
1397
1480
  var loc = node.location;
1398
1481
  var turnId = loc && (loc.kind === "turn" || loc.kind === "step") && loc.turn ? loc.turn.turn : null;
1399
1482
  var sameGroup = current !== null && current.turn === turnId;
@@ -1404,12 +1487,26 @@ window.__ModuleLoader__.load({
1404
1487
  userKey: turnId !== null ? (turnUserKey[turnId] || "") : "",
1405
1488
  userText: turnId !== null ? (turnUserText[turnId] || "") : "",
1406
1489
  userFull: turnId !== null ? (turnUserFull[turnId] || "") : "",
1490
+ failure: null,
1407
1491
  msgs: [],
1408
1492
  headings: []
1409
1493
  };
1410
1494
  result.push(current);
1411
1495
  }
1412
1496
  seen[key] = true;
1497
+ if (node.kind === "turn-error") {
1498
+ // the host's terminal failure row: the turn ended without a reply, so
1499
+ // the outline shows the reason instead of pretending it is unloaded
1500
+ if (current.failure === null) {
1501
+ current.failure = {
1502
+ key: key,
1503
+ message: (node.data && node.data.message) || "",
1504
+ code: (node.data && node.data.code) || ""
1505
+ };
1506
+ }
1507
+ continue;
1508
+ }
1509
+ if (node.kind !== "assistant-step") continue;
1413
1510
  var info = nodeInfo(key, node);
1414
1511
  current.msgs.push({ key: key, text: info.text });
1415
1512
  for (var k = 0; k < info.headings.length; k++) {
@@ -1424,9 +1521,11 @@ window.__ModuleLoader__.load({
1424
1521
  }
1425
1522
  }
1426
1523
  pruneNodeInfo(seen);
1427
- // keep a turn when it has headings OR a time turns without headings
1428
- // still get a standalone time entry in the outline (click to jump)
1429
- var ready = result.filter(function (g) { return g.headings.length > 0 || g.time !== ""; });
1524
+ // keep a turn when it has headings, a time, a loaded prompt or a failure —
1525
+ // turns without headings still get a standalone time entry (click to jump)
1526
+ var ready = result.filter(function (g) {
1527
+ return g.headings.length > 0 || g.time !== "" || g.userKey !== "" || g.failure !== null;
1528
+ });
1430
1529
  if (outlineTurns === null) return ready;
1431
1530
  // ---- merge the whole-log turn index ---------------------------------
1432
1531
  // Loaded groups keep their order (a group whose node carries no turn
@@ -1452,6 +1551,7 @@ window.__ModuleLoader__.load({
1452
1551
  userKey: "",
1453
1552
  userText: entry.prompt,
1454
1553
  userFull: "",
1554
+ failure: null,
1455
1555
  msgs: [],
1456
1556
  headings: [],
1457
1557
  preview: entry.response
@@ -1469,12 +1569,26 @@ window.__ModuleLoader__.load({
1469
1569
  didInitScroll.current = true;
1470
1570
  listRef.current.scrollTop = listRef.current.scrollHeight;
1471
1571
  }, [groups.length]);
1472
- // one-line diagnostic so the loaded build AND the host capabilities it can
1473
- // actually reach are identifiable from the browser console
1572
+ // The "back to the newest row" button follows the list's real geometry, and a
1573
+ // bottom can be reached WITHOUT a scroll event: older turns page in, a search
1574
+ // replaces the rows, the panel is resized, the panel is expanded/collapsed.
1575
+ // Also must stay below `groups` (deps arrays read the vars eagerly).
1576
+ react.useEffect(function () {
1577
+ syncAtBottom(listRef.current);
1578
+ }, [groups.length, query, visibleCount, panelW, panelH, open]);
1579
+ // Mount diagnostic: the host capabilities this panel actually reached
1580
+ // (whole-log turn index + jump loader). SILENT by default — a normal console
1581
+ // should stay clean — and switched on with
1582
+ // localStorage.setItem("dsh-quick-toc.debug", "1")
1583
+ // plus a reload. Kept because it is the fastest way to tell "the host has no
1584
+ // turnOutline projection" apart from "the jump-loader bridge is broken".
1474
1585
  var mountLogRef = react.useRef(false);
1475
1586
  react.useEffect(function () {
1476
1587
  if (mountLogRef.current) return;
1477
1588
  mountLogRef.current = true;
1589
+ var debug = false;
1590
+ try { debug = localStorage.getItem("dsh-quick-toc.debug") === "1"; } catch (e) { debug = false; }
1591
+ if (!debug) return;
1478
1592
  console.log("[dsh-quick-toc] panel mounted · turnOutline=" +
1479
1593
  (outlineTurns ? outlineTurns.length + " turns" : "unavailable") +
1480
1594
  " · jumpLoader=" + jumpLoaderState());
@@ -1506,6 +1620,7 @@ window.__ModuleLoader__.load({
1506
1620
  var g = groups[i];
1507
1621
  if (g.ghost) continue; // no loaded rows to match
1508
1622
  if (g.userKey) m[g.userKey] = i;
1623
+ if (g.failure) m[g.failure.key] = i;
1509
1624
  for (var j = 0; j < g.headings.length; j++) {
1510
1625
  m[g.headings[j].key] = i;
1511
1626
  }
@@ -1604,6 +1719,18 @@ window.__ModuleLoader__.load({
1604
1719
  level: 0, path: "", time: g.time, snippet: snippetAround(msg.text, fq, 36, fuzzy)
1605
1720
  }, nm);
1606
1721
  }
1722
+ // a failed turn's provider message is searchable too — "timed out" must
1723
+ // find the turns it killed
1724
+ if (g.failure && g.failure.message) {
1725
+ var nf = countOccurrences(g.failure.message, needle, fuzzy);
1726
+ if (nf > 0) {
1727
+ push({
1728
+ gi: gi, key: g.failure.key, idx: undefined, title: previewText(g.failure.message, 40),
1729
+ level: 0, path: "", time: g.time, failed: true,
1730
+ snippet: snippetAround(g.failure.message, fq, 36, fuzzy)
1731
+ }, nf);
1732
+ }
1733
+ }
1607
1734
  }
1608
1735
  }
1609
1736
  return rows;
@@ -1831,21 +1958,45 @@ window.__ModuleLoader__.load({
1831
1958
  if (handleRef.current) handleRef.current.style.opacity = on ? "1" : "0.35";
1832
1959
  };
1833
1960
 
1834
- // ---- jump: smooth glide to the exact heading element. When glued to the
1835
- // bottom, lift just past DSH's 25px stick-to-bottom threshold first so
1836
- // the glide is not yanked back. ----
1961
+ // ---- jump: glide to the exact heading element ---------------------------
1962
+ // A SMOOTH scroll is only trustworthy while the paged conversation keeps its
1963
+ // geometry. A LONG jump travels through the whole window, and the host mounts
1964
+ // and drops turns along the way, so the target's offset moves mid-flight and
1965
+ // the animation lands short ("it scrolled halfway and stopped"). Long jumps
1966
+ // therefore position INSTANTLY and then re-check for a short while (the same
1967
+ // "instant, never smooth" rule the outline's own follow uses), while nearby
1968
+ // headings keep the glide. When glued to the bottom, lift just past DSH's 25px
1969
+ // stick-to-bottom threshold first so the landing is not yanked back.
1970
+ var scrollTargetOf = function (el, sp) {
1971
+ var t = el.getBoundingClientRect().top - sp.getBoundingClientRect().top + sp.scrollTop - 20;
1972
+ var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
1973
+ return Math.max(0, Math.min(t, floor));
1974
+ };
1837
1975
  var glideTo = function (el) {
1838
1976
  var sp = el.closest ? el.closest("[data-conversation-scroll]") : null;
1839
1977
  if (!sp) {
1840
1978
  el.scrollIntoView({ behavior: "smooth", block: "start" });
1841
1979
  return;
1842
1980
  }
1843
- var t = el.getBoundingClientRect().top - sp.getBoundingClientRect().top + sp.scrollTop - 20;
1981
+ var t = scrollTargetOf(el, sp);
1844
1982
  var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
1845
1983
  if (floor - sp.scrollTop <= 25 && Math.abs(t - sp.scrollTop) > 60) {
1846
1984
  sp.scrollTop = Math.max(0, floor - 26);
1847
1985
  }
1848
- sp.scrollTo({ top: t, behavior: "smooth" });
1986
+ if (Math.abs(t - sp.scrollTop) <= sp.clientHeight * 1.5) {
1987
+ sp.scrollTo({ top: t, behavior: "smooth" });
1988
+ return;
1989
+ }
1990
+ // long jump: land now, then keep the landing honest for ~0.6s while the host
1991
+ // finishes measuring the window it just re-paged
1992
+ sp.scrollTop = t;
1993
+ var ticks = 0;
1994
+ var settle = setInterval(function () {
1995
+ ticks++;
1996
+ var next = scrollTargetOf(el, sp);
1997
+ if (Math.abs(next - sp.scrollTop) > 2) sp.scrollTop = next;
1998
+ if (ticks >= 5) clearInterval(settle);
1999
+ }, 120);
1849
2000
  };
1850
2001
 
1851
2002
  var jump = function (key, idx) {
@@ -2192,30 +2343,16 @@ window.__ModuleLoader__.load({
2192
2343
  gap: 6
2193
2344
  },
2194
2345
  children: [
2195
- // three-bar outline mark (long / medium / short) no title text
2196
- react_jsx_runtime.jsx("div", {
2197
- style: { display: "flex", alignItems: "center", color: C.muted, flex: "none" },
2198
- title: T("panel.title"),
2199
- children: react_jsx_runtime.jsx("svg", {
2200
- width: 18,
2201
- height: 14,
2202
- viewBox: "0 0 18 14",
2203
- style: { display: "block" },
2204
- children: [
2205
- react_jsx_runtime.jsx("line", { x1: 0, y1: 2, x2: 18, y2: 2, stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" }),
2206
- react_jsx_runtime.jsx("line", { x1: 0, y1: 7, x2: 12, y2: 7, stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" }),
2207
- react_jsx_runtime.jsx("line", { x1: 0, y1: 12, x2: 6, y2: 12, stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" })
2208
- ]
2209
- })
2210
- }),
2346
+ // LEFT pair: the heading-level filter and the dock toggle take the
2347
+ // left end — so the header reads two controls per side (search +
2348
+ // collapse sit at the other end).
2211
2349
  react_jsx_runtime.jsx("div", {
2212
2350
  style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
2213
2351
  children: [
2214
- // heading-level filter: a round icon button (directly left
2215
- // of the search button) that pops down the H1–H6 picker.
2216
- // Icon: three lines of decreasing width = outline levels.
2217
- // Hover behaviour matches the magnifier: background + icon
2218
- // tint change; the resting look never shifts when open.
2352
+ // heading-level filter: a round icon button that pops down the
2353
+ // H1–H6 picker. Icon: three lines of decreasing width = outline
2354
+ // levels. Hover behaviour matches the magnifier: background +
2355
+ // icon tint change; the resting look never shifts when open.
2219
2356
  react_jsx_runtime.jsx("button", {
2220
2357
  className: "dqt-levels-btn",
2221
2358
  onClick: function () { levelsOpen ? closeLevels() : setLevelsOpen(true); },
@@ -2262,6 +2399,16 @@ window.__ModuleLoader__.load({
2262
2399
  ]
2263
2400
  })
2264
2401
  }),
2402
+ // dock toggle: the triangle tips toward the side it will move TO
2403
+ iconBtn(toggleDock, dockRight ? T("handle.dockLeft") : T("handle.dockRight"), dockRight ? "◀" : "▶", 12, dockRight ? { x: -1, y: -1 } : { x: 1, y: -1 })
2404
+ ]
2405
+ }),
2406
+ // spacer: nothing here, it just pins the two pairs to the two ends
2407
+ react_jsx_runtime.jsx("div", { style: { flex: "1 1 auto" } }),
2408
+ // RIGHT pair: search + collapse
2409
+ react_jsx_runtime.jsx("div", {
2410
+ style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
2411
+ children: [
2265
2412
  // magnifier button (SVG, matches the other buttons' style)
2266
2413
  react_jsx_runtime.jsx("button", {
2267
2414
  onClick: function () { searchOpen ? closeSearch() : openSearch(); },
@@ -2304,8 +2451,6 @@ window.__ModuleLoader__.load({
2304
2451
  ]
2305
2452
  })
2306
2453
  }),
2307
- // triangle tips toward the side it will move TO: shift slightly up + toward the tip
2308
- iconBtn(toggleDock, dockRight ? T("handle.dockLeft") : T("handle.dockRight"), dockRight ? "◀" : "▶", 12, dockRight ? { x: -1, y: -1 } : { x: 1, y: -1 }),
2309
2454
  // close: thick SVG cross, nudged slightly down
2310
2455
  react_jsx_runtime.jsx("button", {
2311
2456
  onClick: function () { setOpen(false); },
@@ -2575,6 +2720,71 @@ window.__ModuleLoader__.load({
2575
2720
  : renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup, renderExtra)
2576
2721
  })
2577
2722
  }),
2723
+ // "back to the newest row": floats over the list's lower-right corner and
2724
+ // fades in the moment the newest turns fall below the fold. Kept MOUNTED
2725
+ // and toggled by opacity/pointer-events — an unmounted button has nothing
2726
+ // to animate, and (like the level picker) its animation must not depend on
2727
+ // inline keyframes that every re-render rewrites.
2728
+ react_jsx_runtime.jsx("button", {
2729
+ onClick: scrollToBottom,
2730
+ className: "dqt-bottom-btn",
2731
+ "data-at-bottom": atBottom ? "on" : "off",
2732
+ title: T("outline.bottom"),
2733
+ tabIndex: atBottom ? -1 : 0,
2734
+ "aria-hidden": atBottom ? "true" : undefined,
2735
+ onMouseEnter: function (e) {
2736
+ e.currentTarget.style.color = C.accent;
2737
+ e.currentTarget.style.borderColor = C.groupEdgeSoft;
2738
+ },
2739
+ onMouseLeave: function (e) {
2740
+ e.currentTarget.style.color = C.text;
2741
+ e.currentTarget.style.borderColor = C.panelBorder;
2742
+ },
2743
+ style: {
2744
+ position: "absolute",
2745
+ // clear of the resize handle in the very corner (16x16)
2746
+ right: 18,
2747
+ bottom: 12,
2748
+ width: 26,
2749
+ height: 26,
2750
+ display: "flex",
2751
+ alignItems: "center",
2752
+ justifyContent: "center",
2753
+ padding: 0,
2754
+ // OPAQUE surface + outline + lift, the same vocabulary as the hover card:
2755
+ // a translucent chip disappeared into the rows behind it (and the panel's
2756
+ // own idle transparency multiplied it away). The surface is the host's
2757
+ // RAISED menu colour, not the panel background — a fill identical to the
2758
+ // panel it sits on reads as "no fill" and the button looks see-through.
2759
+ background: "var(--dsw-specific-menu, rgba(44, 49, 60, 0.99))",
2760
+ border: "1px solid " + C.panelBorder,
2761
+ borderRadius: "50%",
2762
+ cornerShape: "round",
2763
+ color: C.text,
2764
+ cursor: "pointer",
2765
+ zIndex: 6,
2766
+ boxShadow: "0 3px 10px rgba(0, 0, 0, 0.30)",
2767
+ opacity: atBottom ? 0 : 1,
2768
+ transform: atBottom ? "translateY(6px)" : "translateY(0)",
2769
+ pointerEvents: atBottom ? "none" : "auto",
2770
+ transition: "opacity 0.22s ease, transform 0.22s ease, background 0.18s ease, color 0.18s ease, border-color 0.18s ease"
2771
+ },
2772
+ children: react_jsx_runtime.jsx("svg", {
2773
+ width: 14,
2774
+ height: 14,
2775
+ viewBox: "0 0 14 14",
2776
+ fill: "none",
2777
+ stroke: "currentColor",
2778
+ strokeWidth: 1.8,
2779
+ strokeLinecap: "round",
2780
+ strokeLinejoin: "round",
2781
+ style: { display: "block" },
2782
+ children: [
2783
+ react_jsx_runtime.jsx("polyline", { points: "3.5,4.5 7,8 10.5,4.5" }),
2784
+ react_jsx_runtime.jsx("line", { x1: 3.5, y1: 11, x2: 10.5, y2: 11 })
2785
+ ]
2786
+ })
2787
+ }),
2578
2788
  toastEl,
2579
2789
  // resize handles (right edge: width, bottom edge: height)
2580
2790
  react_jsx_runtime.jsx("div", {
@@ -2620,8 +2830,9 @@ window.__ModuleLoader__.load({
2620
2830
  onPointerDown: onResizeCornerDown,
2621
2831
  title: T("resize.wh")
2622
2832
  }),
2623
- // heading-level picker: pops down from the round header button,
2624
- // right-aligned, with an outer shadow so it reads as a floating layer.
2833
+ // heading-level picker: pops down from the round header button and its
2834
+ // LEFT edge lines up with that button (the button now sits at the left end
2835
+ // of the header, so 10px is exactly the header's own left padding).
2625
2836
  // Open/close animate from the button's center: the animation lives in
2626
2837
  // the injected stylesheet (classes, not inline animation) so React
2627
2838
  // re-renders never restart or cancel it — an inline `animation` set on
@@ -2631,7 +2842,7 @@ window.__ModuleLoader__.load({
2631
2842
  style: {
2632
2843
  position: "absolute",
2633
2844
  top: 40,
2634
- right: 8,
2845
+ left: 10,
2635
2846
  zIndex: 30,
2636
2847
  display: "flex",
2637
2848
  alignItems: "center",
@@ -2643,8 +2854,9 @@ window.__ModuleLoader__.load({
2643
2854
  borderRadius: 10,
2644
2855
  cornerShape: "round",
2645
2856
  boxShadow: "0 6px 20px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.18)",
2646
- maxWidth: "calc(100% - 16px)",
2647
- transformOrigin: "calc(50% + 12px) top"
2857
+ maxWidth: "calc(100% - 20px)",
2858
+ // the button's center sits 12px inside the popup's left edge
2859
+ transformOrigin: "12px top"
2648
2860
  },
2649
2861
  children: [1, 2, 3, 4, 5, 6].map(function (lv) {
2650
2862
  var active = !!levelSet[lv];
@@ -2893,6 +3105,7 @@ window.__ModuleLoader__.load({
2893
3105
  var fuzzy = !!(extra && extra.fuzzy);
2894
3106
  var meta = [];
2895
3107
  if (r.ghost) meta.push(T("turn.unloaded"));
3108
+ if (r.failed) meta.push(T("turn.failed"));
2896
3109
  if (r.time) meta.push(r.time);
2897
3110
  if (r.path) meta.push(r.path);
2898
3111
  if (r.count > 1) meta.push("×" + r.count);
@@ -2984,8 +3197,10 @@ window.__ModuleLoader__.load({
2984
3197
  // Implemented as a function (not an inline closure in a loop) so each
2985
3198
  // header captures its own (g, gi) — the var-in-loop closure bug would
2986
3199
  // otherwise make every header jump to the last group.
2987
- function renderGroupHeader(g, gi, jump, C, isActive, headingless) {
2988
- var replyKey = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : "";
3200
+ function renderGroupHeader(g, gi, jump, C, isActive) {
3201
+ // a turn that ended in a failure has no reply to land on: its terminal row IS
3202
+ // the interesting position, so the header lands there (and says so in the tip)
3203
+ var replyKey = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : (g.failure ? g.failure.key : "");
2989
3204
  var jumpToTurn = function (e) {
2990
3205
  e.stopPropagation();
2991
3206
  // prefer the model's reply; fall back to the user message, then to the
@@ -2996,8 +3211,12 @@ window.__ModuleLoader__.load({
2996
3211
  };
2997
3212
  return react_jsx_runtime.jsx("div", {
2998
3213
  style: {
2999
- padding: headingless ? "3px 4px" : "1px 4px 2px",
3000
- height: headingless ? undefined : 18,
3214
+ // ONE row geometry for every group header, whether or not the turn has
3215
+ // headings: same box, same 18px rhythm, same left inset, so the time
3216
+ // column of a heading-less turn lines up with all the others. Only the
3217
+ // type emphasis differs (weight + colour, see the label below).
3218
+ padding: "1px 4px 2px",
3219
+ height: 18,
3001
3220
  display: "flex",
3002
3221
  alignItems: "center",
3003
3222
  minWidth: 0,
@@ -3027,19 +3246,21 @@ window.__ModuleLoader__.load({
3027
3246
  }, "g-h-tint-" + gi),
3028
3247
  react_jsx_runtime.jsx("span", {
3029
3248
  onClick: jumpToTurn,
3030
- title: replyKey ? T("turn.jumpReply") : T("turn.jumpTurn"),
3249
+ title: (g.msgs && g.msgs.length > 0) ? T("turn.jumpReply") : (g.failure ? T("turn.jumpFailed") : T("turn.jumpTurn")),
3031
3250
  onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
3032
3251
  onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
3033
3252
  style: {
3034
- // A turn with no headings has ONLY this row, so it carries the whole
3035
- // entry: readable size, weight and colour. No extra pill background
3036
- // the "current group" tint belongs to the group, so headed and
3037
- // heading-less turns look the same when they are being read.
3038
- fontSize: headingless ? 12 : 11,
3039
- fontWeight: headingless ? 600 : 400,
3040
- color: headingless ? C.text : C.muted,
3253
+ // ONE style for every group header, heading-less turns included: a turn
3254
+ // whose reply has no markdown headings is not a different kind of entry,
3255
+ // it is simply a group with no rows under its header, and it must line up
3256
+ // with the rest of the list (same size, weight, colour, opacity). The only
3257
+ // thing that marks a group is the shared "current group" tint, which lives
3258
+ // on the group box — never on the label.
3259
+ fontSize: 11,
3260
+ fontWeight: 400,
3261
+ color: C.muted,
3041
3262
  cursor: "pointer",
3042
- padding: headingless ? "2px 6px" : "1px 5px",
3263
+ padding: "1px 5px",
3043
3264
  borderRadius: 4,
3044
3265
  background: "transparent",
3045
3266
  transition: "background 0.15s ease",
@@ -3054,8 +3275,8 @@ window.__ModuleLoader__.load({
3054
3275
  react_jsx_runtime.jsx("span", { style: { fontWeight: 600, flex: "none" }, children: g.time || " " }),
3055
3276
  g.userText ? react_jsx_runtime.jsx("span", {
3056
3277
  style: {
3057
- fontWeight: headingless ? 500 : 400,
3058
- opacity: headingless ? 0.95 : 0.75,
3278
+ fontWeight: 400,
3279
+ opacity: 0.75,
3059
3280
  overflow: "hidden",
3060
3281
  textOverflow: "ellipsis",
3061
3282
  whiteSpace: "nowrap",
@@ -3123,6 +3344,55 @@ window.__ModuleLoader__.load({
3123
3344
  }, "g-ghost-" + gi);
3124
3345
  }
3125
3346
 
3347
+ // A turn that ended in a terminal failure (the host's `turn-error` row): the
3348
+ // outline lists it as a normal turn and adds this row, so a turn whose request
3349
+ // never came back reads as "请求失败 · <provider message>" instead of "未加载"
3350
+ // and never silently disappears from the outline.
3351
+ function renderFailureRow(g, gi, jump, C) {
3352
+ var f = g.failure;
3353
+ var message = f.message || f.code || "";
3354
+ var open = function (e) {
3355
+ e.stopPropagation();
3356
+ if (findRow(f.key)) jump(f.key);
3357
+ else if (g.userKey && findRow(g.userKey)) jump(g.userKey);
3358
+ };
3359
+ return react_jsx_runtime.jsx("div", {
3360
+ onClick: open,
3361
+ title: message || T("turn.failed"),
3362
+ "data-turn-failure": g.turn === null || g.turn === undefined ? String(gi) : String(g.turn),
3363
+ style: {
3364
+ display: "flex",
3365
+ alignItems: "center",
3366
+ gap: 5,
3367
+ minWidth: 0,
3368
+ padding: "1px 6px",
3369
+ margin: "1px 0",
3370
+ borderRadius: 6,
3371
+ cursor: "pointer",
3372
+ transition: "background 0.15s ease"
3373
+ },
3374
+ onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
3375
+ onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
3376
+ children: [
3377
+ react_jsx_runtime.jsx("span", {
3378
+ style: { fontSize: 10, fontWeight: 600, flex: "none", color: C.error, border: "1px solid " + C.error, borderRadius: 4, padding: "0 4px" },
3379
+ children: T("turn.failed")
3380
+ }),
3381
+ message ? react_jsx_runtime.jsx("span", {
3382
+ style: {
3383
+ fontSize: 11,
3384
+ color: C.muted,
3385
+ overflow: "hidden",
3386
+ textOverflow: "ellipsis",
3387
+ whiteSpace: "nowrap",
3388
+ minWidth: 0
3389
+ },
3390
+ children: message
3391
+ }) : null
3392
+ ]
3393
+ }, "g-fail-" + gi);
3394
+ }
3395
+
3126
3396
  // render each conversation turn as its own block: solid divider + time header
3127
3397
  // offset = global group index of the first rendered group (stable React keys)
3128
3398
  // activeIdx = array of groups being read (full opacity); others are dimmed
@@ -3148,14 +3418,20 @@ window.__ModuleLoader__.load({
3148
3418
  if (g.ghost) {
3149
3419
  items.push(renderGhostGroup(g, gi, C, extra));
3150
3420
  } else {
3151
- items.push(renderGroupHeader(g, gi, jump, C, isActive, !rows || rows.length === 0));
3421
+ // every group gets the SAME header, whether or not this turn produced any
3422
+ // markdown headings — a heading-less turn is just a group with no rows, not
3423
+ // a second kind of entry (`rows` only decides what gets listed below it)
3424
+ items.push(renderGroupHeader(g, gi, jump, C, isActive));
3152
3425
  items.push(renderNodes(rows, 0, jump, C, "g" + gi, extra));
3426
+ // the failure is the turn's LAST event, so its row sits under the rows
3427
+ if (g.failure) items.push(renderFailureRow(g, gi, jump, C));
3153
3428
  }
3154
- // Inactive groups used to fade to 0.6, which multiplied by the panel's own
3155
- // idle opacityturned the whole list into grey. 0.85 keeps every row
3156
- // readable, and "the group being read" is marked by a CLOSED tinted box
3157
- // (fill + outline + accent bar) instead, which stays visible on top of any
3158
- // panel transparency and no longer looks cut off on the right.
3429
+ // The group being read stays at full opacity and every other group is dimmed
3430
+ // to 0.85enough to mark the reading position without turning the list grey
3431
+ // (0.6 did, and multiplied by the panel's own idle transparency it made the
3432
+ // outline barely readable). The current group ALSO gets the closed tinted box
3433
+ // below (fill + outline + accent bar), which stays visible on top of any
3434
+ // panel transparency and never looks cut off on the right.
3159
3435
  var dim = !isActive && !g.ghost;
3160
3436
  out.push(react_jsx_runtime.jsx("div", {
3161
3437
  "data-group-idx": gi,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quick-toc",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Conversation TOC for DeepSeek Harness (DSH): turn-grouped Markdown outline covering the whole session (unloaded turns included), title/full-text search with an optional fuzzy switch, hover previews, in-chat match highlighting, heading-level filter and reading-position auto-follow",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",