@max-null/dsh-chat-rail 0.3.1 → 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/README.md CHANGED
@@ -1,14 +1,18 @@
1
1
  # @max-null/dsh-chat-rail
2
2
 
3
+ 本插件属于 **`@max-null/*` 插件系列**——这一系列共同构成 **[SSID(思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** 桌面体验。SSID 是整合它们的盒:`dsh-capture` · `dsh-chat-rail` · `dsh-chinese-thinking` · `dsh-draft-polish` · `dsh-guardian` · `dsh-habit` · `dsh-header-unify` · `dsh-memory` · `dsh-node-appearance` · `dsh-plugin-center` · `dsh-skill-mcp-center` · `dsh-ssid-panels` · `dsh-ssid-zh-ui`。
4
+
5
+ This plugin belongs to the **`@max-null/*` family** — a set of plugins that together form the **[SSID (思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** desktop experience.
6
+
3
7
  面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的**画卷式消息导航栏**:会话面板右侧的一条竖排导航,每条用户消息一个指示点,scroll-spy 跟随阅读位置,hover 展开画卷查看消息列表,点击跳转任意历史消息。
4
8
 
5
9
  > 不同于市面上"面板弹出式"的导航插件,dsh-chat-rail 采用**画卷式展开**:收起时是一条干净的竖条,hover 时从右往左平滑展开,像打开画卷一样。
6
10
 
7
11
  ## 截图
8
12
 
9
- | 展开态(画卷 + 消息预览 tip) | 收起态(与 better-sidebar 共存) |
13
+ | 画卷展开(收藏过滤 + 收藏导航条) | 消息操作按钮(收藏 ★ / 填充 ➕ / 复制) |
10
14
  |---|---|
11
- | ![rail-1](docs/shots/rail-1.png) | ![rail-2](docs/shots/rail-2.png) |
15
+ | ![rail-fav-1](docs/shots/rail-fav-1.png) | ![rail-fav-2](docs/shots/rail-fav-2.png) |
12
16
 
13
17
  ## 为什么比市面上的导航栏优秀
14
18
 
@@ -23,6 +27,7 @@
23
27
  | **消息预览** | 浏览器原生 title(与悬停双重提示) | ✅ **自绘 tip 面板**:完整内容、动画结束后精准定位、无原生 title 干扰 |
24
28
  | **消息信息** | 只有预览文本 | ✅ **编号 + 相对时间 + 文本三列对齐**:编号固定宽、时间靠右,文本区域最大化且整齐 |
25
29
  | **长会话跳转** | 干等卡死 | ✅ **加载反馈**:跳转远距离消息时显示 spinner + "加载中",收起态只留图标 |
30
+ | **消息收藏** | ❌ 大多数只能看不能存 | ✅ **收藏 + 快速填充**:消息行星标收藏(黄色导航条联动)、只显示收藏过滤、加号一键全文+附件回填输入框 |
26
31
  | **主题适配** | 部分 | ✅ 全部使用 DSH 主题变量(`--dsw-alias-*`),浅深色无缝适配 |
27
32
  | **无侧边栏兜底** | ❌ 依赖特定插件 | ✅ 纯 DSH 环境开箱即用,固定右缘 + 垂直居中 |
28
33
 
@@ -53,6 +58,14 @@ dsh plugin --profile <name> add @max-null/dsh-chat-rail
53
58
  - **滚动会话**:高亮点跟随,导航条自动滚动保持居中
54
59
  - **含图消息**:展开态显示柔和图片角标(收起态不显示,保持竖条干净)
55
60
 
61
+ ### 收藏与快速填充(v0.4.0)
62
+
63
+ - **消息操作按钮**:每条用户消息的复制按钮左侧新增两个按钮
64
+ - **星 ★**:收藏/取消收藏该消息(收藏后变成黄色实心星);收藏状态按会话持久化(localStorage),刷新与重启后保留
65
+ - **加号 +**:一键把该消息**全文 + 附件**填入输入框(草稿设为消息全文,历史图片经官方附件通道回填入输入框)
66
+ - **导航条联动**:收藏的消息指示条显示为**黄色**;展开态在标题前显示小星标
67
+ - **只显示收藏**:导航条顶部新增星形过滤开关,点击点亮(黄色实心)后导航栏只显示已收藏的消息;再点恢复全部
68
+
56
69
  ## 架构
57
70
 
58
71
  - **host 半端**(`lib/index.mjs`):注册 `chatRail` 会话投影——从 session log 折叠用户消息锚点 `{ seq, time, text, id }`(排除插件/工具注入的上下文行)
@@ -61,10 +74,6 @@ dsh plugin --profile <name> add @max-null/dsh-chat-rail
61
74
 
62
75
  ## SSID 系列
63
76
 
64
- 本插件属于 **`@max-null/*` 插件系列**——这一系列共同构成 **[SSID(思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** 桌面体验。SSID 是整合它们的盒:`dsh-chat-rail` · `dsh-chinese-thinking` · `dsh-draft-polish` · `dsh-guardian` · `dsh-habit` · `dsh-header-unify` · `dsh-memory` · `dsh-node-appearance` · `dsh-plugin-center` · `dsh-skill-mcp-center` · `dsh-ssid-panels` · `dsh-ssid-zh-ui`。
65
-
66
- This plugin belongs to the **`@max-null/*` family** — a set of plugins that together form the **[SSID (思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** desktop experience.
67
-
68
77
  ## 开发
69
78
 
70
79
  ```bash
Binary file
Binary file
package/lib/client.js CHANGED
@@ -31,7 +31,11 @@ window.__ModuleLoader__.load({
31
31
  * background loadOlder loop (stopped as soon as the projection delivers).
32
32
  * Mounted in conversation.input.dock, portal-rendered to body.
33
33
  */
34
- const inject = ["slots", "sessions"];
34
+ const inject = [
35
+ "slots",
36
+ "sessions",
37
+ "conversation"
38
+ ];
35
39
  const STRINGS = {
36
40
  zh: {
37
41
  railLabel: "消息导航",
@@ -43,7 +47,12 @@ window.__ModuleLoader__.load({
43
47
  timeJustNow: "刚刚",
44
48
  timeMinutes: "{n}分钟前",
45
49
  timeHours: "{n}小时前",
46
- timeDays: "{n}天前"
50
+ timeDays: "{n}天前",
51
+ fav: "收藏消息",
52
+ unfav: "取消收藏",
53
+ fill: "填充到输入框",
54
+ filled: "已填入输入框",
55
+ favOnly: "只显示收藏"
47
56
  },
48
57
  en: {
49
58
  railLabel: "Message rail",
@@ -55,7 +64,12 @@ window.__ModuleLoader__.load({
55
64
  timeJustNow: "just now",
56
65
  timeMinutes: "{n}m ago",
57
66
  timeHours: "{n}h ago",
58
- timeDays: "{n}d ago"
67
+ timeDays: "{n}d ago",
68
+ fav: "Bookmark message",
69
+ unfav: "Remove bookmark",
70
+ fill: "Fill into input",
71
+ filled: "Filled into input",
72
+ favOnly: "Bookmarks only"
59
73
  }
60
74
  };
61
75
  /** Compact relative time for a message timestamp (zh/en via template). */
@@ -117,6 +131,29 @@ window.__ModuleLoader__.load({
117
131
  "body[data-ds-dark-theme] .crl_tipImg,[data-theme='dark'] .crl_tipImg,.dark .crl_tipImg{border-color:var(--dsw-alias-border-l2,var(--border-default,rgba(255,255,255,.14)))}",
118
132
  ".crl_tipBadge{display:inline-block;margin-bottom:8px;padding:0 7px;font-size:10px;line-height:16px;border-radius:9px;background:var(--dsw-alias-surface-sunken,var(--bg-muted,rgba(0,0,0,.06)));color:var(--dsw-alias-label-secondary,var(--text-muted,rgba(0,0,0,.45)))}",
119
133
  "@keyframes crl-pulse{0%,100%{opacity:.45}50%{opacity:.85}}",
134
+ ".crl_msgAct{position:relative;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin:0;padding:6px;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,rgba(0,0,0,.42));cursor:pointer;transition:background .15s ease,color .15s ease}",
135
+ ".crl_msgAct:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06));color:var(--dsw-alias-label-secondary,rgba(0,0,0,.72))}",
136
+ ".crl_msgAct:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:2px}",
137
+ ".crl_msgAct[data-tip]:hover::after{content:attr(data-tip);position:absolute;left:50%;bottom:calc(100% + 8px);transform:translateX(-50%);padding:5px 10px;border-radius:8px;background:rgba(24,28,36,.94);color:rgba(255,255,255,.95);font-size:12px;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;animation:crl-tip-in .12s ease .55s forwards;z-index:3000}",
138
+ ".crl_msgAct[data-tip]:hover::before{content:\"\";position:absolute;left:50%;bottom:calc(100% + 3px);transform:translateX(-50%);border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid rgba(24,28,36,.94);pointer-events:none;opacity:0;animation:crl-tip-in .12s ease .55s forwards;z-index:3000}",
139
+ "@keyframes crl-tip-in{to{opacity:1}}",
140
+ ".crl_msgAct.crl_fav.crl_on{color:#ffd166}",
141
+ ".crl_msgAct.crl_fav.crl_on svg{fill:currentColor}",
142
+ ".crl_msgAct.crl_fav.crl_on:hover{color:#ffd166}",
143
+ ".crl_favStar{display:none;flex-shrink:0;align-items:center;justify-content:center;width:14px;height:14px;margin-right:6px;color:#ffd166}",
144
+ ".crl_show .crl_favStar{display:inline-flex;animation:crl-fade .18s ease}",
145
+ ".crl_item.crl_favItem .crl_line{background-color:#ffd166}",
146
+ "body[data-ds-dark-theme] .crl_item.crl_favItem .crl_line,[data-theme='dark'] .crl_item.crl_favItem .crl_line,.dark .crl_item.crl_favItem .crl_line{background-color:#ffd166}",
147
+ ".crl_item.crl_favItem.crl_active .crl_line{background-color:#ffd166;box-shadow:0 0 6px #ffd166}",
148
+ ".crl_favToggle{position:sticky;top:0;z-index:3;flex-shrink:0;display:flex;align-items:center;justify-content:center;gap:6px;width:26px;height:26px;margin:0 0 6px;padding:0;border:none;border-radius:13px;background:inherit;color:rgba(0,0,0,.4);cursor:pointer;font-size:11px;line-height:1;white-space:nowrap;transition:background .15s ease,color .15s ease}",
149
+ ".crl_favToggle:hover{background:rgba(0,0,0,.07);color:rgba(0,0,0,.75)}",
150
+ ".crl_favToggle.crl_on{color:#ffd166;background:rgba(255,209,102,.14)}",
151
+ ".crl_show .crl_favToggle{width:auto;height:26px;margin:0 6px 8px;padding:0 11px;border-radius:13px;align-self:flex-start;text-align:left}",
152
+ ".crl_favToggleLabel{display:none}",
153
+ ".crl_show .crl_favToggleLabel{display:inline-block;min-width:0;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}",
154
+ "body[data-ds-dark-theme] .crl_favToggle,[data-theme='dark'] .crl_favToggle,.dark .crl_favToggle{color:rgba(255,255,255,.4)}",
155
+ "body[data-ds-dark-theme] .crl_favToggle:hover,[data-theme='dark'] .crl_favToggle:hover,.dark .crl_favToggle:hover{background:rgba(255,255,255,.1);color:rgba(255,255,255,.85)}",
156
+ "body[data-ds-dark-theme] .crl_favToggle.crl_on,[data-theme='dark'] .crl_favToggle.crl_on,.dark .crl_favToggle.crl_on{color:#ffd166;background:rgba(255,209,102,.18)}",
120
157
  "@media (prefers-reduced-motion:reduce){.crl_nav,.crl_title,.crl_num,.crl_time,.crl_line{transition:none}.crl_tipImgPh{animation:none}}"
121
158
  ].join("");
122
159
  const STYLE_ID = "@max-null/dsh-chat-rail/styles.module.css";
@@ -146,12 +183,227 @@ window.__ModuleLoader__.load({
146
183
  tipImg: "crl_tipImg",
147
184
  tipImgCount: "crl_tipImgCount",
148
185
  tipImgPh: "crl_tipImgPh",
149
- tipBadge: "crl_tipBadge"
186
+ tipBadge: "crl_tipBadge",
187
+ favStar: "crl_favStar",
188
+ favItem: "crl_favItem",
189
+ favToggle: "crl_favToggle",
190
+ favToggleLabel: "crl_favToggleLabel"
150
191
  };
151
192
  const NOOP_STORE = {
152
193
  getSnapshot: () => void 0,
153
194
  subscribe: () => () => {}
154
195
  };
196
+ const FAVORITES_KEY = "@max-null/dsh-chat-rail:favorites";
197
+ const FAVORITES_API = "/chat-rail/api/favorites";
198
+ /** 模块级快照缓存(host 加载后充当源 truth)+ 订阅者集合。 */
199
+ let favoritesCache = null;
200
+ const favoritesListeners = /* @__PURE__ */ new Set();
201
+ /** 从 localStorage 读旧数据(迁移用/离线兜底)。 */
202
+ function readFavoritesLocal() {
203
+ if (typeof localStorage === "undefined") return {};
204
+ try {
205
+ const raw = localStorage.getItem(FAVORITES_KEY);
206
+ if (raw === null) return {};
207
+ const value = JSON.parse(raw);
208
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
209
+ } catch {
210
+ return {};
211
+ }
212
+ }
213
+ async function fetchFavoritesMap() {
214
+ try {
215
+ const res = await fetch(FAVORITES_API, { headers: { Accept: "application/json" } });
216
+ if (!res.ok) return {};
217
+ const value = (await res.json())?.value;
218
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
219
+ } catch {
220
+ return {};
221
+ }
222
+ }
223
+ /** 模块加载时同步 host;旧 localStorage 数据迁移一次(host 为空且本地有值)。 */
224
+ (async () => {
225
+ const host = await fetchFavoritesMap();
226
+ if (typeof window !== "undefined" && typeof localStorage !== "undefined") {
227
+ const legacy = readFavoritesLocal();
228
+ if (Object.keys(host).length === 0 && Object.keys(legacy).length > 0) await fetch(FAVORITES_API, {
229
+ method: "PUT",
230
+ headers: { "Content-Type": "application/json" },
231
+ body: JSON.stringify({ favorites: legacy })
232
+ }).catch(() => {});
233
+ try {
234
+ localStorage.removeItem(FAVORITES_KEY);
235
+ } catch {}
236
+ favoritesCache = Object.keys(host).length === 0 ? legacy : host;
237
+ for (const listener of favoritesListeners) listener();
238
+ } else favoritesCache = host;
239
+ })();
240
+ /** Read the favorites map(内存镜像;未加载完成时回退 localStorage)。 */
241
+ function readFavorites() {
242
+ if (favoritesCache !== null) return favoritesCache;
243
+ return readFavoritesLocal();
244
+ }
245
+ /** Write the favorites map;更新内存并 fire-and-forget 持久化到 host(host-only)。 */
246
+ function writeFavorites(map) {
247
+ favoritesCache = map;
248
+ fetch(FAVORITES_API, {
249
+ method: "PUT",
250
+ headers: { "Content-Type": "application/json" },
251
+ body: JSON.stringify({ favorites: map })
252
+ }).catch(() => {});
253
+ }
254
+ /** Normalized id list for one session (drops non-string entries). */
255
+ function favoriteIdsOf(map, sessionId) {
256
+ const list = map[sessionId];
257
+ return Array.isArray(list) ? list.filter((id) => typeof id === "string") : [];
258
+ }
259
+ /** Toggle one id in a list (stable new array; no in-place mutation). */
260
+ function toggleFavoriteId(list, id) {
261
+ return list.includes(id) ? list.filter((candidate) => candidate !== id) : [...list, id];
262
+ }
263
+ /** Whether one message is favorited in one session. */
264
+ function isFavorite(map, sessionId, messageId) {
265
+ return favoriteIdsOf(map, sessionId).includes(messageId);
266
+ }
267
+ /** Stable snapshot for useSyncExternalStore (loaded once, cached until toggle). */
268
+ function favoritesSnapshot() {
269
+ if (favoritesCache === null) favoritesCache = readFavorites();
270
+ return favoritesCache;
271
+ }
272
+ /** Subscribe to favorites changes; returns the unsubscribe. */
273
+ function subscribeFavorites(listener) {
274
+ favoritesListeners.add(listener);
275
+ return () => {
276
+ favoritesListeners.delete(listener);
277
+ };
278
+ }
279
+ /** Toggle one message's favorite state: snapshot → new map → persist → notify. */
280
+ function toggleFavorite(sessionId, messageId) {
281
+ const next = { ...favoritesSnapshot() };
282
+ next[sessionId] = toggleFavoriteId(favoriteIdsOf(favoritesSnapshot(), sessionId), messageId);
283
+ favoritesCache = next;
284
+ writeFavorites(next);
285
+ for (const listener of [...favoritesListeners]) listener();
286
+ }
287
+ /** Remember the messageId read off a DOM anchor key (`13:input-message<id>`). */
288
+ function messageIdOfAnchorKey(key) {
289
+ return key.startsWith("13:input-message") ? key.slice(16) : key;
290
+ }
291
+ /** The favorite key of one rail message: the durable id when present (host
292
+ * projection), otherwise the id reconstructed from the node anchor (the
293
+ * loaded-node fallback path). */
294
+ function favoriteIdOfMessage(m) {
295
+ if (typeof m.id === "string" && m.id !== "") return m.id;
296
+ if (typeof m.key === "string") return messageIdOfAnchorKey(m.key);
297
+ return "";
298
+ }
299
+ const actionCtx = {
300
+ sessionId: void 0,
301
+ onToggleFavorite: () => {},
302
+ onFill: () => {}
303
+ };
304
+ /** aria-label values of the copy button across shipped locales (button sits
305
+ * in the user row's IconActions; its parent is the row's action host). */
306
+ const COPY_ARIA_LABELS = /* @__PURE__ */ new Set([
307
+ "复制",
308
+ "Copy",
309
+ "已复制",
310
+ "Copied"
311
+ ]);
312
+ /** The copy button inside one user message row (null until rendered). */
313
+ function copyButtonOf(row) {
314
+ for (const button of row.querySelectorAll("button")) {
315
+ const label = button.getAttribute("aria-label");
316
+ if (label !== null && COPY_ARIA_LABELS.has(label)) return button;
317
+ }
318
+ return null;
319
+ }
320
+ /** Star SVG path (milestone-compatible glyph, 24-unit viewBox). */
321
+ const STAR_PATH = "m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z";
322
+ /** Build the favorite (star) button for one user message row. */
323
+ function favoriteButtonOf(messageId, lang) {
324
+ const button = document.createElement("button");
325
+ button.type = "button";
326
+ button.className = "crl_msgAct crl_fav";
327
+ button.dataset.crlStar = messageId;
328
+ const initialLabel = isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? "", messageId) ? lang.unfav : lang.fav;
329
+ button.setAttribute("aria-pressed", String(isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? "", messageId)));
330
+ button.dataset.starred = isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? "", messageId) ? "true" : void 0;
331
+ button.classList.toggle("crl_on", isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? "", messageId));
332
+ button.setAttribute("data-tip", initialLabel);
333
+ button.setAttribute("aria-label", initialLabel);
334
+ button.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="${STAR_PATH}"/></svg>`;
335
+ button.addEventListener("click", (event) => {
336
+ event.preventDefault();
337
+ event.stopPropagation();
338
+ const id = button.dataset.crlStar;
339
+ if (id !== void 0) {
340
+ actionCtx.onToggleFavorite(id);
341
+ refreshFavoriteButton(button, lang);
342
+ }
343
+ });
344
+ return button;
345
+ }
346
+ /** Build the fill-to-input (plus) button for one user message row. */
347
+ function fillButtonOf(messageId, lang) {
348
+ const button = document.createElement("button");
349
+ button.type = "button";
350
+ button.className = "crl_msgAct crl_fill";
351
+ button.dataset.crlFill = messageId;
352
+ button.setAttribute("data-tip", lang.fill);
353
+ button.setAttribute("aria-label", lang.fill);
354
+ button.innerHTML = "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" aria-hidden=\"true\"><path d=\"M8 3.5v9M3.5 8h9\"/></svg>";
355
+ button.addEventListener("click", (event) => {
356
+ event.preventDefault();
357
+ event.stopPropagation();
358
+ const id = button.dataset.crlFill;
359
+ if (id !== void 0) actionCtx.onFill(id);
360
+ });
361
+ return button;
362
+ }
363
+ /** Sync one star button to its message's current favorite state. */
364
+ function refreshFavoriteButton(button, lang) {
365
+ const id = button.dataset.crlStar;
366
+ if (id === void 0) return;
367
+ const starred = isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? "", id);
368
+ button.setAttribute("aria-pressed", String(starred));
369
+ button.dataset.starred = starred ? "true" : void 0;
370
+ button.classList.toggle("crl_on", starred);
371
+ const label = starred ? lang.unfav : lang.fav;
372
+ button.setAttribute("data-tip", label);
373
+ button.setAttribute("aria-label", label);
374
+ }
375
+ /** Refresh every injected star button (session switch / external toggle). */
376
+ function refreshAllFavoriteButtons(lang) {
377
+ for (const button of document.querySelectorAll("[data-crl-star]")) refreshFavoriteButton(button, lang);
378
+ }
379
+ /** Inject star + plus buttons into one user message row (idempotent). */
380
+ function injectMessageActions(row, lang) {
381
+ if (row.dataset.crlActions === "1") {
382
+ if (row.querySelector("[data-crl-star]") !== null && row.querySelector("[data-crl-fill]") !== null) return;
383
+ }
384
+ const copy = copyButtonOf(row);
385
+ const messageId = messageIdOfAnchorKey(row.getAttribute("data-chat-anchor-key") ?? "");
386
+ if (copy === null || messageId === "") return;
387
+ const star = favoriteButtonOf(messageId, lang);
388
+ star.dataset.starred = isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? "", messageId) ? "true" : void 0;
389
+ star.classList.toggle("crl_on", isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? "", messageId));
390
+ const fill = fillButtonOf(messageId, lang);
391
+ copy.before(star, fill);
392
+ row.dataset.crlActions = "1";
393
+ }
394
+ /** Start observing new user rows so injected actions follow history paging. */
395
+ function startActionInjector(lang) {
396
+ const scan = () => {
397
+ for (const row of document.querySelectorAll("[data-chat-anchor-key^=\"13:input-message\"]")) injectMessageActions(row, lang);
398
+ };
399
+ const observer = new MutationObserver(scan);
400
+ if (typeof document !== "undefined" && document.body !== null) observer.observe(document.body, {
401
+ childList: true,
402
+ subtree: true
403
+ });
404
+ scan();
405
+ return () => observer.disconnect();
406
+ }
155
407
  /** Extract stored-image references from a ContentBlock list (reference form). */
156
408
  function nodeImagesOf(content) {
157
409
  if (!Array.isArray(content)) return [];
@@ -436,7 +688,7 @@ window.__ModuleLoader__.load({
436
688
  const lang = typeof document !== "undefined" ? (document.documentElement.lang || "zh").toLowerCase() : "zh";
437
689
  return STRINGS[lang.startsWith("zh") ? "zh" : "en"];
438
690
  }
439
- function TimelineRail({ useProjection, sessionId, sessionsService }) {
691
+ function TimelineRail({ useProjection, sessionId, sessionsService, inputActions, conversation }) {
440
692
  const t = langStrings();
441
693
  const projected = useProjection("chatRail");
442
694
  const session = sessionId === void 0 ? void 0 : sessionsService.binding(sessionId)?.session;
@@ -445,6 +697,13 @@ window.__ModuleLoader__.load({
445
697
  let messages = [];
446
698
  if (Array.isArray(projected?.messages) && projected.messages.length > 0) messages = projected.messages.map(normalize).filter((m) => m !== null);
447
699
  if (messages.length === 0) messages = collectFromNodes(nodeSnapshot);
700
+ const favorites = (0, react.useSyncExternalStore)(subscribeFavorites, favoritesSnapshot);
701
+ const [favOnly, setFavOnly] = (0, react.useState)(false);
702
+ const showFavToggle = (sessionId === void 0 ? 0 : messages.filter((m) => isFavorite(favorites, sessionId, favoriteIdOfMessage(m))).length) > 0;
703
+ const effectiveMessages = favOnly ? messages.filter((m) => sessionId !== void 0 && isFavorite(favorites, sessionId, favoriteIdOfMessage(m))) : messages;
704
+ (0, react.useEffect)(() => {
705
+ if (favOnly && !showFavToggle) setFavOnly(false);
706
+ }, [favOnly, showFavToggle]);
448
707
  const [activeIndex, setActiveIndex] = (0, react.useState)(-1);
449
708
  const [show, setShow] = (0, react.useState)(false);
450
709
  const [jumping, setJumping] = (0, react.useState)(false);
@@ -529,6 +788,57 @@ window.__ModuleLoader__.load({
529
788
  const s = sessionsService.binding(sessionId)?.session;
530
789
  return s?.readAttachment === void 0 ? void 0 : (id) => s.readAttachment(id);
531
790
  }, [sessionId, sessionsService]);
791
+ const fillToInput = (0, react.useCallback)((messageId) => {
792
+ const m = messages.find((candidate) => favoriteIdOfMessage(candidate) === messageId);
793
+ if (m === void 0 || inputActions === void 0 || sessionId === void 0) return;
794
+ const text = fullTextOf(m, nodeSnapshot) || m.text;
795
+ if (inputActions.setDraft !== void 0) inputActions.setDraft(text);
796
+ const specs = tipImagesOf(m, nodeSnapshot).filter((spec) => spec.kind === "ref");
797
+ if (specs.length === 0 || conversation === void 0 || readThumb === void 0) return;
798
+ (async () => {
799
+ const files = [];
800
+ for (const spec of specs) try {
801
+ const result = await readThumb(spec.attachmentId);
802
+ if (result?.ok !== true || result.value?.data === void 0) continue;
803
+ const mediaType = result.value.attachment?.mediaType ?? spec.mediaType;
804
+ files.push(new File([result.value.data], `attachment-${spec.attachmentId}${mediaType.includes("jpeg") ? ".jpg" : ".png"}`, { type: mediaType }));
805
+ } catch {}
806
+ if (files.length === 0) return;
807
+ const ids = conversation.createDraftImages(files).map((draft) => draft.id).filter((id) => id !== void 0);
808
+ if (ids.length > 0) try {
809
+ inputActions.addImages?.(ids);
810
+ } catch {}
811
+ })();
812
+ }, [
813
+ conversation,
814
+ inputActions,
815
+ messages,
816
+ nodeSnapshot,
817
+ readThumb,
818
+ sessionId
819
+ ]);
820
+ (0, react.useEffect)(() => {
821
+ actionCtx.sessionId = sessionId;
822
+ actionCtx.onToggleFavorite = (messageId) => {
823
+ if (sessionId !== void 0) toggleFavorite(sessionId, messageId);
824
+ };
825
+ actionCtx.onFill = fillToInput;
826
+ refreshAllFavoriteButtons(t);
827
+ return () => {
828
+ if (actionCtx.sessionId === sessionId) actionCtx.sessionId = void 0;
829
+ };
830
+ }, [
831
+ sessionId,
832
+ fillToInput,
833
+ t
834
+ ]);
835
+ (0, react.useEffect)(() => {
836
+ refreshAllFavoriteButtons(t);
837
+ }, [favorites, t]);
838
+ (0, react.useEffect)(() => {
839
+ if (typeof document === "undefined" || document.body === null) return;
840
+ return startActionInjector(t);
841
+ }, [t]);
532
842
  (0, react.useEffect)(() => {
533
843
  if (session === void 0) return;
534
844
  if (Array.isArray(projected?.messages) && projected.messages.length > 0) return;
@@ -556,10 +866,10 @@ window.__ModuleLoader__.load({
556
866
  Array.isArray(projected?.messages) && projected.messages.length > 0 ? "have" : "none"
557
867
  ]);
558
868
  (0, react.useEffect)(() => {
559
- if (messages.length === 0) return;
869
+ if (effectiveMessages.length === 0) return;
560
870
  const indexByKey = /* @__PURE__ */ new Map();
561
- for (let i = 0; i < messages.length; i++) {
562
- const key = anchorKeyOf(messages[i]);
871
+ for (let i = 0; i < effectiveMessages.length; i++) {
872
+ const key = anchorKeyOf(effectiveMessages[i]);
563
873
  if (key !== void 0) indexByKey.set(key, i);
564
874
  }
565
875
  const updateActive = () => {
@@ -602,7 +912,7 @@ window.__ModuleLoader__.load({
602
912
  window.clearInterval(timer);
603
913
  if (scrollTimer !== null) clearTimeout(scrollTimer);
604
914
  };
605
- }, [sessionId, messages.length]);
915
+ }, [sessionId, effectiveMessages.length]);
606
916
  (0, react.useEffect)(() => {
607
917
  if (activeIndex < 0) return;
608
918
  const el = navRef.current;
@@ -616,13 +926,15 @@ window.__ModuleLoader__.load({
616
926
  });
617
927
  }, [activeIndex]);
618
928
  if (sessionId === void 0 || messages.length < 2) return null;
619
- const items = messages.map((m, i) => {
929
+ const tipIndex = tip === null ? -1 : favOnly ? messages.findIndex((m) => effectiveMessages[tip.index] !== void 0 && favoriteIdOfMessage(m) === favoriteIdOfMessage(effectiveMessages[tip.index])) : tip.index;
930
+ const items = effectiveMessages.map((m, i) => {
620
931
  const key = anchorKeyOf(m);
932
+ const starred = favoriteIdOfMessage(m) !== "" && isFavorite(favorites, sessionId, favoriteIdOfMessage(m));
621
933
  return (0, react.createElement)("button", {
622
934
  type: "button",
623
935
  key: m.seq,
624
936
  "data-crl-index": String(i),
625
- className: S.item + (activeIndex === i ? ` ${S.itemActive}` : ""),
937
+ className: S.item + (activeIndex === i ? ` ${S.itemActive}` : "") + (starred ? ` ${S.favItem}` : ""),
626
938
  "aria-label": `${t.roleUser}: ${m.text.slice(0, 60) || t.noText} (${t.ariaJump})`,
627
939
  "aria-current": activeIndex === i ? "location" : void 0,
628
940
  disabled: jumping,
@@ -638,6 +950,22 @@ window.__ModuleLoader__.load({
638
950
  onMouseLeave: () => handleItemLeave(i),
639
951
  children: [
640
952
  (0, react.createElement)("span", { className: S.num }, `#${i + 1}`),
953
+ starred ? (0, react.createElement)("span", {
954
+ className: S.favStar,
955
+ role: "img",
956
+ "aria-label": t.fav,
957
+ "aria-hidden": void 0
958
+ }, (0, react.createElement)("svg", {
959
+ viewBox: "0 0 24 24",
960
+ width: 13,
961
+ height: 13,
962
+ fill: "currentColor",
963
+ stroke: "none",
964
+ "aria-hidden": true
965
+ }, (0, react.createElement)("path", {
966
+ d: STAR_PATH,
967
+ "aria-hidden": true
968
+ }))) : null,
641
969
  (0, react.createElement)("span", { className: S.title }, m.text === "" ? t.noText : m.text),
642
970
  m.hasImage ? (0, react.createElement)("span", {
643
971
  className: S.img,
@@ -681,11 +1009,34 @@ window.__ModuleLoader__.load({
681
1009
  "aria-label": t.railLabel,
682
1010
  onMouseEnter: () => setShow(true),
683
1011
  onMouseLeave: () => setShow(false),
684
- children: [jumping ? (0, react.createElement)("div", {
685
- className: S.loading,
686
- key: "loading"
687
- }, (0, react.createElement)("span", { className: S.loadingLabel }, t.loading)) : null, ...items]
688
- }), tip !== null && tip.index >= 0 && tip.index < messages.length ? (0, react.createElement)("div", {
1012
+ children: [
1013
+ showFavToggle ? (0, react.createElement)("button", {
1014
+ type: "button",
1015
+ key: "favToggle",
1016
+ className: S.favToggle + (favOnly ? " crl_on" : ""),
1017
+ "aria-pressed": favOnly,
1018
+ "aria-label": t.favOnly,
1019
+ title: t.favOnly,
1020
+ onMouseEnter: (e) => e.stopPropagation(),
1021
+ onClick: () => setFavOnly((v) => !v),
1022
+ children: [(0, react.createElement)("svg", {
1023
+ viewBox: "0 0 24 24",
1024
+ width: 14,
1025
+ height: 14,
1026
+ fill: favOnly ? "currentColor" : "none",
1027
+ stroke: "currentColor",
1028
+ strokeWidth: 2,
1029
+ strokeLinejoin: "round",
1030
+ "aria-hidden": true
1031
+ }, (0, react.createElement)("path", { d: STAR_PATH })), (0, react.createElement)("span", { className: S.favToggleLabel }, t.favOnly)]
1032
+ }) : null,
1033
+ jumping ? (0, react.createElement)("div", {
1034
+ className: S.loading,
1035
+ key: "loading"
1036
+ }, (0, react.createElement)("span", { className: S.loadingLabel }, t.loading)) : null,
1037
+ ...items
1038
+ ]
1039
+ }), tip !== null && tipIndex >= 0 && tipIndex < messages.length ? (0, react.createElement)("div", {
689
1040
  className: S.tip,
690
1041
  style: {
691
1042
  left: `${tip.x}px`,
@@ -693,7 +1044,7 @@ window.__ModuleLoader__.load({
693
1044
  transform: "translateX(-100%)"
694
1045
  }
695
1046
  }, (() => {
696
- const m = messages[tip.index];
1047
+ const m = messages[tipIndex];
697
1048
  const specs = tipImagesOf(m, nodeSnapshot);
698
1049
  const children = [];
699
1050
  if (specs.length > 0 && readThumb !== void 0) children.push((0, react.createElement)("div", {
@@ -723,15 +1074,23 @@ window.__ModuleLoader__.load({
723
1074
  name: "conversation.input.dock",
724
1075
  id: "chat-rail",
725
1076
  order: 40,
726
- inject: () => ({ sessionsService: ctx.sessions })
1077
+ inject: () => ({
1078
+ sessionsService: ctx.sessions,
1079
+ conversation: ctx.conversation
1080
+ })
727
1081
  }, TimelineRail));
728
1082
  }
729
1083
  //#endregion
730
1084
  exports.TimelineRail = TimelineRail;
731
1085
  exports.apply = apply;
1086
+ exports.favoriteIdsOf = favoriteIdsOf;
732
1087
  exports.imageSpecsOfContent = imageSpecsOfContent;
733
1088
  exports.inject = inject;
1089
+ exports.isFavorite = isFavorite;
1090
+ exports.messageIdOfAnchorKey = messageIdOfAnchorKey;
734
1091
  exports.normalize = normalize;
1092
+ exports.readFavorites = readFavorites;
1093
+ exports.toggleFavoriteId = toggleFavoriteId;
735
1094
  return module.exports;
736
1095
  }
737
1096
  });
package/lib/client.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":["useState","createElement","useSyncExternalStore","useRef","useMemo","createPortal"],"sources":["../src/client/index.tsx"],"sourcesContent":["/**\r\n * @max-null/dsh-chat-rail — web client half.\r\n *\r\n * Right-edge conversation navigation rail: one indicator per user message,\r\n * scroll-spy highlight of the reading position, hover preview, click to jump.\r\n *\r\n * Two deliberate fixes over the dsh-chat-timeline reference:\r\n *\r\n * 1. ANIMATION SYNC with dsh-better-sidebar. better-sidebar drives its layout\r\n * push through the `--dsh-sidebar-width` CSS variable on `:root` (the\r\n * `#root` margin-right transition reads the same variable). The rail's\r\n * `right` is `calc(var(--dsh-sidebar-width, 0px) + 12px)` with the same\r\n * transition timing, so rail and scrollport move together while the panel\r\n * expands — no \"panel first, scrollbar later\" lag.\r\n *\r\n * 2. VISIBILITY. The reference rail is 34px wide with 8×2px rgba(0,0,0,.16)\r\n * lines — nearly invisible on a light background. Here the rail keeps a\r\n * constant translucent capsule, thicker/darker indicator lines, and an\r\n * always-visible collapsed state (no \"reveal on pointer proximity\").\r\n *\r\n * Data: host `chatRail` projection first, loaded chat nodes fallback, then a\r\n * background loadOlder loop (stopped as soon as the projection delivers).\r\n * Mounted in conversation.input.dock, portal-rendered to body.\r\n */\r\n\r\nimport { createElement, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'\r\nimport type { ISessions } from '@deepseek-ai/dsh-client-runtime/client'\r\n// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry).\r\nimport type {} from '@deepseek-ai/dsh-client-ui-conversation/client'\r\n\r\nexport const inject = ['slots', 'sessions']\r\n\r\n// ---- i18n (DSH locale-aware, zh/en) ----\r\ntype LocaleId = 'zh' | 'en'\r\nconst STRINGS: Record<LocaleId, Record<string, string>> = {\r\n zh: {\r\n railLabel: '消息导航',\r\n roleUser: '用户',\r\n noText: '(无文本内容)',\r\n ariaJump: '跳转到消息',\r\n hasImage: '含图片',\r\n loading: '加载中…',\r\n timeJustNow: '刚刚',\r\n timeMinutes: '{n}分钟前',\r\n timeHours: '{n}小时前',\r\n timeDays: '{n}天前',\r\n },\r\n en: {\r\n railLabel: 'Message rail',\r\n roleUser: 'User',\r\n noText: '(no text)',\r\n ariaJump: 'Jump to message',\r\n hasImage: 'Has image',\r\n loading: 'Loading…',\r\n timeJustNow: 'just now',\r\n timeMinutes: '{n}m ago',\r\n timeHours: '{n}h ago',\r\n timeDays: '{n}d ago',\r\n },\r\n}\r\n\r\n/** Compact relative time for a message timestamp (zh/en via template). */\r\nfunction relativeTime(ts: number, s: Record<string, string>): string {\r\n const diff = Date.now() - ts\r\n if (diff < 60_000) return s.timeJustNow\r\n if (diff < 3_600_000) return s.timeMinutes.replace('{n}', String(Math.floor(diff / 60_000)))\r\n if (diff < 86_400_000) return s.timeHours.replace('{n}', String(Math.floor(diff / 3_600_000)))\r\n if (diff < 7 * 86_400_000) return s.timeDays.replace('{n}', String(Math.floor(diff / 86_400_000)))\r\n const d = new Date(ts)\r\n return `${d.getMonth() + 1}/${d.getDate()}`\r\n}\r\n\r\n// ---- styles ----\r\n// 画卷式导航:单个容器,折叠态 36px 竖条;hover 时宽度从右往左展开到 280px\r\n// (打开画卷效果)。容器垂直方向在「去掉底栏后的可用区域」内居中:\r\n// top = (100vh - 底栏高)/2 + translateY(-50%),底栏收起时正中、展开时上移避让。\r\n// right 跟随 --dsh-sidebar-width、top 跟随 --dsh-sidebar-height,与 better-sidebar\r\n// 面板共享同一 CSS 变量 + transition,动画同步。\r\nconst css = [\r\n '.crl_nav{user-select:none;z-index:100;position:fixed;right:calc(var(--dsh-sidebar-width,0px) + 3px);top:calc((100vh - var(--dsh-sidebar-height,0px)) / 2);transform:translateY(-50%);width:36px;max-height:min(60vh,420px,calc(100vh - var(--dsh-sidebar-height,0px) - 40px));display:flex;flex-direction:column;align-items:center;box-sizing:border-box;padding:10px 0;border-radius:18px;overflow-y:hidden;overflow-x:hidden;background:rgba(255,255,255,.55);border:1px solid rgba(0,0,0,.07);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.18) transparent;transition:width .25s cubic-bezier(.4,0,.2,1),right var(--ds-transition-duration-slow,0.3s) var(--ds-ease-in-out,ease-in-out),top var(--ds-transition-duration-slow,0.3s) var(--ds-ease-in-out,ease-in-out),background .2s ease,border-color .2s ease,box-shadow .2s ease}',\r\n 'body[data-ds-dark-theme] .crl_nav,[data-theme=\\'dark\\'] .crl_nav,.dark .crl_nav{background:rgba(28,28,32,.6);border-color:rgba(255,255,255,.09);scrollbar-color:rgba(255,255,255,.25) transparent}',\r\n '.crl_nav.crl_show{width:280px;overflow-y:auto;align-items:stretch;background:rgba(255,255,255,.94);border-color:rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(0,0,0,.10),0 2px 8px rgba(0,0,0,.05)}',\r\n 'body[data-ds-dark-theme] .crl_nav.crl_show,[data-theme=\\'dark\\'] .crl_nav.crl_show,.dark .crl_nav.crl_show{background:rgba(28,28,32,.96);border-color:rgba(255,255,255,.09);box-shadow:0 10px 30px rgba(0,0,0,.5),0 2px 8px rgba(0,0,0,.28)}',\r\n '.crl_nav::-webkit-scrollbar{width:4px}',\r\n '.crl_nav::-webkit-scrollbar-thumb{background:rgba(0,0,0,.18);border-radius:4px}',\r\n // Jump-in-progress indicator: sticky row pinned at the rail top. The spinner\r\n // icon is always visible; the \"Loading…\" label only appears once expanded\r\n // (in the collapsed 36px rail the text would overflow the capsule).\r\n '.crl_loading{position:sticky;top:0;z-index:2;flex-shrink:0;width:100%;padding:4px 0;font-size:10px;line-height:1;text-align:center;color:var(--dsw-alias-label-secondary,var(--text-muted,rgba(0,0,0,.5)));background:inherit;pointer-events:none}',\r\n '.crl_loading::before{content:\"\";display:inline-block;width:8px;height:8px;margin-right:4px;border:1.5px solid var(--dsw-alias-state-business-primary,#4d6bfe);border-top-color:transparent;border-radius:50%;vertical-align:-1px;animation:crl-spin .8s linear infinite}',\r\n '.crl_loading .crl_loadingLabel{display:none}',\r\n '.crl_show .crl_loading .crl_loadingLabel{display:inline}',\r\n '@keyframes crl-spin{to{transform:rotate(360deg)}}',\r\n // Item row: always flex-end (the indicator rides the right edge through the\r\n // width animation, never jumping). The collapsed padding (0 9px) centers the\r\n // 18px indicator in the 36px capsule; the expanded padding (0 14px) clears\r\n // the text. Padding transitions with the same timing as the width, so the\r\n // indicator glides smoothly instead of snapping when text hides/shows.\r\n '.crl_item{cursor:pointer;flex-shrink:0;height:32px;min-height:32px;display:flex;justify-content:flex-end;align-items:center;width:100%;box-sizing:border-box;padding:0 9px;line-height:20px;background:none;border:none;font:inherit;text-align:left;color:rgba(0,0,0,.68);transition:padding .25s cubic-bezier(.4,0,.2,1),color .15s ease}',\r\n '.crl_show .crl_item{padding:0 14px}',\r\n '.crl_item:hover{color:rgba(0,0,0,.95)}',\r\n '.crl_item.crl_active{color:var(--dsw-alias-state-business-primary,#4d6bfe)}',\r\n 'body[data-ds-dark-theme] .crl_item,[data-theme=\\'dark\\'] .crl_item,.dark .crl_item{color:rgba(255,255,255,.68)}',\r\n 'body[data-ds-dark-theme] .crl_item:hover,[data-theme=\\'dark\\'] .crl_item:hover,.dark .crl_item:hover{color:rgba(255,255,255,.95)}',\r\n // Title / ordinal / time: display:none in collapsed state (so they take no\r\n // flex space and the indicator line stays centered), revealed on expand.\r\n // The ordinal has a fixed width (right-aligned) so message text starts at\r\n // the same x across items; time sits AFTER the title (no fixed width, so it\r\n // never shrinks the text area).\r\n '.crl_title{display:none;font-size:13px;line-height:20px;text-overflow:ellipsis;white-space:nowrap;margin-right:8px;flex:1;min-width:0;text-align:left;overflow:hidden;color:inherit}',\r\n '.crl_show .crl_title{display:block;animation:crl-fade .18s ease}',\r\n '.crl_item.crl_active .crl_title{color:var(--dsw-alias-state-business-primary,#4d6bfe);font-weight:500}',\r\n '.crl_num{display:none;flex-shrink:0;width:28px;font-size:10px;line-height:20px;color:rgba(0,0,0,.35);margin-right:8px;text-align:right;user-select:none}',\r\n '.crl_show .crl_num{display:block;animation:crl-fade .18s ease}',\r\n 'body[data-ds-dark-theme] .crl_num,[data-theme=\\'dark\\'] .crl_num,.dark .crl_num{color:rgba(255,255,255,.35)}',\r\n '.crl_time{display:none;flex-shrink:0;font-size:10px;line-height:20px;color:rgba(0,0,0,.28);margin-right:10px;user-select:none;white-space:nowrap}',\r\n '.crl_show .crl_time{display:block;animation:crl-fade .18s ease}',\r\n 'body[data-ds-dark-theme] .crl_time,[data-theme=\\'dark\\'] .crl_time,.dark .crl_time{color:rgba(255,255,255,.28)}',\r\n '@keyframes crl-fade{from{opacity:0}to{opacity:1}}',\r\n // Indicator line: 10×3px, clearly visible in both themes.\r\n '.crl_ind{flex-shrink:0;display:flex;justify-content:center;align-items:center;width:18px;height:20px}',\r\n '.crl_show .crl_ind{margin-left:8px}',\r\n // Image badge: hidden in the collapsed state (the rail is a clean 36px\r\n // capsule of indicator lines; an icon there would crowd and distract) and\r\n // revealed with the other labels on expand. A quiet stroke glyph in a muted\r\n // tone that ignores the active-blue text color.\r\n '.crl_img{display:none;flex-shrink:0;align-items:center;justify-content:center;width:14px;height:14px;margin-left:6px;color:rgba(0,0,0,.38)}',\r\n '.crl_show .crl_img{display:inline-flex;animation:crl-fade .18s ease}',\r\n 'body[data-ds-dark-theme] .crl_img,[data-theme=\\'dark\\'] .crl_img,.dark .crl_img{color:rgba(255,255,255,.38)}',\r\n '.crl_line{background-color:rgba(0,0,0,.45);border-radius:4px;flex-shrink:0;width:10px;height:3px;transition:background-color .2s ease,transform .2s ease}',\r\n '.crl_item:hover .crl_line{background-color:rgba(0,0,0,.9)}',\r\n '.crl_item.crl_active .crl_line{background-color:var(--dsw-alias-state-business-primary,#4d6bfe);transform-origin:50%;transform:scale(1.4);box-shadow:0 0 6px var(--dsw-alias-state-business-primary,#4d6bfe)}',\r\n 'body[data-ds-dark-theme] .crl_line,[data-theme=\\'dark\\'] .crl_line,.dark .crl_line{background-color:rgba(255,255,255,.5)}',\r\n 'body[data-ds-dark-theme] .crl_item:hover .crl_line,[data-theme=\\'dark\\'] .crl_item:hover .crl_line,.dark .crl_item:hover .crl_line{background-color:rgba(255,255,255,.95)}',\r\n 'body[data-ds-dark-theme] .crl_item.crl_active .crl_line,[data-theme=\\'dark\\'] .crl_item.crl_active .crl_line,.dark .crl_item.crl_active .crl_line{background-color:var(--dsw-alias-state-business-primary,#4d6bfe)}',\r\n // Full-content hover panel: floats to the LEFT of the expanded rail list.\r\n '.crl_tip{position:fixed;z-index:200;max-width:360px;max-height:70vh;overflow-y:auto;padding:10px 12px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-primary,var(--text-primary,rgba(0,0,0,.85)));background:var(--dsw-alias-surface-raised,var(--bg-elevated,rgba(255,255,255,.97)));border:1px solid var(--dsw-alias-border-l2,var(--border-default,rgba(0,0,0,.12)));border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.16);white-space:pre-wrap;word-break:break-word;pointer-events:none}',\r\n 'body[data-ds-dark-theme] .crl_tip,[data-theme=\\'dark\\'] .crl_tip,.dark .crl_tip{background:var(--dsw-alias-surface-raised,var(--bg-elevated,rgba(28,28,32,.97)));border-color:var(--dsw-alias-border-l2,var(--border-default,rgba(255,255,255,.14)))}',\r\n // Tip thumbnail gallery: stacked above the text, one row per image, capped\r\n // height so a tall screenshot cannot swallow the preview panel. The\r\n // placeholder pulses while session.readAttachment resolves the bytes; a\r\n // failed load removes the row entirely rather than showing a broken image.\r\n '.crl_tipImgs{display:flex;flex-direction:column;gap:6px;margin-bottom:8px}',\r\n '.crl_tipImgWrap{position:relative;width:fit-content;max-width:100%;max-height:150px}',\r\n '.crl_tipImg{display:block;max-width:100%;max-height:150px;width:auto;height:auto;object-fit:contain;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,var(--border-default,rgba(0,0,0,.12)))}',\r\n '.crl_tipImgCount{position:absolute;right:6px;bottom:6px;padding:0 6px;font-size:10px;line-height:16px;border-radius:9px;background:rgba(0,0,0,.55);color:rgba(255,255,255,.95);pointer-events:none}',\r\n '.crl_tipImgPh{flex-shrink:0;height:56px;border-radius:8px;background:var(--dsw-alias-surface-sunken,var(--bg-muted,rgba(0,0,0,.05)));animation:crl-pulse 1.2s ease-in-out infinite}',\r\n 'body[data-ds-dark-theme] .crl_tipImg,[data-theme=\\'dark\\'] .crl_tipImg,.dark .crl_tipImg{border-color:var(--dsw-alias-border-l2,var(--border-default,rgba(255,255,255,.14)))}',\r\n '.crl_tipBadge{display:inline-block;margin-bottom:8px;padding:0 7px;font-size:10px;line-height:16px;border-radius:9px;background:var(--dsw-alias-surface-sunken,var(--bg-muted,rgba(0,0,0,.06)));color:var(--dsw-alias-label-secondary,var(--text-muted,rgba(0,0,0,.45)))}',\r\n '@keyframes crl-pulse{0%,100%{opacity:.45}50%{opacity:.85}}',\r\n '@media (prefers-reduced-motion:reduce){.crl_nav,.crl_title,.crl_num,.crl_time,.crl_line{transition:none}.crl_tipImgPh{animation:none}}',\r\n].join('')\r\n\r\nconst STYLE_ID = '@max-null/dsh-chat-rail/styles.module.css'\r\nif (typeof document !== 'undefined' && document.querySelector(`style[data-plugin-css=\"${STYLE_ID}\"]`) === null) {\r\n const tag = document.createElement('style')\r\n tag.dataset.plugin = 'dsh-chat-rail'\r\n tag.dataset.pluginCss = STYLE_ID\r\n tag.textContent = css\r\n document.head.appendChild(tag)\r\n}\r\n\r\nconst S = {\r\n nav: 'crl_nav',\r\n navShow: 'crl_show',\r\n item: 'crl_item',\r\n itemActive: 'crl_active',\r\n title: 'crl_title',\r\n num: 'crl_num',\r\n time: 'crl_time',\r\n ind: 'crl_ind',\r\n line: 'crl_line',\r\n img: 'crl_img',\r\n loading: 'crl_loading',\r\n loadingLabel: 'crl_loadingLabel',\r\n tip: 'crl_tip',\r\n tipImgs: 'crl_tipImgs',\r\n tipImgWrap: 'crl_tipImgWrap',\r\n tipImg: 'crl_tipImg',\r\n tipImgCount: 'crl_tipImgCount',\r\n tipImgPh: 'crl_tipImgPh',\r\n tipBadge: 'crl_tipBadge',\r\n}\r\n\r\n// ---- data helpers ----\r\nconst NOOP_STORE = { getSnapshot: () => undefined, subscribe: () => () => {} }\r\n\r\ninterface RailImage {\r\n attachmentId: string\r\n mediaType: string\r\n width: number\r\n height: number\r\n}\r\n\r\ninterface RailMessage {\r\n seq: number\r\n time: number\r\n text: string\r\n hasImage: boolean\r\n /** Stored-image references from the host projection (empty when inline-only). */\r\n images?: RailImage[]\r\n key?: string\r\n id?: string\r\n}\r\n\r\n/** One displayable tip image: either a durable reference (resolved lazily) or\r\n * an inline base64 payload already usable as an <img> src. */\r\ntype ImageSpec =\r\n | { kind: 'ref'; attachmentId: string; mediaType: string }\r\n | { kind: 'data'; src: string }\r\n\r\n/** Extract stored-image references from a ContentBlock list (reference form). */\r\nfunction nodeImagesOf(content: unknown): RailImage[] {\r\n if (!Array.isArray(content)) return []\r\n const out: RailImage[] = []\r\n for (const block of content) {\r\n if (block === null || typeof block !== 'object') continue\r\n const b = block as { type?: unknown; attachment?: unknown }\r\n if (b.type !== 'image') continue\r\n const a = b.attachment\r\n if (a === null || typeof a !== 'object') continue\r\n const ref = a as { attachmentId?: unknown; mediaType?: unknown; width?: unknown; height?: unknown }\r\n if (typeof ref.attachmentId !== 'string' || ref.attachmentId === '') continue\r\n out.push({\r\n attachmentId: ref.attachmentId,\r\n mediaType: typeof ref.mediaType === 'string' ? ref.mediaType : 'image/png',\r\n width: typeof ref.width === 'number' ? ref.width : 0,\r\n height: typeof ref.height === 'number' ? ref.height : 0,\r\n })\r\n }\r\n return out\r\n}\r\n\r\n/** Convert a ContentBlock list to displayable image specs; inline base64\r\n * blocks (rare in replayed history) become data URLs on the spot. */\r\nfunction imageSpecsOfContent(content: unknown): ImageSpec[] {\r\n const specs: ImageSpec[] = []\r\n if (!Array.isArray(content)) return specs\r\n for (const block of content) {\r\n if (block === null || typeof block !== 'object') continue\r\n const b = block as { type?: unknown; attachment?: unknown; data?: unknown; mediaType?: unknown }\r\n if (b.type !== 'image') continue\r\n const a = b.attachment\r\n if (a !== null && typeof a === 'object') {\r\n const ref = a as { attachmentId?: unknown; mediaType?: unknown }\r\n if (typeof ref.attachmentId === 'string' && ref.attachmentId !== '') {\r\n specs.push({ kind: 'ref', attachmentId: ref.attachmentId, mediaType: typeof ref.mediaType === 'string' ? ref.mediaType : 'image/png' })\r\n continue\r\n }\r\n }\r\n if (typeof b.data === 'string' && b.data !== '') {\r\n specs.push({ kind: 'data', src: `data:${typeof b.mediaType === 'string' ? b.mediaType : 'image/png'};base64,${b.data}` })\r\n }\r\n }\r\n return specs\r\n}\r\n\r\n/** Normalize one projection entry to a rail message. */\r\nfunction normalize(m: unknown): RailMessage | null {\r\n if (m === null || typeof m !== 'object') return null\r\n const o = m as Record<string, unknown>\r\n if (typeof o.seq !== 'number') return null\r\n // Host projection emits `text`; keep `preview` as a defensive fallback in\r\n // case an older projection payload is still cached in the browser.\r\n const text = typeof o.text === 'string' ? o.text : typeof o.preview === 'string' ? o.preview : ''\r\n const images = Array.isArray(o.images)\r\n ? o.images.map(nodeImagesOfEntry).filter((i): i is RailImage => i !== null)\r\n : undefined\r\n return {\r\n seq: o.seq,\r\n time: typeof o.time === 'number' ? o.time : 0,\r\n text,\r\n // Older projections lack hasImage; defensive default false.\r\n hasImage: o.hasImage === true,\r\n ...(images !== undefined && images.length > 0 ? { images } : {}),\r\n ...(typeof o.key === 'string' ? { key: o.key } : {}),\r\n ...(typeof o.id === 'string' ? { id: o.id } : {}),\r\n }\r\n}\r\n\r\n/** Normalize one projection entry of the `images` array (wire form). */\r\nfunction nodeImagesOfEntry(v: unknown): RailImage | null {\r\n if (v === null || typeof v !== 'object') return null\r\n const o = v as Record<string, unknown>\r\n if (typeof o.attachmentId !== 'string' || o.attachmentId === '') return null\r\n return {\r\n attachmentId: o.attachmentId,\r\n mediaType: typeof o.mediaType === 'string' ? o.mediaType : 'image/png',\r\n width: typeof o.width === 'number' ? o.width : 0,\r\n height: typeof o.height === 'number' ? o.height : 0,\r\n }\r\n}\r\n\r\n/** Extract preview text from a user message's ContentBlock list. */\r\nfunction userTextOf(content: unknown): string {\r\n if (!Array.isArray(content)) return ''\r\n let out = ''\r\n for (const block of content) {\r\n if (block !== null && typeof block === 'object' && (block as { type?: unknown }).type === 'text'\r\n && typeof (block as { text?: unknown }).text === 'string') {\r\n out += (block as { text: string }).text\r\n }\r\n }\r\n return out.trim().slice(0, 80)\r\n}\r\n\r\n/** Whether a ContentBlock list carries an image block (rc.8 attachments). */\r\nfunction userHasImage(content: unknown): boolean {\r\n if (!Array.isArray(content)) return false\r\n return content.some(block => block !== null && typeof block === 'object'\r\n && (block as { type?: unknown }).type === 'image')\r\n}\r\n\r\n/** Fallback collector: enumerate user messages from the loaded chat nodes. */\r\nfunction collectFromNodes(snapshot: unknown): RailMessage[] {\r\n const out: RailMessage[] = []\r\n if (snapshot === undefined || (snapshot as { chat?: unknown }).chat === undefined) return out\r\n const chat = (snapshot as { chat: { nodes?: Map<unknown, unknown> } }).chat\r\n if (!chat.nodes) return out\r\n for (const node of chat.nodes.values()) {\r\n if (node === null || typeof node !== 'object') continue\r\n const n = node as { kind?: unknown; key?: unknown; anchorSeq?: unknown; data?: unknown }\r\n if (n.kind !== 'user') continue\r\n const data = n.data as { time?: unknown; content?: unknown } | null\r\n if (data === null || typeof data !== 'object') continue\r\n if (typeof data.time !== 'number' || !Array.isArray(data.content)) continue\r\n const key = typeof n.key === 'string' ? n.key : undefined\r\n if (key === undefined) continue\r\n const images = nodeImagesOf(data.content)\r\n out.push({\r\n seq: typeof n.anchorSeq === 'number' ? n.anchorSeq : 0,\r\n time: data.time,\r\n text: userTextOf(data.content),\r\n hasImage: userHasImage(data.content),\r\n ...(images.length > 0 ? { images } : {}),\r\n key,\r\n })\r\n }\r\n out.sort((a, b) => a.seq - b.seq)\r\n return out\r\n}\r\n\r\n/** Resolve the chat node's data-chat-anchor-key (direct key or id-reconstructed). */\r\nfunction anchorKeyOf(m: RailMessage): string | undefined {\r\n if (typeof m.key === 'string' && m.key !== '') return m.key\r\n if (typeof m.id === 'string' && m.id !== '') return '13:input-message' + m.id\r\n return undefined\r\n}\r\n\r\n/** Full text of a rail message from the loaded chat nodes (uncapped),\r\n * falling back to the projection preview when the node is not mounted. */\r\nfunction fullTextOf(m: RailMessage, nodeSnapshot: unknown): string {\r\n const key = anchorKeyOf(m)\r\n const nodes = (nodeSnapshot as { chat?: { nodes?: Map<string, { data?: { content?: unknown } }> } } | undefined)?.chat?.nodes\r\n const node = key === undefined ? undefined : nodes?.get(key)\r\n const content = node?.data?.content\r\n if (Array.isArray(content)) {\r\n let out = ''\r\n for (const block of content) {\r\n if (block !== null && typeof block === 'object' && (block as { type?: unknown }).type === 'text'\r\n && typeof (block as { text?: unknown }).text === 'string') {\r\n out += (block as { text: string }).text\r\n }\r\n }\r\n const full = out.trim()\r\n if (full !== '') return full\r\n }\r\n return m.text\r\n}\r\n\r\n/** Displayable tip images for one rail message: host projection references\r\n * first (works even before the node window covers the message), then the\r\n * loaded chat node's content blocks. */\r\nfunction tipImagesOf(m: RailMessage, nodeSnapshot: unknown): ImageSpec[] {\r\n if (Array.isArray(m.images) && m.images.length > 0) {\r\n return m.images.map(img => ({ kind: 'ref', attachmentId: img.attachmentId, mediaType: img.mediaType }))\r\n }\r\n const key = anchorKeyOf(m)\r\n const nodes = (nodeSnapshot as { chat?: { nodes?: Map<string, { data?: { content?: unknown } }> } } | undefined)?.chat?.nodes\r\n const node = key === undefined ? undefined : nodes?.get(key)\r\n return imageSpecsOfContent(node?.data?.content)\r\n}\r\n\r\nconst delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))\r\n\r\n/**\r\n * Ensure the message node is loaded into the visible window, then scroll to it.\r\n * loadOlder pages 50 messages at a time (DSH PAGE_MESSAGES), so a jump far back\r\n * in a long session needs many pages. Report page count through onProgress so\r\n * the UI can show a loading state instead of appearing frozen.\r\n *\r\n * The paged-in data lands in the session snapshot synchronously with\r\n * loadOlder's resolution, but the chat rows render through React — the DOM row\r\n * for the target key appears a beat later. We therefore poll for the row (with\r\n * a timeout) instead of querying once and giving up; otherwise a multi-page\r\n * jump would \"finish loading\" without scrolling and need a second click.\r\n */\r\nasync function jumpToMessage(\r\n sessionsService: { binding: (id: string) => { session?: { getSnapshot(): unknown; loadOlder(): Promise<unknown>; hasMore?: boolean; loadingOlder?: boolean } } | undefined },\r\n sessionId: string,\r\n key: string,\r\n onProgress?: (pages: number) => void,\r\n signal?: AbortSignal,\r\n): Promise<boolean> {\r\n const session = sessionsService.binding(sessionId)?.session\r\n if (session === undefined) return false\r\n let pages = 0\r\n let guard = 0\r\n let loaded = false\r\n while (guard++ < 120) {\r\n if (signal?.aborted) return false\r\n const snapshot = session.getSnapshot() as { chat?: { nodes?: Map<string, unknown> }; hasMore?: boolean; loadingOlder?: boolean } | undefined\r\n if (snapshot?.chat?.nodes?.get(key) !== undefined) {\r\n loaded = true\r\n break\r\n }\r\n if (snapshot?.hasMore !== true) break\r\n if (snapshot.loadingOlder === true) {\r\n // Another loader owns the current page; wait for it without busy-spinning.\r\n await delay(50)\r\n continue\r\n }\r\n await session.loadOlder()\r\n pages++\r\n // Report progress only on page boundaries, not every loop iteration.\r\n onProgress?.(pages)\r\n }\r\n // Page guard exhausted (or hasMore ran out) without the node materializing:\r\n // fail fast with context instead of burning the DOM poll below.\r\n if (!loaded) {\r\n console.warn(`[chat-rail] jumpToMessage: node \"${key}\" not loaded after ${pages} page(s)`)\r\n return false\r\n }\r\n const scrollport = typeof document !== 'undefined' ? document.querySelector('[data-conversation-scroll]') : null\r\n if (scrollport === null) return false\r\n // The snapshot is authoritative for \"loaded\", but the row's DOM node only\r\n // appears once React renders the prepended page. Poll for it (up to ~5s) so a\r\n // freshly paged-in jump scrolls on the same click.\r\n let row: Element | null = null\r\n let waited = 0\r\n while (waited++ < 100) {\r\n if (signal?.aborted) return false\r\n row = scrollport.querySelector(`[data-chat-anchor-key=\"${CSS.escape(key)}\"]`)\r\n if (row !== null) break\r\n await delay(50)\r\n }\r\n if (row === null) return false\r\n const reducedMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches\r\n row.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'center' })\r\n return true\r\n}\r\n\r\n// ---- tip image thumbnails ----\r\n\r\n/** Session-scoped browser URL cache for resolved attachment bytes (one entry\r\n * per session:attachment; a failed load evicts itself so the next hover\r\n * retries instead of showing a permanently broken thumbnail). */\r\nconst thumbUrls = new Map<string, Promise<string>>()\r\n\r\n/** Minimal RpcResult shape of session.readAttachment (contract level). */\r\ninterface ReadAttachmentResult {\r\n ok: boolean\r\n value?: { attachment?: { mediaType?: string }; data?: Uint8Array }\r\n error?: { code?: string; message?: string }\r\n}\r\n\r\ntype ReadAttachmentFn = (attachmentId: string) => Promise<ReadAttachmentResult>\r\n\r\n/** Fallback data URL when the browser cannot mint object URLs. */\r\nfunction bytesToDataUrl(data: Uint8Array, mediaType: string): string {\r\n let binary = ''\r\n const chunk = 0x8000\r\n for (let offset = 0; offset < data.length; offset += chunk) {\r\n binary += String.fromCharCode(...data.subarray(offset, offset + chunk))\r\n }\r\n return `data:${mediaType};base64,${btoa(binary)}`\r\n}\r\n\r\n/** Resolve (and cache) one stored image to a displayable URL. */\r\nfunction resolveThumb(sessionId: string, read: ReadAttachmentFn, image: Pick<RailImage, 'attachmentId' | 'mediaType'>): Promise<string> {\r\n const key = `${sessionId}:${image.attachmentId}`\r\n let pending = thumbUrls.get(key)\r\n if (pending === undefined) {\r\n pending = read(image.attachmentId).then((result): string => {\r\n if (!result.ok || result.value === undefined) {\r\n throw new Error(result.error?.message ?? result.error?.code ?? 'readAttachment failed')\r\n }\r\n const data = result.value.data\r\n if (data === undefined) throw new Error('readAttachment resolved no bytes')\r\n const mediaType = result.value.attachment?.mediaType ?? image.mediaType\r\n if (typeof URL.createObjectURL === 'function') {\r\n return URL.createObjectURL(new Blob([data as BlobPart], { type: mediaType }))\r\n }\r\n return bytesToDataUrl(data, mediaType)\r\n })\r\n void pending.catch(() => { thumbUrls.delete(key) })\r\n thumbUrls.set(key, pending)\r\n }\r\n return pending\r\n}\r\n\r\n/** One tip thumbnail: resolves its attachment lazily, shows a pulsing\r\n * placeholder while loading, and removes itself on failure. */\r\nfunction TipThumb({ spec, sessionId, read }: {\r\n spec: ImageSpec\r\n sessionId: string\r\n read: ReadAttachmentFn\r\n}): ReactNode {\r\n const [src, setSrc] = useState<string | null>(null)\r\n const [failed, setFailed] = useState(false)\r\n const specKey = spec.kind === 'ref' ? `ref:${spec.attachmentId}` : 'data'\r\n useEffect(() => {\r\n let alive = true\r\n setSrc(null)\r\n setFailed(false)\r\n if (spec.kind === 'data') {\r\n setSrc(spec.src)\r\n return () => { alive = false }\r\n }\r\n resolveThumb(sessionId, read, spec)\r\n .then((url) => { if (alive) setSrc(url) })\r\n .catch(() => { if (alive) setFailed(true) })\r\n return () => { alive = false }\r\n // spec is a fresh object each render; the string key is the stable identity.\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [specKey, sessionId, read])\r\n if (failed) return null\r\n if (src === null) return createElement('div', { className: S.tipImgPh, 'aria-hidden': true })\r\n return createElement('img', { className: S.tipImg, src, alt: '', draggable: false })\r\n}\r\n\r\n// ---- component ----\r\ninterface TimelineRailProps {\r\n useProjection: (key: string) => { messages?: unknown[] } | undefined\r\n sessionId?: SessionId\r\n sessionsService: ISessions\r\n}\r\n\r\n/** Resolve copy for the current UI language (document lang, DSH sets zh/en). */\r\nfunction langStrings(): Record<string, string> {\r\n const lang = typeof document !== 'undefined' ? (document.documentElement.lang || 'zh').toLowerCase() : 'zh'\r\n return STRINGS[lang.startsWith('zh') ? 'zh' : 'en']\r\n}\r\n\r\nfunction TimelineRail({ useProjection, sessionId, sessionsService }: TimelineRailProps): ReactNode {\r\n const t = langStrings()\r\n const projected = useProjection('chatRail')\r\n const session = sessionId === undefined ? undefined : (sessionsService.binding(sessionId) as { session?: { subscribe(cb: () => void): () => void; getSnapshot(): unknown } } | undefined)?.session\r\n const fallbackStore = session === undefined ? NOOP_STORE : session\r\n const nodeSnapshot = useSyncExternalStore(\r\n (cb) => fallbackStore.subscribe(cb),\r\n () => fallbackStore.getSnapshot(),\r\n )\r\n\r\n let messages: RailMessage[] = []\r\n if (Array.isArray(projected?.messages) && projected.messages.length > 0) {\r\n messages = projected.messages.map(normalize).filter((m): m is RailMessage => m !== null)\r\n }\r\n if (messages.length === 0) {\r\n messages = collectFromNodes(nodeSnapshot)\r\n }\r\n\r\n const [activeIndex, setActiveIndex] = useState(-1)\r\n const [show, setShow] = useState(false)\r\n const [jumping, setJumping] = useState(false)\r\n // Hover full-content panel: index of the hovered item + fixed position.\r\n // The tip only triggers after the expand animation settles — in the\r\n // collapsed state the item rect is only 36px wide, so a tip positioned\r\n // there would be wrong once the rail expands.\r\n const [tip, setTip] = useState<{ index: number; x: number; y: number } | null>(null)\r\n const navRef = useRef<HTMLDivElement | null>(null) // True only after the expand animation has fully settled; the width\r\n // transition takes ~250ms after `show` flips, and item rects are only\r\n // stable once it finishes. Tip positioning must wait for this.\r\n const expandedRef = useRef(false)\r\n // Last observed pointer position over the rail. A mouseenter that arrives\r\n // before the width transition cannot show the tip (rects unstable), yet the\r\n // enter event is already gone by the time the animation ends — the settle\r\n // handler re-hit-tests the cursor against this position to show the tip for\r\n // the node still under the pointer.\r\n const lastPointerRef = useRef<{ x: number; y: number } | null>(null)\r\n // Aborts an in-flight jump (component unmount or a newer click superseding\r\n // an older one), so the loadOlder/DOM-poll loops stop promptly.\r\n const jumpAbortRef = useRef<AbortController | null>(null)\r\n useEffect(() => () => jumpAbortRef.current?.abort(), [])\r\n\r\n // Track the cursor over the rail for the expand-settled tip re-hit-test\r\n // (the `show` effect's settle handler reads lastPointerRef). Native\r\n // listener: plain DOM events keep the callback typed without React\r\n // synthetic-event generics in the hand-written createElement props.\r\n useEffect(() => {\r\n const el = navRef.current\r\n if (el === null) return\r\n const onMove = (e: PointerEvent) => { lastPointerRef.current = { x: e.clientX, y: e.clientY } }\r\n el.addEventListener('pointermove', onMove, { passive: true })\r\n return () => el.removeEventListener('pointermove', onMove)\r\n }, [])\r\n\r\n /** Position the tip against the item's CURRENT (post-expand) rect. */\r\n const positionTip = (index: number) => {\r\n if (index < 0) return\r\n const el = navRef.current?.querySelector<HTMLElement>(`[data-crl-index=\"${index}\"]`)\r\n if (el === null || el === undefined) return\r\n const rect = el.getBoundingClientRect()\r\n setTip({ index, x: rect.left - 12, y: rect.top })\r\n }\r\n\r\n /** Handle item hover: the collapsed state only expands the rail; the tip\r\n * only triggers once the expand animation has settled (item rects are then\r\n * stable, so the tip is positioned correctly). */\r\n const handleItemEnter = (index: number) => {\r\n if (!expandedRef.current) {\r\n setShow(true)\r\n return\r\n }\r\n positionTip(index)\r\n }\r\n\r\n const handleItemLeave = (index: number) => {\r\n setTip((prev) => (prev?.index === index ? null : prev))\r\n }\r\n\r\n // Track the settled expand state: when `show` flips true, wait for the width\r\n // transition to end before allowing tip triggers; when it flips false, the\r\n // rail is collapsing — treat as unexpanded immediately.\r\n useEffect(() => {\r\n if (!show) {\r\n expandedRef.current = false\r\n return\r\n }\r\n const el = navRef.current\r\n if (el === null) return\r\n let settled = false\r\n const settle = () => {\r\n if (settled) return\r\n settled = true\r\n expandedRef.current = true\r\n el.removeEventListener('transitionend', onTransitionEnd)\r\n clearTimeout(timer)\r\n // Re-hit-test the cursor: a mouseenter that fired before the width\r\n // transition could not show the tip (rects were still unstable), and\r\n // the enter event is long gone once the animation ends. If the pointer\r\n // still sits on a rail item, position that item's tip now.\r\n const p = lastPointerRef.current\r\n if (p !== null) {\r\n const hit = document.elementFromPoint(p.x, p.y)\r\n const item = hit === null ? null : hit.closest('[data-crl-index]')\r\n const idx = item === null ? -1 : Number(item.getAttribute('data-crl-index'))\r\n if (Number.isInteger(idx) && idx >= 0) positionTip(idx)\r\n }\r\n }\r\n const onTransitionEnd = (e: TransitionEvent) => {\r\n if (e.propertyName !== 'width') return\r\n settle()\r\n }\r\n el.addEventListener('transitionend', onTransitionEnd)\r\n // Fallback for `prefers-reduced-motion: reduce` (the stylesheet disables\r\n // the width transition, so `transitionend` never fires) and for any lost\r\n // event: settle after the transition duration regardless.\r\n const timer = setTimeout(settle, 300)\r\n return () => {\r\n el.removeEventListener('transitionend', onTransitionEnd)\r\n clearTimeout(timer)\r\n }\r\n }, [show])\r\n\r\n // Session-scoped attachment reader for tip thumbnails (stable identity so\r\n // TipThumb effects do not re-run every render; resolveThumb caches anyway).\r\n const readThumb = useMemo<ReadAttachmentFn | undefined>(() => {\r\n if (sessionId === undefined) return undefined\r\n const s = (sessionsService.binding(sessionId) as unknown as\r\n { session?: { readAttachment?: ReadAttachmentFn } } | undefined)?.session\r\n return s?.readAttachment === undefined\r\n ? undefined\r\n : (id: string) => s.readAttachment!(id)\r\n }, [sessionId, sessionsService])\r\n\r\n // Background full-history load: follow the runtime's authoritative hasMore\r\n // flag, but STOP as soon as the projection delivers.\r\n useEffect(() => {\r\n if (session === undefined) return\r\n if (Array.isArray(projected?.messages) && projected.messages.length > 0) return\r\n let cancelled = false\r\n const run = async () => {\r\n let guard = 0\r\n while (!cancelled && guard++ < 120) {\r\n if (Array.isArray(projected?.messages) && projected.messages.length > 0) return\r\n const snap = session.getSnapshot() as { hasMore?: boolean; loadingOlder?: boolean }\r\n if (snap?.hasMore !== true) return\r\n if (snap.loadingOlder === true) { await delay(50); continue }\r\n await (session as unknown as { loadOlder(): Promise<unknown> }).loadOlder()\r\n }\r\n }\r\n run().catch(() => {})\r\n return () => { cancelled = true }\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [sessionId, session === undefined ? 'none' : 'ready', Array.isArray(projected?.messages) && projected.messages.length > 0 ? 'have' : 'none'])\r\n\r\n // Track the reading position (active item): nearest row to the 40% viewport line.\r\n useEffect(() => {\r\n if (messages.length === 0) return\r\n const indexByKey = new Map<string, number>()\r\n for (let i = 0; i < messages.length; i++) {\r\n const key = anchorKeyOf(messages[i])\r\n if (key !== undefined) indexByKey.set(key, i)\r\n }\r\n const updateActive = () => {\r\n const sp = document.querySelector('[data-conversation-scroll]')\r\n if (sp === null) return\r\n const rect = sp.getBoundingClientRect()\r\n if (rect.height === 0) return\r\n const line = rect.top + rect.height * 0.4\r\n const rows = sp.querySelectorAll('[data-chat-anchor-key^=\"13:input-message\"]')\r\n let best = -1\r\n let bestDist = Infinity\r\n for (const row of rows) {\r\n const key = row.getAttribute('data-chat-anchor-key')\r\n if (key === null) continue\r\n const idx = indexByKey.get(key) ?? -1\r\n if (idx === -1) continue\r\n const r = row.getBoundingClientRect()\r\n const dist = Math.abs(r.top + r.height / 2 - line)\r\n if (dist < bestDist) { bestDist = dist; best = idx }\r\n }\r\n setActiveIndex(best)\r\n }\r\n updateActive()\r\n const el = document.querySelector('[data-conversation-scroll]')\r\n let scrollTimer: ReturnType<typeof setTimeout> | null = null\r\n const onScroll = () => {\r\n if (scrollTimer !== null) return\r\n scrollTimer = setTimeout(() => { scrollTimer = null; updateActive() }, 60)\r\n }\r\n el?.addEventListener('scroll', onScroll, { passive: true })\r\n const timer = window.setInterval(updateActive, 2000)\r\n return () => {\r\n el?.removeEventListener('scroll', onScroll)\r\n window.clearInterval(timer)\r\n if (scrollTimer !== null) clearTimeout(scrollTimer)\r\n }\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [sessionId, messages.length])\r\n\r\n // Scroll the rail so the active item stays centered in its visible area:\r\n // follows the conversation's reading position (and re-centers after a\r\n // session switch, where activeIndex is recomputed for the new session).\r\n useEffect(() => {\r\n if (activeIndex < 0) return\r\n const el = navRef.current\r\n if (el === null) return\r\n const item = el.querySelector<HTMLElement>(`[data-crl-index=\"${activeIndex}\"]`)\r\n if (item === null) return\r\n const target = item.offsetTop - el.clientHeight / 2 + item.clientHeight / 2\r\n el.scrollTo({ top: Math.max(0, target), behavior: 'smooth' })\r\n }, [activeIndex])\r\n\r\n if (sessionId === undefined || messages.length < 2) return null\r\n\r\n const items = messages.map((m, i) => {\r\n const key = anchorKeyOf(m)\r\n return createElement('button', {\r\n type: 'button',\r\n key: m.seq,\r\n 'data-crl-index': String(i),\r\n className: S.item + (activeIndex === i ? ` ${S.itemActive}` : ''),\r\n 'aria-label': `${t.roleUser}: ${m.text.slice(0, 60) || t.noText} (${t.ariaJump})`,\r\n 'aria-current': activeIndex === i ? 'location' : undefined,\r\n disabled: jumping,\r\n onClick: () => {\r\n if (key === undefined || jumping) return\r\n jumpAbortRef.current?.abort()\r\n const controller = new AbortController()\r\n jumpAbortRef.current = controller\r\n setJumping(true)\r\n void jumpToMessage(sessionsService as never, sessionId as string, key, undefined, controller.signal)\r\n .finally(() => setJumping(false))\r\n },\r\n onMouseEnter: () => handleItemEnter(i),\r\n onMouseLeave: () => handleItemLeave(i),\r\n children: [\r\n createElement('span', { className: S.num }, `#${i + 1}`),\r\n createElement('span', { className: S.title }, m.text === '' ? t.noText : m.text),\r\n m.hasImage\r\n ? createElement('span', { className: S.img, role: 'img', 'aria-label': t.hasImage },\r\n createElement('svg', {\r\n viewBox: '0 0 16 16',\r\n width: 12,\r\n height: 12,\r\n fill: 'none',\r\n stroke: 'currentColor',\r\n strokeWidth: 1.4,\r\n strokeLinecap: 'round',\r\n strokeLinejoin: 'round',\r\n 'aria-hidden': true,\r\n },\r\n createElement('rect', { x: 1.7, y: 2.7, width: 12.6, height: 10.6, rx: 2.2 }),\r\n createElement('circle', { cx: 5.6, cy: 6.4, r: 1.15, fill: 'currentColor', stroke: 'none' }),\r\n createElement('path', { d: 'M2.5 11.6l3.6-3.1 2.6 2.2 2.1-1.9 3.3 2.8' })))\r\n : null,\r\n createElement('span', { className: S.time }, relativeTime(m.time, t)),\r\n createElement('span', { className: S.ind, 'aria-hidden': true },\r\n createElement('span', { className: S.line })),\r\n ],\r\n })\r\n })\r\n\r\n return createPortal(\r\n // Fragment: the nav (transformed) and the tip must be siblings — a fixed\r\n // child inside a transformed ancestor is positioned relative to that\r\n // ancestor, breaking the viewport coordinates the tip uses.\r\n [createElement('div', {\r\n ref: navRef,\r\n className: S.nav + (show ? ` ${S.navShow}` : ''),\r\n role: 'navigation',\r\n 'aria-label': t.railLabel,\r\n onMouseEnter: () => setShow(true),\r\n onMouseLeave: () => setShow(false),\r\n children: [\r\n jumping ? createElement('div', { className: S.loading, key: 'loading' },\r\n createElement('span', { className: S.loadingLabel }, t.loading)) : null,\r\n ...items,\r\n ],\r\n }),\r\n // Full-content panel: anchored to the left of the hovered item. Images\r\n // stack above the text; a message whose image cannot be resolved (no\r\n // projection refs and unloaded node) shows a quiet \"has image\" badge.\r\n tip !== null && tip.index >= 0 && tip.index < messages.length\r\n ? createElement('div', {\r\n className: S.tip,\r\n style: { left: `${tip.x}px`, top: `${tip.y}px`, transform: 'translateX(-100%)' },\r\n }, (() => {\r\n const m = messages[tip.index]\r\n const specs = tipImagesOf(m, nodeSnapshot)\r\n const children: ReactNode[] = []\r\n if (specs.length > 0 && readThumb !== undefined) {\r\n // Only the first image renders; the rest collapse into a \"+N\"\r\n // badge so a many-image message cannot push the tip past its\r\n // viewport cap (the stacked thumbnails did).\r\n children.push(createElement('div', { className: S.tipImgs, key: 'imgs' },\r\n createElement('div', { className: S.tipImgWrap, key: 'img' },\r\n createElement(TipThumb, { spec: specs[0], sessionId: sessionId as string, read: readThumb }),\r\n specs.length > 1\r\n ? createElement('span', { className: S.tipImgCount, 'aria-hidden': true }, `+${specs.length - 1}`)\r\n : null)))\r\n } else if (m.hasImage) {\r\n children.push(createElement('span', { className: S.tipBadge, key: 'badge' }, t.hasImage))\r\n }\r\n children.push(createElement('span', { key: 'text' }, fullTextOf(m, nodeSnapshot) || t.noText))\r\n return children\r\n })())\r\n : null],\r\n document.body,\r\n )\r\n}\r\n\r\nfunction apply(ctx: ClientContext): void {\r\n ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({\r\n name: 'conversation.input.dock',\r\n id: 'chat-rail',\r\n order: 40,\r\n inject: () => ({ sessionsService: ctx.sessions }),\r\n }, TimelineRail))\r\n}\r\n\r\nexport { apply, TimelineRail, imageSpecsOfContent, normalize }\r\n"],"mappings":"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"}
1
+ {"version":3,"file":"client.js","names":["useState","createElement","useSyncExternalStore","useRef","useMemo","useCallback","createPortal"],"sources":["../src/client/index.tsx"],"sourcesContent":["/**\r\n * @max-null/dsh-chat-rail — web client half.\r\n *\r\n * Right-edge conversation navigation rail: one indicator per user message,\r\n * scroll-spy highlight of the reading position, hover preview, click to jump.\r\n *\r\n * Two deliberate fixes over the dsh-chat-timeline reference:\r\n *\r\n * 1. ANIMATION SYNC with dsh-better-sidebar. better-sidebar drives its layout\r\n * push through the `--dsh-sidebar-width` CSS variable on `:root` (the\r\n * `#root` margin-right transition reads the same variable). The rail's\r\n * `right` is `calc(var(--dsh-sidebar-width, 0px) + 12px)` with the same\r\n * transition timing, so rail and scrollport move together while the panel\r\n * expands — no \"panel first, scrollbar later\" lag.\r\n *\r\n * 2. VISIBILITY. The reference rail is 34px wide with 8×2px rgba(0,0,0,.16)\r\n * lines — nearly invisible on a light background. Here the rail keeps a\r\n * constant translucent capsule, thicker/darker indicator lines, and an\r\n * always-visible collapsed state (no \"reveal on pointer proximity\").\r\n *\r\n * Data: host `chatRail` projection first, loaded chat nodes fallback, then a\r\n * background loadOlder loop (stopped as soon as the projection delivers).\r\n * Mounted in conversation.input.dock, portal-rendered to body.\r\n */\r\n\r\nimport { createElement, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'\r\nimport type { ISessions } from '@deepseek-ai/dsh-client-runtime/client'\r\n// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry).\r\nimport type {} from '@deepseek-ai/dsh-client-ui-conversation/client'\r\n\r\nexport const inject = ['slots', 'sessions', 'conversation']\r\n\r\n// ---- i18n (DSH locale-aware, zh/en) ----\r\ntype LocaleId = 'zh' | 'en'\r\nconst STRINGS: Record<LocaleId, Record<string, string>> = {\r\n zh: {\r\n railLabel: '消息导航',\r\n roleUser: '用户',\r\n noText: '(无文本内容)',\r\n ariaJump: '跳转到消息',\r\n hasImage: '含图片',\r\n loading: '加载中…',\r\n timeJustNow: '刚刚',\r\n timeMinutes: '{n}分钟前',\r\n timeHours: '{n}小时前',\r\n timeDays: '{n}天前',\r\n fav: '收藏消息',\r\n unfav: '取消收藏',\r\n fill: '填充到输入框',\r\n filled: '已填入输入框',\r\n favOnly: '只显示收藏',\r\n },\r\n en: {\r\n railLabel: 'Message rail',\r\n roleUser: 'User',\r\n noText: '(no text)',\r\n ariaJump: 'Jump to message',\r\n hasImage: 'Has image',\r\n loading: 'Loading…',\r\n timeJustNow: 'just now',\r\n timeMinutes: '{n}m ago',\r\n timeHours: '{n}h ago',\r\n timeDays: '{n}d ago',\r\n fav: 'Bookmark message',\r\n unfav: 'Remove bookmark',\r\n fill: 'Fill into input',\r\n filled: 'Filled into input',\r\n favOnly: 'Bookmarks only',\r\n },\r\n}\r\n\r\n/** Compact relative time for a message timestamp (zh/en via template). */\r\nfunction relativeTime(ts: number, s: Record<string, string>): string {\r\n const diff = Date.now() - ts\r\n if (diff < 60_000) return s.timeJustNow\r\n if (diff < 3_600_000) return s.timeMinutes.replace('{n}', String(Math.floor(diff / 60_000)))\r\n if (diff < 86_400_000) return s.timeHours.replace('{n}', String(Math.floor(diff / 3_600_000)))\r\n if (diff < 7 * 86_400_000) return s.timeDays.replace('{n}', String(Math.floor(diff / 86_400_000)))\r\n const d = new Date(ts)\r\n return `${d.getMonth() + 1}/${d.getDate()}`\r\n}\r\n\r\n// ---- styles ----\r\n// 画卷式导航:单个容器,折叠态 36px 竖条;hover 时宽度从右往左展开到 280px\r\n// (打开画卷效果)。容器垂直方向在「去掉底栏后的可用区域」内居中:\r\n// top = (100vh - 底栏高)/2 + translateY(-50%),底栏收起时正中、展开时上移避让。\r\n// right 跟随 --dsh-sidebar-width、top 跟随 --dsh-sidebar-height,与 better-sidebar\r\n// 面板共享同一 CSS 变量 + transition,动画同步。\r\nconst css = [\r\n '.crl_nav{user-select:none;z-index:100;position:fixed;right:calc(var(--dsh-sidebar-width,0px) + 3px);top:calc((100vh - var(--dsh-sidebar-height,0px)) / 2);transform:translateY(-50%);width:36px;max-height:min(60vh,420px,calc(100vh - var(--dsh-sidebar-height,0px) - 40px));display:flex;flex-direction:column;align-items:center;box-sizing:border-box;padding:10px 0;border-radius:18px;overflow-y:hidden;overflow-x:hidden;background:rgba(255,255,255,.55);border:1px solid rgba(0,0,0,.07);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.18) transparent;transition:width .25s cubic-bezier(.4,0,.2,1),right var(--ds-transition-duration-slow,0.3s) var(--ds-ease-in-out,ease-in-out),top var(--ds-transition-duration-slow,0.3s) var(--ds-ease-in-out,ease-in-out),background .2s ease,border-color .2s ease,box-shadow .2s ease}',\r\n 'body[data-ds-dark-theme] .crl_nav,[data-theme=\\'dark\\'] .crl_nav,.dark .crl_nav{background:rgba(28,28,32,.6);border-color:rgba(255,255,255,.09);scrollbar-color:rgba(255,255,255,.25) transparent}',\r\n '.crl_nav.crl_show{width:280px;overflow-y:auto;align-items:stretch;background:rgba(255,255,255,.94);border-color:rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(0,0,0,.10),0 2px 8px rgba(0,0,0,.05)}',\r\n 'body[data-ds-dark-theme] .crl_nav.crl_show,[data-theme=\\'dark\\'] .crl_nav.crl_show,.dark .crl_nav.crl_show{background:rgba(28,28,32,.96);border-color:rgba(255,255,255,.09);box-shadow:0 10px 30px rgba(0,0,0,.5),0 2px 8px rgba(0,0,0,.28)}',\r\n '.crl_nav::-webkit-scrollbar{width:4px}',\r\n '.crl_nav::-webkit-scrollbar-thumb{background:rgba(0,0,0,.18);border-radius:4px}',\r\n // Jump-in-progress indicator: sticky row pinned at the rail top. The spinner\r\n // icon is always visible; the \"Loading…\" label only appears once expanded\r\n // (in the collapsed 36px rail the text would overflow the capsule).\r\n '.crl_loading{position:sticky;top:0;z-index:2;flex-shrink:0;width:100%;padding:4px 0;font-size:10px;line-height:1;text-align:center;color:var(--dsw-alias-label-secondary,var(--text-muted,rgba(0,0,0,.5)));background:inherit;pointer-events:none}',\r\n '.crl_loading::before{content:\"\";display:inline-block;width:8px;height:8px;margin-right:4px;border:1.5px solid var(--dsw-alias-state-business-primary,#4d6bfe);border-top-color:transparent;border-radius:50%;vertical-align:-1px;animation:crl-spin .8s linear infinite}',\r\n '.crl_loading .crl_loadingLabel{display:none}',\r\n '.crl_show .crl_loading .crl_loadingLabel{display:inline}',\r\n '@keyframes crl-spin{to{transform:rotate(360deg)}}',\r\n // Item row: always flex-end (the indicator rides the right edge through the\r\n // width animation, never jumping). The collapsed padding (0 9px) centers the\r\n // 18px indicator in the 36px capsule; the expanded padding (0 14px) clears\r\n // the text. Padding transitions with the same timing as the width, so the\r\n // indicator glides smoothly instead of snapping when text hides/shows.\r\n '.crl_item{cursor:pointer;flex-shrink:0;height:32px;min-height:32px;display:flex;justify-content:flex-end;align-items:center;width:100%;box-sizing:border-box;padding:0 9px;line-height:20px;background:none;border:none;font:inherit;text-align:left;color:rgba(0,0,0,.68);transition:padding .25s cubic-bezier(.4,0,.2,1),color .15s ease}',\r\n '.crl_show .crl_item{padding:0 14px}',\r\n '.crl_item:hover{color:rgba(0,0,0,.95)}',\r\n '.crl_item.crl_active{color:var(--dsw-alias-state-business-primary,#4d6bfe)}',\r\n 'body[data-ds-dark-theme] .crl_item,[data-theme=\\'dark\\'] .crl_item,.dark .crl_item{color:rgba(255,255,255,.68)}',\r\n 'body[data-ds-dark-theme] .crl_item:hover,[data-theme=\\'dark\\'] .crl_item:hover,.dark .crl_item:hover{color:rgba(255,255,255,.95)}',\r\n // Title / ordinal / time: display:none in collapsed state (so they take no\r\n // flex space and the indicator line stays centered), revealed on expand.\r\n // The ordinal has a fixed width (right-aligned) so message text starts at\r\n // the same x across items; time sits AFTER the title (no fixed width, so it\r\n // never shrinks the text area).\r\n '.crl_title{display:none;font-size:13px;line-height:20px;text-overflow:ellipsis;white-space:nowrap;margin-right:8px;flex:1;min-width:0;text-align:left;overflow:hidden;color:inherit}',\r\n '.crl_show .crl_title{display:block;animation:crl-fade .18s ease}',\r\n '.crl_item.crl_active .crl_title{color:var(--dsw-alias-state-business-primary,#4d6bfe);font-weight:500}',\r\n '.crl_num{display:none;flex-shrink:0;width:28px;font-size:10px;line-height:20px;color:rgba(0,0,0,.35);margin-right:8px;text-align:right;user-select:none}',\r\n '.crl_show .crl_num{display:block;animation:crl-fade .18s ease}',\r\n 'body[data-ds-dark-theme] .crl_num,[data-theme=\\'dark\\'] .crl_num,.dark .crl_num{color:rgba(255,255,255,.35)}',\r\n '.crl_time{display:none;flex-shrink:0;font-size:10px;line-height:20px;color:rgba(0,0,0,.28);margin-right:10px;user-select:none;white-space:nowrap}',\r\n '.crl_show .crl_time{display:block;animation:crl-fade .18s ease}',\r\n 'body[data-ds-dark-theme] .crl_time,[data-theme=\\'dark\\'] .crl_time,.dark .crl_time{color:rgba(255,255,255,.28)}',\r\n '@keyframes crl-fade{from{opacity:0}to{opacity:1}}',\r\n // Indicator line: 10×3px, clearly visible in both themes.\r\n '.crl_ind{flex-shrink:0;display:flex;justify-content:center;align-items:center;width:18px;height:20px}',\r\n '.crl_show .crl_ind{margin-left:8px}',\r\n // Image badge: hidden in the collapsed state (the rail is a clean 36px\r\n // capsule of indicator lines; an icon there would crowd and distract) and\r\n // revealed with the other labels on expand. A quiet stroke glyph in a muted\r\n // tone that ignores the active-blue text color.\r\n '.crl_img{display:none;flex-shrink:0;align-items:center;justify-content:center;width:14px;height:14px;margin-left:6px;color:rgba(0,0,0,.38)}',\r\n '.crl_show .crl_img{display:inline-flex;animation:crl-fade .18s ease}',\r\n 'body[data-ds-dark-theme] .crl_img,[data-theme=\\'dark\\'] .crl_img,.dark .crl_img{color:rgba(255,255,255,.38)}',\r\n '.crl_line{background-color:rgba(0,0,0,.45);border-radius:4px;flex-shrink:0;width:10px;height:3px;transition:background-color .2s ease,transform .2s ease}',\r\n '.crl_item:hover .crl_line{background-color:rgba(0,0,0,.9)}',\r\n '.crl_item.crl_active .crl_line{background-color:var(--dsw-alias-state-business-primary,#4d6bfe);transform-origin:50%;transform:scale(1.4);box-shadow:0 0 6px var(--dsw-alias-state-business-primary,#4d6bfe)}',\r\n 'body[data-ds-dark-theme] .crl_line,[data-theme=\\'dark\\'] .crl_line,.dark .crl_line{background-color:rgba(255,255,255,.5)}',\r\n 'body[data-ds-dark-theme] .crl_item:hover .crl_line,[data-theme=\\'dark\\'] .crl_item:hover .crl_line,.dark .crl_item:hover .crl_line{background-color:rgba(255,255,255,.95)}',\r\n 'body[data-ds-dark-theme] .crl_item.crl_active .crl_line,[data-theme=\\'dark\\'] .crl_item.crl_active .crl_line,.dark .crl_item.crl_active .crl_line{background-color:var(--dsw-alias-state-business-primary,#4d6bfe)}',\r\n // Full-content hover panel: floats to the LEFT of the expanded rail list.\r\n '.crl_tip{position:fixed;z-index:200;max-width:360px;max-height:70vh;overflow-y:auto;padding:10px 12px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-primary,var(--text-primary,rgba(0,0,0,.85)));background:var(--dsw-alias-surface-raised,var(--bg-elevated,rgba(255,255,255,.97)));border:1px solid var(--dsw-alias-border-l2,var(--border-default,rgba(0,0,0,.12)));border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.16);white-space:pre-wrap;word-break:break-word;pointer-events:none}',\r\n 'body[data-ds-dark-theme] .crl_tip,[data-theme=\\'dark\\'] .crl_tip,.dark .crl_tip{background:var(--dsw-alias-surface-raised,var(--bg-elevated,rgba(28,28,32,.97)));border-color:var(--dsw-alias-border-l2,var(--border-default,rgba(255,255,255,.14)))}',\r\n // Tip thumbnail gallery: stacked above the text, one row per image, capped\r\n // height so a tall screenshot cannot swallow the preview panel. The\r\n // placeholder pulses while session.readAttachment resolves the bytes; a\r\n // failed load removes the row entirely rather than showing a broken image.\r\n '.crl_tipImgs{display:flex;flex-direction:column;gap:6px;margin-bottom:8px}',\r\n '.crl_tipImgWrap{position:relative;width:fit-content;max-width:100%;max-height:150px}',\r\n '.crl_tipImg{display:block;max-width:100%;max-height:150px;width:auto;height:auto;object-fit:contain;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,var(--border-default,rgba(0,0,0,.12)))}',\r\n '.crl_tipImgCount{position:absolute;right:6px;bottom:6px;padding:0 6px;font-size:10px;line-height:16px;border-radius:9px;background:rgba(0,0,0,.55);color:rgba(255,255,255,.95);pointer-events:none}',\r\n '.crl_tipImgPh{flex-shrink:0;height:56px;border-radius:8px;background:var(--dsw-alias-surface-sunken,var(--bg-muted,rgba(0,0,0,.05)));animation:crl-pulse 1.2s ease-in-out infinite}',\r\n 'body[data-ds-dark-theme] .crl_tipImg,[data-theme=\\'dark\\'] .crl_tipImg,.dark .crl_tipImg{border-color:var(--dsw-alias-border-l2,var(--border-default,rgba(255,255,255,.14)))}',\r\n '.crl_tipBadge{display:inline-block;margin-bottom:8px;padding:0 7px;font-size:10px;line-height:16px;border-radius:9px;background:var(--dsw-alias-surface-sunken,var(--bg-muted,rgba(0,0,0,.06)));color:var(--dsw-alias-label-secondary,var(--text-muted,rgba(0,0,0,.45)))}',\r\n '@keyframes crl-pulse{0%,100%{opacity:.45}50%{opacity:.85}}',\r\n // Message action buttons injected next to the copy button (DOM row: the\r\n // user-message IconActions row). The host rows use 28px round icon-action\r\n // buttons (padding 6, 16px glyphs, DSW token colors, hover fill) — the\r\n // injected buttons mirror the exact geometry and token surface so the star\r\n // and plus read as native siblings of copy/branch. The star lights\r\n // yellowish (#ffd166, milestone-compatible) once favorited.\r\n // Tooltip: same dark pill as the host Tooltip (black rounded label, no\r\n // native title balloon). The delay mirrors the host tooltip's settle; the\r\n // fade is short so the pill appears right beside the hovered button.\r\n '.crl_msgAct{position:relative;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin:0;padding:6px;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,rgba(0,0,0,.42));cursor:pointer;transition:background .15s ease,color .15s ease}',\r\n '.crl_msgAct:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06));color:var(--dsw-alias-label-secondary,rgba(0,0,0,.72))}',\r\n '.crl_msgAct:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:2px}',\r\n '.crl_msgAct[data-tip]:hover::after{content:attr(data-tip);position:absolute;left:50%;bottom:calc(100% + 8px);transform:translateX(-50%);padding:5px 10px;border-radius:8px;background:rgba(24,28,36,.94);color:rgba(255,255,255,.95);font-size:12px;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;animation:crl-tip-in .12s ease .55s forwards;z-index:3000}',\r\n '.crl_msgAct[data-tip]:hover::before{content:\"\";position:absolute;left:50%;bottom:calc(100% + 3px);transform:translateX(-50%);border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid rgba(24,28,36,.94);pointer-events:none;opacity:0;animation:crl-tip-in .12s ease .55s forwards;z-index:3000}',\r\n '@keyframes crl-tip-in{to{opacity:1}}',\r\n '.crl_msgAct.crl_fav.crl_on{color:#ffd166}',\r\n '.crl_msgAct.crl_fav.crl_on svg{fill:currentColor}',\r\n '.crl_msgAct.crl_fav.crl_on:hover{color:#ffd166}',\r\n // Rail row favorite badge + yellow indicator line for favorited messages.\r\n '.crl_favStar{display:none;flex-shrink:0;align-items:center;justify-content:center;width:14px;height:14px;margin-right:6px;color:#ffd166}',\r\n '.crl_show .crl_favStar{display:inline-flex;animation:crl-fade .18s ease}',\r\n '.crl_item.crl_favItem .crl_line{background-color:#ffd166}',\r\n 'body[data-ds-dark-theme] .crl_item.crl_favItem .crl_line,[data-theme=\\'dark\\'] .crl_item.crl_favItem .crl_line,.dark .crl_item.crl_favItem .crl_line{background-color:#ffd166}',\r\n // A favorited line that is also the current reading position keeps its\r\n // yellow identity — the generic active glow is blue, which pairs miserably\r\n // with the gold line, so the favorite-active state recasts the glow in the\r\n // same warm tone.\r\n '.crl_item.crl_favItem.crl_active .crl_line{background-color:#ffd166;box-shadow:0 0 6px #ffd166}',\r\n // Rail-top favorites-only toggle: a round icon pill in the collapsed state;\r\n // on expand it becomes a full-width label row (star + \"bookmarks only\") so\r\n // the control reads as a real rail header action instead of a stray dot.\r\n // STICKY: the expanded rail's rows scroll inside the capsule (overflow-y),\r\n // and the pill must stay pinned at the top instead of scrolling away with\r\n // them — sticky + an opaque inherited background keeps the labels reading\r\n // cleanly over passing rows.\r\n '.crl_favToggle{position:sticky;top:0;z-index:3;flex-shrink:0;display:flex;align-items:center;justify-content:center;gap:6px;width:26px;height:26px;margin:0 0 6px;padding:0;border:none;border-radius:13px;background:inherit;color:rgba(0,0,0,.4);cursor:pointer;font-size:11px;line-height:1;white-space:nowrap;transition:background .15s ease,color .15s ease}',\r\n '.crl_favToggle:hover{background:rgba(0,0,0,.07);color:rgba(0,0,0,.75)}',\r\n '.crl_favToggle.crl_on{color:#ffd166;background:rgba(255,209,102,.14)}',\r\n '.crl_show .crl_favToggle{width:auto;height:26px;margin:0 6px 8px;padding:0 11px;border-radius:13px;align-self:flex-start;text-align:left}',\r\n '.crl_favToggleLabel{display:none}',\r\n '.crl_show .crl_favToggleLabel{display:inline-block;min-width:0;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}',\r\n 'body[data-ds-dark-theme] .crl_favToggle,[data-theme=\\'dark\\'] .crl_favToggle,.dark .crl_favToggle{color:rgba(255,255,255,.4)}',\r\n 'body[data-ds-dark-theme] .crl_favToggle:hover,[data-theme=\\'dark\\'] .crl_favToggle:hover,.dark .crl_favToggle:hover{background:rgba(255,255,255,.1);color:rgba(255,255,255,.85)}',\r\n 'body[data-ds-dark-theme] .crl_favToggle.crl_on,[data-theme=\\'dark\\'] .crl_favToggle.crl_on,.dark .crl_favToggle.crl_on{color:#ffd166;background:rgba(255,209,102,.18)}',\r\n '@media (prefers-reduced-motion:reduce){.crl_nav,.crl_title,.crl_num,.crl_time,.crl_line{transition:none}.crl_tipImgPh{animation:none}}',\r\n].join('')\r\n\r\nconst STYLE_ID = '@max-null/dsh-chat-rail/styles.module.css'\r\nif (typeof document !== 'undefined' && document.querySelector(`style[data-plugin-css=\"${STYLE_ID}\"]`) === null) {\r\n const tag = document.createElement('style')\r\n tag.dataset.plugin = 'dsh-chat-rail'\r\n tag.dataset.pluginCss = STYLE_ID\r\n tag.textContent = css\r\n document.head.appendChild(tag)\r\n}\r\n\r\nconst S = {\r\n nav: 'crl_nav',\r\n navShow: 'crl_show',\r\n item: 'crl_item',\r\n itemActive: 'crl_active',\r\n title: 'crl_title',\r\n num: 'crl_num',\r\n time: 'crl_time',\r\n ind: 'crl_ind',\r\n line: 'crl_line',\r\n img: 'crl_img',\r\n loading: 'crl_loading',\r\n loadingLabel: 'crl_loadingLabel',\r\n tip: 'crl_tip',\r\n tipImgs: 'crl_tipImgs',\r\n tipImgWrap: 'crl_tipImgWrap',\r\n tipImg: 'crl_tipImg',\r\n tipImgCount: 'crl_tipImgCount',\r\n tipImgPh: 'crl_tipImgPh',\r\n tipBadge: 'crl_tipBadge',\r\n favStar: 'crl_favStar',\r\n favItem: 'crl_favItem',\r\n favToggle: 'crl_favToggle',\r\n favToggleLabel: 'crl_favToggleLabel',\r\n}\r\n\r\n// ---- data helpers ----\r\nconst NOOP_STORE = { getSnapshot: () => undefined, subscribe: () => () => {} }\r\n\r\n// ---- favorites (persisted per-session bookmark store) ----\r\n// Shape: Record<sessionId, messageId[]>. The durable message id is the same\r\n// identity the host projection emits (`id`) and the DOM anchor encodes\r\n// (`13:input-message<id>`), so DOM-injected buttons and the rail agree on\r\n// the key with no projection hop.\r\n// 持久化(2026-08-27):host 文件(~/.dsh/chat-rail-favorites.json,经\r\n// /chat-rail/api/favorites GET/PUT)——localStorage 按 origin 隔离,思灵 DSH\r\n// web 端口每次启动随机 → 跨重启丢弃。localStorage 仅作旧数据一次性迁移。\r\nconst FAVORITES_KEY = '@max-null/dsh-chat-rail:favorites'\r\ntype FavoritesMap = Record<string, string[]>\r\n\r\nconst FAVORITES_API = '/chat-rail/api/favorites'\r\n/** 模块级快照缓存(host 加载后充当源 truth)+ 订阅者集合。 */\r\nlet favoritesCache: FavoritesMap | null = null\r\nconst favoritesListeners = new Set<() => void>()\r\n\r\n/** 从 localStorage 读旧数据(迁移用/离线兜底)。 */\r\nfunction readFavoritesLocal(): FavoritesMap {\r\n if (typeof localStorage === 'undefined') return {}\r\n try {\r\n const raw = localStorage.getItem(FAVORITES_KEY)\r\n if (raw === null) return {}\r\n const value: unknown = JSON.parse(raw)\r\n return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as FavoritesMap : {}\r\n } catch {\r\n return {}\r\n }\r\n}\r\n\r\nasync function fetchFavoritesMap(): Promise<FavoritesMap> {\r\n try {\r\n const res = await fetch(FAVORITES_API, { headers: { Accept: 'application/json' } })\r\n if (!res.ok) return {}\r\n const data = await res.json() as { ok?: boolean, value?: unknown }\r\n const value = data?.value\r\n return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as FavoritesMap : {}\r\n } catch {\r\n return {}\r\n }\r\n}\r\n\r\n/** 模块加载时同步 host;旧 localStorage 数据迁移一次(host 为空且本地有值)。 */\r\nvoid (async () => {\r\n const host = await fetchFavoritesMap()\r\n if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') {\r\n const legacy = readFavoritesLocal()\r\n if (Object.keys(host).length === 0 && Object.keys(legacy).length > 0) {\r\n await fetch(FAVORITES_API, {\r\n method: 'PUT', headers: { 'Content-Type': 'application/json' },\r\n body: JSON.stringify({ favorites: legacy }),\r\n }).catch(() => {})\r\n }\r\n try { localStorage.removeItem(FAVORITES_KEY) } catch { /* 忽略 */ }\r\n /* eslint-disable-next-line @typescript-eslint/no-explicit-any */\r\n favoritesCache = Object.keys(host).length === 0 ? legacy : host\r\n // host 就位后通知快照订阅(rail/收藏按钮重渲染)\r\n for (const listener of favoritesListeners) listener()\r\n } else {\r\n favoritesCache = host\r\n }\r\n})()\r\n\r\n/** Read the favorites map(内存镜像;未加载完成时回退 localStorage)。 */\r\nexport function readFavorites(): FavoritesMap {\r\n if (favoritesCache !== null) return favoritesCache\r\n return readFavoritesLocal()\r\n}\r\n\r\n/** Write the favorites map;更新内存并 fire-and-forget 持久化到 host(host-only)。 */\r\nfunction writeFavorites(map: FavoritesMap): void {\r\n favoritesCache = map\r\n void fetch(FAVORITES_API, {\r\n method: 'PUT', headers: { 'Content-Type': 'application/json' },\r\n body: JSON.stringify({ favorites: map }),\r\n }).catch(() => { /* 网络失败:内存仍在用(下次写会重试) */ })\r\n}\r\n\r\n/** Normalized id list for one session (drops non-string entries). */\r\nexport function favoriteIdsOf(map: FavoritesMap, sessionId: string): string[] {\r\n const list = map[sessionId]\r\n return Array.isArray(list) ? list.filter((id): id is string => typeof id === 'string') : []\r\n}\r\n\r\n/** Toggle one id in a list (stable new array; no in-place mutation). */\r\nexport function toggleFavoriteId(list: string[], id: string): string[] {\r\n return list.includes(id) ? list.filter((candidate) => candidate !== id) : [...list, id]\r\n}\r\n\r\n/** Whether one message is favorited in one session. */\r\nexport function isFavorite(map: FavoritesMap, sessionId: string, messageId: string): boolean {\r\n return favoriteIdsOf(map, sessionId).includes(messageId)\r\n}\r\n\r\n/** Stable snapshot for useSyncExternalStore (loaded once, cached until toggle). */\r\nfunction favoritesSnapshot(): FavoritesMap {\r\n if (favoritesCache === null) favoritesCache = readFavorites()\r\n return favoritesCache\r\n}\r\n\r\n/** Subscribe to favorites changes; returns the unsubscribe. */\r\nfunction subscribeFavorites(listener: () => void): () => void {\r\n favoritesListeners.add(listener)\r\n return () => { favoritesListeners.delete(listener) }\r\n}\r\n\r\n/** Toggle one message's favorite state: snapshot → new map → persist → notify. */\r\nfunction toggleFavorite(sessionId: string, messageId: string): void {\r\n const next = { ...favoritesSnapshot() }\r\n next[sessionId] = toggleFavoriteId(favoriteIdsOf(favoritesSnapshot(), sessionId), messageId)\r\n favoritesCache = next\r\n writeFavorites(next)\r\n for (const listener of [...favoritesListeners]) listener()\r\n}\r\n\r\n/** Remember the messageId read off a DOM anchor key (`13:input-message<id>`). */\r\nexport function messageIdOfAnchorKey(key: string): string {\r\n return key.startsWith('13:input-message') ? key.slice('13:input-message'.length) : key\r\n}\r\n\r\n/** The favorite key of one rail message: the durable id when present (host\r\n * projection), otherwise the id reconstructed from the node anchor (the\r\n * loaded-node fallback path). */\r\nfunction favoriteIdOfMessage(m: Pick<RailMessage, 'id' | 'key'>): string {\r\n if (typeof m.id === 'string' && m.id !== '') return m.id\r\n if (typeof m.key === 'string') return messageIdOfAnchorKey(m.key)\r\n return ''\r\n}\r\n\r\n// ---- message-action injector (DOM) ----\r\n// User-message rows have no official action slot (assistant-actions serves\r\n// assistant turns only), so the star (favorite) and plus (fill-to-input)\r\n// buttons are injected next to the copy button of each user row. The rows\r\n// are located by their anchor key so history paging and re-renders both\r\n// land; the injector is a MutationObserver that re-scans after any DOM\r\n// change and never double-injects (row marker).\r\n//\r\n// Runtime wiring lives in a module-level context the rail component updates\r\n// every render (session id + handlers), so the pure DOM buttons stay tiny:\r\n// they only read data attributes and call context handlers.\r\nconst actionCtx: {\r\n sessionId: string | undefined\r\n onToggleFavorite: (messageId: string) => void\r\n onFill: (messageId: string) => void\r\n} = {\r\n sessionId: undefined,\r\n onToggleFavorite: () => {},\r\n onFill: () => {},\r\n}\r\n\r\n/** aria-label values of the copy button across shipped locales (button sits\r\n * in the user row's IconActions; its parent is the row's action host). */\r\nconst COPY_ARIA_LABELS = new Set(['复制', 'Copy', '已复制', 'Copied'])\r\n\r\n/** The copy button inside one user message row (null until rendered). */\r\nfunction copyButtonOf(row: HTMLElement): HTMLButtonElement | null {\r\n for (const button of row.querySelectorAll<HTMLButtonElement>('button')) {\r\n const label = button.getAttribute('aria-label')\r\n if (label !== null && COPY_ARIA_LABELS.has(label)) return button\r\n }\r\n return null\r\n}\r\n\r\n/** Star SVG path (milestone-compatible glyph, 24-unit viewBox). */\r\nconst STAR_PATH = 'm12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'\r\n\r\n/** Build the favorite (star) button for one user message row. */\r\nfunction favoriteButtonOf(messageId: string, lang: Record<string, string>): HTMLButtonElement {\r\n const button = document.createElement('button')\r\n button.type = 'button'\r\n button.className = 'crl_msgAct crl_fav'\r\n button.dataset.crlStar = messageId\r\n const initialLabel = isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? '', messageId) ? lang.unfav : lang.fav\r\n button.setAttribute('aria-pressed', String(isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? '', messageId)))\r\n button.dataset.starred = isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? '', messageId) ? 'true' : undefined\r\n button.classList.toggle('crl_on', isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? '', messageId))\r\n // The CSS tooltip reads data-tip (a short dark pill like the host Tooltip);\r\n // the native title balloon is intentionally absent — double tips would\r\n // stack a browser bubble over the styled one.\r\n button.setAttribute('data-tip', initialLabel)\r\n button.setAttribute('aria-label', initialLabel)\r\n button.innerHTML = `<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"${STAR_PATH}\"/></svg>`\r\n button.addEventListener('click', (event) => {\r\n event.preventDefault()\r\n event.stopPropagation()\r\n // Read the id off the dataset at click time: the injector may refresh the\r\n // button for a new session and the closure must stay current.\r\n const id = button.dataset.crlStar\r\n if (id !== undefined) {\r\n actionCtx.onToggleFavorite(id)\r\n refreshFavoriteButton(button, lang)\r\n }\r\n })\r\n return button\r\n}\r\n\r\n/** Build the fill-to-input (plus) button for one user message row. */\r\nfunction fillButtonOf(messageId: string, lang: Record<string, string>): HTMLButtonElement {\r\n const button = document.createElement('button')\r\n button.type = 'button'\r\n button.className = 'crl_msgAct crl_fill'\r\n button.dataset.crlFill = messageId\r\n button.setAttribute('data-tip', lang.fill)\r\n button.setAttribute('aria-label', lang.fill)\r\n button.innerHTML = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" aria-hidden=\"true\"><path d=\"M8 3.5v9M3.5 8h9\"/></svg>'\r\n button.addEventListener('click', (event) => {\r\n event.preventDefault()\r\n event.stopPropagation()\r\n const id = button.dataset.crlFill\r\n if (id !== undefined) actionCtx.onFill(id)\r\n })\r\n return button\r\n}\r\n\r\n/** Sync one star button to its message's current favorite state. */\r\nfunction refreshFavoriteButton(button: HTMLButtonElement, lang: Record<string, string>): void {\r\n const id = button.dataset.crlStar\r\n if (id === undefined) return\r\n const starred = isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? '', id)\r\n button.setAttribute('aria-pressed', String(starred))\r\n button.dataset.starred = starred ? 'true' : undefined\r\n button.classList.toggle('crl_on', starred)\r\n const label = starred ? lang.unfav : lang.fav\r\n // Both the a11y label and the styled tooltip follow the new state.\r\n button.setAttribute('data-tip', label)\r\n button.setAttribute('aria-label', label)\r\n}\r\n\r\n/** Refresh every injected star button (session switch / external toggle). */\r\nfunction refreshAllFavoriteButtons(lang: Record<string, string>): void {\r\n for (const button of document.querySelectorAll<HTMLButtonElement>('[data-crl-star]')) {\r\n refreshFavoriteButton(button, lang)\r\n }\r\n}\r\n\r\n/** Inject star + plus buttons into one user message row (idempotent). */\r\nfunction injectMessageActions(row: HTMLElement, lang: Record<string, string>): void {\r\n // Already injected and both buttons still present → leave it alone.\r\n if (row.dataset.crlActions === '1') {\r\n if (row.querySelector('[data-crl-star]') !== null && row.querySelector('[data-crl-fill]') !== null) return\r\n // React re-rendered the row and replaced the icons: fall through to re-inject.\r\n }\r\n const copy = copyButtonOf(row)\r\n const messageId = messageIdOfAnchorKey(row.getAttribute('data-chat-anchor-key') ?? '')\r\n if (copy === null || messageId === '') return\r\n const star = favoriteButtonOf(messageId, lang)\r\n star.dataset.starred = isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? '', messageId) ? 'true' : undefined\r\n star.classList.toggle('crl_on', isFavorite(favoritesSnapshot(), actionCtx.sessionId ?? '', messageId))\r\n const fill = fillButtonOf(messageId, lang)\r\n copy.before(star, fill)\r\n row.dataset.crlActions = '1'\r\n}\r\n\r\n/** Start observing new user rows so injected actions follow history paging. */\r\nfunction startActionInjector(lang: Record<string, string>): () => void {\r\n const scan = () => {\r\n for (const row of document.querySelectorAll<HTMLElement>('[data-chat-anchor-key^=\"13:input-message\"]')) {\r\n injectMessageActions(row, lang)\r\n }\r\n }\r\n const observer = new MutationObserver(scan)\r\n if (typeof document !== 'undefined' && document.body !== null) {\r\n observer.observe(document.body, { childList: true, subtree: true })\r\n }\r\n scan()\r\n return () => observer.disconnect()\r\n}\r\n\r\ninterface RailImage {\r\n attachmentId: string\r\n mediaType: string\r\n width: number\r\n height: number\r\n}\r\n\r\ninterface RailMessage {\r\n seq: number\r\n time: number\r\n text: string\r\n hasImage: boolean\r\n /** Stored-image references from the host projection (empty when inline-only). */\r\n images?: RailImage[]\r\n key?: string\r\n id?: string\r\n}\r\n\r\n/** One displayable tip image: either a durable reference (resolved lazily) or\r\n * an inline base64 payload already usable as an <img> src. */\r\ntype ImageSpec =\r\n | { kind: 'ref'; attachmentId: string; mediaType: string }\r\n | { kind: 'data'; src: string }\r\n\r\n/** Extract stored-image references from a ContentBlock list (reference form). */\r\nfunction nodeImagesOf(content: unknown): RailImage[] {\r\n if (!Array.isArray(content)) return []\r\n const out: RailImage[] = []\r\n for (const block of content) {\r\n if (block === null || typeof block !== 'object') continue\r\n const b = block as { type?: unknown; attachment?: unknown }\r\n if (b.type !== 'image') continue\r\n const a = b.attachment\r\n if (a === null || typeof a !== 'object') continue\r\n const ref = a as { attachmentId?: unknown; mediaType?: unknown; width?: unknown; height?: unknown }\r\n if (typeof ref.attachmentId !== 'string' || ref.attachmentId === '') continue\r\n out.push({\r\n attachmentId: ref.attachmentId,\r\n mediaType: typeof ref.mediaType === 'string' ? ref.mediaType : 'image/png',\r\n width: typeof ref.width === 'number' ? ref.width : 0,\r\n height: typeof ref.height === 'number' ? ref.height : 0,\r\n })\r\n }\r\n return out\r\n}\r\n\r\n/** Convert a ContentBlock list to displayable image specs; inline base64\r\n * blocks (rare in replayed history) become data URLs on the spot. */\r\nfunction imageSpecsOfContent(content: unknown): ImageSpec[] {\r\n const specs: ImageSpec[] = []\r\n if (!Array.isArray(content)) return specs\r\n for (const block of content) {\r\n if (block === null || typeof block !== 'object') continue\r\n const b = block as { type?: unknown; attachment?: unknown; data?: unknown; mediaType?: unknown }\r\n if (b.type !== 'image') continue\r\n const a = b.attachment\r\n if (a !== null && typeof a === 'object') {\r\n const ref = a as { attachmentId?: unknown; mediaType?: unknown }\r\n if (typeof ref.attachmentId === 'string' && ref.attachmentId !== '') {\r\n specs.push({ kind: 'ref', attachmentId: ref.attachmentId, mediaType: typeof ref.mediaType === 'string' ? ref.mediaType : 'image/png' })\r\n continue\r\n }\r\n }\r\n if (typeof b.data === 'string' && b.data !== '') {\r\n specs.push({ kind: 'data', src: `data:${typeof b.mediaType === 'string' ? b.mediaType : 'image/png'};base64,${b.data}` })\r\n }\r\n }\r\n return specs\r\n}\r\n\r\n/** Normalize one projection entry to a rail message. */\r\nfunction normalize(m: unknown): RailMessage | null {\r\n if (m === null || typeof m !== 'object') return null\r\n const o = m as Record<string, unknown>\r\n if (typeof o.seq !== 'number') return null\r\n // Host projection emits `text`; keep `preview` as a defensive fallback in\r\n // case an older projection payload is still cached in the browser.\r\n const text = typeof o.text === 'string' ? o.text : typeof o.preview === 'string' ? o.preview : ''\r\n const images = Array.isArray(o.images)\r\n ? o.images.map(nodeImagesOfEntry).filter((i): i is RailImage => i !== null)\r\n : undefined\r\n return {\r\n seq: o.seq,\r\n time: typeof o.time === 'number' ? o.time : 0,\r\n text,\r\n // Older projections lack hasImage; defensive default false.\r\n hasImage: o.hasImage === true,\r\n ...(images !== undefined && images.length > 0 ? { images } : {}),\r\n ...(typeof o.key === 'string' ? { key: o.key } : {}),\r\n ...(typeof o.id === 'string' ? { id: o.id } : {}),\r\n }\r\n}\r\n\r\n/** Normalize one projection entry of the `images` array (wire form). */\r\nfunction nodeImagesOfEntry(v: unknown): RailImage | null {\r\n if (v === null || typeof v !== 'object') return null\r\n const o = v as Record<string, unknown>\r\n if (typeof o.attachmentId !== 'string' || o.attachmentId === '') return null\r\n return {\r\n attachmentId: o.attachmentId,\r\n mediaType: typeof o.mediaType === 'string' ? o.mediaType : 'image/png',\r\n width: typeof o.width === 'number' ? o.width : 0,\r\n height: typeof o.height === 'number' ? o.height : 0,\r\n }\r\n}\r\n\r\n/** Extract preview text from a user message's ContentBlock list. */\r\nfunction userTextOf(content: unknown): string {\r\n if (!Array.isArray(content)) return ''\r\n let out = ''\r\n for (const block of content) {\r\n if (block !== null && typeof block === 'object' && (block as { type?: unknown }).type === 'text'\r\n && typeof (block as { text?: unknown }).text === 'string') {\r\n out += (block as { text: string }).text\r\n }\r\n }\r\n return out.trim().slice(0, 80)\r\n}\r\n\r\n/** Whether a ContentBlock list carries an image block (rc.8 attachments). */\r\nfunction userHasImage(content: unknown): boolean {\r\n if (!Array.isArray(content)) return false\r\n return content.some(block => block !== null && typeof block === 'object'\r\n && (block as { type?: unknown }).type === 'image')\r\n}\r\n\r\n/** Fallback collector: enumerate user messages from the loaded chat nodes. */\r\nfunction collectFromNodes(snapshot: unknown): RailMessage[] {\r\n const out: RailMessage[] = []\r\n if (snapshot === undefined || (snapshot as { chat?: unknown }).chat === undefined) return out\r\n const chat = (snapshot as { chat: { nodes?: Map<unknown, unknown> } }).chat\r\n if (!chat.nodes) return out\r\n for (const node of chat.nodes.values()) {\r\n if (node === null || typeof node !== 'object') continue\r\n const n = node as { kind?: unknown; key?: unknown; anchorSeq?: unknown; data?: unknown }\r\n if (n.kind !== 'user') continue\r\n const data = n.data as { time?: unknown; content?: unknown } | null\r\n if (data === null || typeof data !== 'object') continue\r\n if (typeof data.time !== 'number' || !Array.isArray(data.content)) continue\r\n const key = typeof n.key === 'string' ? n.key : undefined\r\n if (key === undefined) continue\r\n const images = nodeImagesOf(data.content)\r\n out.push({\r\n seq: typeof n.anchorSeq === 'number' ? n.anchorSeq : 0,\r\n time: data.time,\r\n text: userTextOf(data.content),\r\n hasImage: userHasImage(data.content),\r\n ...(images.length > 0 ? { images } : {}),\r\n key,\r\n })\r\n }\r\n out.sort((a, b) => a.seq - b.seq)\r\n return out\r\n}\r\n\r\n/** Resolve the chat node's data-chat-anchor-key (direct key or id-reconstructed). */\r\nfunction anchorKeyOf(m: RailMessage): string | undefined {\r\n if (typeof m.key === 'string' && m.key !== '') return m.key\r\n if (typeof m.id === 'string' && m.id !== '') return '13:input-message' + m.id\r\n return undefined\r\n}\r\n\r\n/** Full text of a rail message from the loaded chat nodes (uncapped),\r\n * falling back to the projection preview when the node is not mounted. */\r\nfunction fullTextOf(m: RailMessage, nodeSnapshot: unknown): string {\r\n const key = anchorKeyOf(m)\r\n const nodes = (nodeSnapshot as { chat?: { nodes?: Map<string, { data?: { content?: unknown } }> } } | undefined)?.chat?.nodes\r\n const node = key === undefined ? undefined : nodes?.get(key)\r\n const content = node?.data?.content\r\n if (Array.isArray(content)) {\r\n let out = ''\r\n for (const block of content) {\r\n if (block !== null && typeof block === 'object' && (block as { type?: unknown }).type === 'text'\r\n && typeof (block as { text?: unknown }).text === 'string') {\r\n out += (block as { text: string }).text\r\n }\r\n }\r\n const full = out.trim()\r\n if (full !== '') return full\r\n }\r\n return m.text\r\n}\r\n\r\n/** Displayable tip images for one rail message: host projection references\r\n * first (works even before the node window covers the message), then the\r\n * loaded chat node's content blocks. */\r\nfunction tipImagesOf(m: RailMessage, nodeSnapshot: unknown): ImageSpec[] {\r\n if (Array.isArray(m.images) && m.images.length > 0) {\r\n return m.images.map(img => ({ kind: 'ref', attachmentId: img.attachmentId, mediaType: img.mediaType }))\r\n }\r\n const key = anchorKeyOf(m)\r\n const nodes = (nodeSnapshot as { chat?: { nodes?: Map<string, { data?: { content?: unknown } }> } } | undefined)?.chat?.nodes\r\n const node = key === undefined ? undefined : nodes?.get(key)\r\n return imageSpecsOfContent(node?.data?.content)\r\n}\r\n\r\nconst delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))\r\n\r\n/**\r\n * Ensure the message node is loaded into the visible window, then scroll to it.\r\n * loadOlder pages 50 messages at a time (DSH PAGE_MESSAGES), so a jump far back\r\n * in a long session needs many pages. Report page count through onProgress so\r\n * the UI can show a loading state instead of appearing frozen.\r\n *\r\n * The paged-in data lands in the session snapshot synchronously with\r\n * loadOlder's resolution, but the chat rows render through React — the DOM row\r\n * for the target key appears a beat later. We therefore poll for the row (with\r\n * a timeout) instead of querying once and giving up; otherwise a multi-page\r\n * jump would \"finish loading\" without scrolling and need a second click.\r\n */\r\nasync function jumpToMessage(\r\n sessionsService: { binding: (id: string) => { session?: { getSnapshot(): unknown; loadOlder(): Promise<unknown>; hasMore?: boolean; loadingOlder?: boolean } } | undefined },\r\n sessionId: string,\r\n key: string,\r\n onProgress?: (pages: number) => void,\r\n signal?: AbortSignal,\r\n): Promise<boolean> {\r\n const session = sessionsService.binding(sessionId)?.session\r\n if (session === undefined) return false\r\n let pages = 0\r\n let guard = 0\r\n let loaded = false\r\n while (guard++ < 120) {\r\n if (signal?.aborted) return false\r\n const snapshot = session.getSnapshot() as { chat?: { nodes?: Map<string, unknown> }; hasMore?: boolean; loadingOlder?: boolean } | undefined\r\n if (snapshot?.chat?.nodes?.get(key) !== undefined) {\r\n loaded = true\r\n break\r\n }\r\n if (snapshot?.hasMore !== true) break\r\n if (snapshot.loadingOlder === true) {\r\n // Another loader owns the current page; wait for it without busy-spinning.\r\n await delay(50)\r\n continue\r\n }\r\n await session.loadOlder()\r\n pages++\r\n // Report progress only on page boundaries, not every loop iteration.\r\n onProgress?.(pages)\r\n }\r\n // Page guard exhausted (or hasMore ran out) without the node materializing:\r\n // fail fast with context instead of burning the DOM poll below.\r\n if (!loaded) {\r\n console.warn(`[chat-rail] jumpToMessage: node \"${key}\" not loaded after ${pages} page(s)`)\r\n return false\r\n }\r\n const scrollport = typeof document !== 'undefined' ? document.querySelector('[data-conversation-scroll]') : null\r\n if (scrollport === null) return false\r\n // The snapshot is authoritative for \"loaded\", but the row's DOM node only\r\n // appears once React renders the prepended page. Poll for it (up to ~5s) so a\r\n // freshly paged-in jump scrolls on the same click.\r\n let row: Element | null = null\r\n let waited = 0\r\n while (waited++ < 100) {\r\n if (signal?.aborted) return false\r\n row = scrollport.querySelector(`[data-chat-anchor-key=\"${CSS.escape(key)}\"]`)\r\n if (row !== null) break\r\n await delay(50)\r\n }\r\n if (row === null) return false\r\n const reducedMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches\r\n row.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'center' })\r\n return true\r\n}\r\n\r\n// ---- tip image thumbnails ----\r\n\r\n/** Session-scoped browser URL cache for resolved attachment bytes (one entry\r\n * per session:attachment; a failed load evicts itself so the next hover\r\n * retries instead of showing a permanently broken thumbnail). */\r\nconst thumbUrls = new Map<string, Promise<string>>()\r\n\r\n/** Minimal RpcResult shape of session.readAttachment (contract level). */\r\ninterface ReadAttachmentResult {\r\n ok: boolean\r\n value?: { attachment?: { mediaType?: string }; data?: Uint8Array }\r\n error?: { code?: string; message?: string }\r\n}\r\n\r\ntype ReadAttachmentFn = (attachmentId: string) => Promise<ReadAttachmentResult>\r\n\r\n/** Fallback data URL when the browser cannot mint object URLs. */\r\nfunction bytesToDataUrl(data: Uint8Array, mediaType: string): string {\r\n let binary = ''\r\n const chunk = 0x8000\r\n for (let offset = 0; offset < data.length; offset += chunk) {\r\n binary += String.fromCharCode(...data.subarray(offset, offset + chunk))\r\n }\r\n return `data:${mediaType};base64,${btoa(binary)}`\r\n}\r\n\r\n/** Resolve (and cache) one stored image to a displayable URL. */\r\nfunction resolveThumb(sessionId: string, read: ReadAttachmentFn, image: Pick<RailImage, 'attachmentId' | 'mediaType'>): Promise<string> {\r\n const key = `${sessionId}:${image.attachmentId}`\r\n let pending = thumbUrls.get(key)\r\n if (pending === undefined) {\r\n pending = read(image.attachmentId).then((result): string => {\r\n if (!result.ok || result.value === undefined) {\r\n throw new Error(result.error?.message ?? result.error?.code ?? 'readAttachment failed')\r\n }\r\n const data = result.value.data\r\n if (data === undefined) throw new Error('readAttachment resolved no bytes')\r\n const mediaType = result.value.attachment?.mediaType ?? image.mediaType\r\n if (typeof URL.createObjectURL === 'function') {\r\n return URL.createObjectURL(new Blob([data as BlobPart], { type: mediaType }))\r\n }\r\n return bytesToDataUrl(data, mediaType)\r\n })\r\n void pending.catch(() => { thumbUrls.delete(key) })\r\n thumbUrls.set(key, pending)\r\n }\r\n return pending\r\n}\r\n\r\n/** One tip thumbnail: resolves its attachment lazily, shows a pulsing\r\n * placeholder while loading, and removes itself on failure. */\r\nfunction TipThumb({ spec, sessionId, read }: {\r\n spec: ImageSpec\r\n sessionId: string\r\n read: ReadAttachmentFn\r\n}): ReactNode {\r\n const [src, setSrc] = useState<string | null>(null)\r\n const [failed, setFailed] = useState(false)\r\n const specKey = spec.kind === 'ref' ? `ref:${spec.attachmentId}` : 'data'\r\n useEffect(() => {\r\n let alive = true\r\n setSrc(null)\r\n setFailed(false)\r\n if (spec.kind === 'data') {\r\n setSrc(spec.src)\r\n return () => { alive = false }\r\n }\r\n resolveThumb(sessionId, read, spec)\r\n .then((url) => { if (alive) setSrc(url) })\r\n .catch(() => { if (alive) setFailed(true) })\r\n return () => { alive = false }\r\n // spec is a fresh object each render; the string key is the stable identity.\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [specKey, sessionId, read])\r\n if (failed) return null\r\n if (src === null) return createElement('div', { className: S.tipImgPh, 'aria-hidden': true })\r\n return createElement('img', { className: S.tipImg, src, alt: '', draggable: false })\r\n}\r\n\r\n// ---- component ----\r\ninterface TimelineRailProps {\r\n useProjection: (key: string) => { messages?: unknown[] } | undefined\r\n sessionId?: SessionId\r\n sessionsService: ISessions\r\n /** Draft write + attachment-add face (session-scope framework injection). */\r\n inputActions?: {\r\n setDraft(text: string): void\r\n addImages(ids: readonly string[]): boolean\r\n } | undefined\r\n /** Runtime draft-image registry (hosted by ui-conversation). */\r\n conversation?: {\r\n createDraftImages(files: readonly File[]): readonly { id: string }[]\r\n } | undefined\r\n}\r\n\r\n/** Resolve copy for the current UI language (document lang, DSH sets zh/en). */\r\nfunction langStrings(): Record<string, string> {\r\n const lang = typeof document !== 'undefined' ? (document.documentElement.lang || 'zh').toLowerCase() : 'zh'\r\n return STRINGS[lang.startsWith('zh') ? 'zh' : 'en']\r\n}\r\n\r\nfunction TimelineRail({ useProjection, sessionId, sessionsService, inputActions, conversation }: TimelineRailProps): ReactNode {\r\n const t = langStrings()\r\n const projected = useProjection('chatRail')\r\n const session = sessionId === undefined ? undefined : (sessionsService.binding(sessionId) as { session?: { subscribe(cb: () => void): () => void; getSnapshot(): unknown } } | undefined)?.session\r\n const fallbackStore = session === undefined ? NOOP_STORE : session\r\n const nodeSnapshot = useSyncExternalStore(\r\n (cb) => fallbackStore.subscribe(cb),\r\n () => fallbackStore.getSnapshot(),\r\n )\r\n\r\n let messages: RailMessage[] = []\r\n if (Array.isArray(projected?.messages) && projected.messages.length > 0) {\r\n messages = projected.messages.map(normalize).filter((m): m is RailMessage => m !== null)\r\n }\r\n if (messages.length === 0) {\r\n messages = collectFromNodes(nodeSnapshot)\r\n }\r\n\r\n // Favorites: per-session persisted set + a \"bookmarks only\" filter that\r\n // narrows the rail to favorited messages (mirrors dsh-milestone's\r\n // bookmarksOnly toggle; the DOM-injected buttons share the same store).\r\n const favorites = useSyncExternalStore(subscribeFavorites, favoritesSnapshot)\r\n const [favOnly, setFavOnly] = useState(false)\r\n // Count of favorited messages in THIS session. The filter toggle is only\r\n // worth showing when a favorite exists — with zero favorites there is\r\n // nothing to filter to, so the pill disappears entirely.\r\n const favCount = sessionId === undefined\r\n ? 0\r\n : messages.filter((m) => isFavorite(favorites, sessionId, favoriteIdOfMessage(m))).length\r\n const showFavToggle = favCount > 0\r\n const effectiveMessages = favOnly\r\n ? messages.filter((m) => sessionId !== undefined && isFavorite(favorites, sessionId, favoriteIdOfMessage(m)))\r\n : messages\r\n\r\n // If the filter is armed and the last favorite is un-starred (or the\r\n // session switched), the filtered list would be empty AND the toggle would\r\n // vanish — leaving a rail with nothing to un-filter. Auto-release the\r\n // filter instead so the full list returns.\r\n useEffect(() => {\r\n if (favOnly && !showFavToggle) setFavOnly(false)\r\n }, [favOnly, showFavToggle])\r\n\r\n const [activeIndex, setActiveIndex] = useState(-1)\r\n const [show, setShow] = useState(false)\r\n const [jumping, setJumping] = useState(false)\r\n // Hover full-content panel: index of the hovered item + fixed position.\r\n // The tip only triggers after the expand animation settles — in the\r\n // collapsed state the item rect is only 36px wide, so a tip positioned\r\n // there would be wrong once the rail expands.\r\n const [tip, setTip] = useState<{ index: number; x: number; y: number } | null>(null)\r\n const navRef = useRef<HTMLDivElement | null>(null) // True only after the expand animation has fully settled; the width\r\n // transition takes ~250ms after `show` flips, and item rects are only\r\n // stable once it finishes. Tip positioning must wait for this.\r\n const expandedRef = useRef(false)\r\n // Last observed pointer position over the rail. A mouseenter that arrives\r\n // before the width transition cannot show the tip (rects unstable), yet the\r\n // enter event is already gone by the time the animation ends — the settle\r\n // handler re-hit-tests the cursor against this position to show the tip for\r\n // the node still under the pointer.\r\n const lastPointerRef = useRef<{ x: number; y: number } | null>(null)\r\n // Aborts an in-flight jump (component unmount or a newer click superseding\r\n // an older one), so the loadOlder/DOM-poll loops stop promptly.\r\n const jumpAbortRef = useRef<AbortController | null>(null)\r\n useEffect(() => () => jumpAbortRef.current?.abort(), [])\r\n\r\n // Track the cursor over the rail for the expand-settled tip re-hit-test\r\n // (the `show` effect's settle handler reads lastPointerRef). Native\r\n // listener: plain DOM events keep the callback typed without React\r\n // synthetic-event generics in the hand-written createElement props.\r\n useEffect(() => {\r\n const el = navRef.current\r\n if (el === null) return\r\n const onMove = (e: PointerEvent) => { lastPointerRef.current = { x: e.clientX, y: e.clientY } }\r\n el.addEventListener('pointermove', onMove, { passive: true })\r\n return () => el.removeEventListener('pointermove', onMove)\r\n }, [])\r\n\r\n /** Position the tip against the item's CURRENT (post-expand) rect. */\r\n const positionTip = (index: number) => {\r\n if (index < 0) return\r\n const el = navRef.current?.querySelector<HTMLElement>(`[data-crl-index=\"${index}\"]`)\r\n if (el === null || el === undefined) return\r\n const rect = el.getBoundingClientRect()\r\n setTip({ index, x: rect.left - 12, y: rect.top })\r\n }\r\n\r\n /** Handle item hover: the collapsed state only expands the rail; the tip\r\n * only triggers once the expand animation has settled (item rects are then\r\n * stable, so the tip is positioned correctly). */\r\n const handleItemEnter = (index: number) => {\r\n if (!expandedRef.current) {\r\n setShow(true)\r\n return\r\n }\r\n positionTip(index)\r\n }\r\n\r\n const handleItemLeave = (index: number) => {\r\n setTip((prev) => (prev?.index === index ? null : prev))\r\n }\r\n\r\n // Track the settled expand state: when `show` flips true, wait for the width\r\n // transition to end before allowing tip triggers; when it flips false, the\r\n // rail is collapsing — treat as unexpanded immediately.\r\n useEffect(() => {\r\n if (!show) {\r\n expandedRef.current = false\r\n return\r\n }\r\n const el = navRef.current\r\n if (el === null) return\r\n let settled = false\r\n const settle = () => {\r\n if (settled) return\r\n settled = true\r\n expandedRef.current = true\r\n el.removeEventListener('transitionend', onTransitionEnd)\r\n clearTimeout(timer)\r\n // Re-hit-test the cursor: a mouseenter that fired before the width\r\n // transition could not show the tip (rects were still unstable), and\r\n // the enter event is long gone once the animation ends. If the pointer\r\n // still sits on a rail item, position that item's tip now.\r\n const p = lastPointerRef.current\r\n if (p !== null) {\r\n const hit = document.elementFromPoint(p.x, p.y)\r\n const item = hit === null ? null : hit.closest('[data-crl-index]')\r\n const idx = item === null ? -1 : Number(item.getAttribute('data-crl-index'))\r\n if (Number.isInteger(idx) && idx >= 0) positionTip(idx)\r\n }\r\n }\r\n const onTransitionEnd = (e: TransitionEvent) => {\r\n if (e.propertyName !== 'width') return\r\n settle()\r\n }\r\n el.addEventListener('transitionend', onTransitionEnd)\r\n // Fallback for `prefers-reduced-motion: reduce` (the stylesheet disables\r\n // the width transition, so `transitionend` never fires) and for any lost\r\n // event: settle after the transition duration regardless.\r\n const timer = setTimeout(settle, 300)\r\n return () => {\r\n el.removeEventListener('transitionend', onTransitionEnd)\r\n clearTimeout(timer)\r\n }\r\n }, [show])\r\n\r\n // Session-scoped attachment reader for tip thumbnails (stable identity so\r\n // TipThumb effects do not re-run every render; resolveThumb caches anyway).\r\n const readThumb = useMemo<ReadAttachmentFn | undefined>(() => {\r\n if (sessionId === undefined) return undefined\r\n const s = (sessionsService.binding(sessionId) as unknown as\r\n { session?: { readAttachment?: ReadAttachmentFn } } | undefined)?.session\r\n return s?.readAttachment === undefined\r\n ? undefined\r\n : (id: string) => s.readAttachment!(id)\r\n }, [sessionId, sessionsService])\r\n\r\n // Fill-to-input (plus button): put the message's full text into the draft\r\n // and re-attach its stored images through the official draft-image path\r\n // (readAttachment → File → createDraftImages → addImages). Degrades to\r\n // text-only when the conversation service or storage read is unavailable.\r\n const fillToInput = useCallback((messageId: string) => {\r\n const m = messages.find((candidate) => favoriteIdOfMessage(candidate) === messageId)\r\n if (m === undefined || inputActions === undefined || sessionId === undefined) return\r\n const text = fullTextOf(m, nodeSnapshot) || m.text\r\n if (inputActions.setDraft !== undefined) inputActions.setDraft(text)\r\n const specs = tipImagesOf(m, nodeSnapshot).filter((spec): spec is ImageSpec & { kind: 'ref' } => spec.kind === 'ref')\r\n if (specs.length === 0 || conversation === undefined || readThumb === undefined) return\r\n void (async () => {\r\n const files: File[] = []\r\n for (const spec of specs) {\r\n try {\r\n const result = await readThumb(spec.attachmentId) as unknown as { ok?: boolean; value?: { attachment?: { mediaType?: string }; data?: Uint8Array } }\r\n if (result?.ok !== true || result.value?.data === undefined) continue\r\n const mediaType = result.value.attachment?.mediaType ?? spec.mediaType\r\n files.push(new File([result.value.data as BlobPart], `attachment-${spec.attachmentId}${mediaType.includes('jpeg') ? '.jpg' : '.png'}`, { type: mediaType }))\r\n } catch {\r\n // One failed attachment must not block the others.\r\n }\r\n }\r\n if (files.length === 0) return\r\n const drafts = conversation.createDraftImages(files)\r\n const ids = drafts.map((draft) => draft.id).filter((id): id is string => id !== undefined)\r\n if (ids.length > 0) {\r\n try { inputActions.addImages?.(ids) } catch { /* draft-image registry may reject; text fill already landed */ }\r\n }\r\n })()\r\n }, [conversation, inputActions, messages, nodeSnapshot, readThumb, sessionId])\r\n\r\n // Bridge the DOM-injected buttons (module-level actionCtx) to this\r\n // component's live closures + session id; refresh star states on every\r\n // favorites change and on session switch.\r\n useEffect(() => {\r\n actionCtx.sessionId = sessionId\r\n actionCtx.onToggleFavorite = (messageId) => {\r\n if (sessionId !== undefined) toggleFavorite(sessionId, messageId)\r\n }\r\n actionCtx.onFill = fillToInput\r\n refreshAllFavoriteButtons(t)\r\n return () => {\r\n // Only clear what this render set: a newer session may already own the ctx.\r\n if (actionCtx.sessionId === sessionId) actionCtx.sessionId = undefined\r\n }\r\n }, [sessionId, fillToInput, t])\r\n\r\n // Keep the injected DOM buttons in sync with external favorite toggles\r\n // (rail star / another session's store write).\r\n useEffect(() => {\r\n refreshAllFavoriteButtons(t)\r\n }, [favorites, t])\r\n\r\n // Inject star + plus buttons into every user message row; observe DOM\r\n // changes so history paging and re-renders keep them present.\r\n useEffect(() => {\r\n if (typeof document === 'undefined' || document.body === null) return\r\n return startActionInjector(t)\r\n }, [t])\r\n\r\n // Background full-history load: follow the runtime's authoritative hasMore\r\n // flag, but STOP as soon as the projection delivers.\r\n useEffect(() => {\r\n if (session === undefined) return\r\n if (Array.isArray(projected?.messages) && projected.messages.length > 0) return\r\n let cancelled = false\r\n const run = async () => {\r\n let guard = 0\r\n while (!cancelled && guard++ < 120) {\r\n if (Array.isArray(projected?.messages) && projected.messages.length > 0) return\r\n const snap = session.getSnapshot() as { hasMore?: boolean; loadingOlder?: boolean }\r\n if (snap?.hasMore !== true) return\r\n if (snap.loadingOlder === true) { await delay(50); continue }\r\n await (session as unknown as { loadOlder(): Promise<unknown> }).loadOlder()\r\n }\r\n }\r\n run().catch(() => {})\r\n return () => { cancelled = true }\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [sessionId, session === undefined ? 'none' : 'ready', Array.isArray(projected?.messages) && projected.messages.length > 0 ? 'have' : 'none'])\r\n\r\n // Track the reading position (active item): nearest row to the 40% viewport line.\r\n useEffect(() => {\r\n if (effectiveMessages.length === 0) return\r\n const indexByKey = new Map<string, number>()\r\n for (let i = 0; i < effectiveMessages.length; i++) {\r\n const key = anchorKeyOf(effectiveMessages[i])\r\n if (key !== undefined) indexByKey.set(key, i)\r\n }\r\n const updateActive = () => {\r\n const sp = document.querySelector('[data-conversation-scroll]')\r\n if (sp === null) return\r\n const rect = sp.getBoundingClientRect()\r\n if (rect.height === 0) return\r\n const line = rect.top + rect.height * 0.4\r\n const rows = sp.querySelectorAll('[data-chat-anchor-key^=\"13:input-message\"]')\r\n let best = -1\r\n let bestDist = Infinity\r\n for (const row of rows) {\r\n const key = row.getAttribute('data-chat-anchor-key')\r\n if (key === null) continue\r\n const idx = indexByKey.get(key) ?? -1\r\n if (idx === -1) continue\r\n const r = row.getBoundingClientRect()\r\n const dist = Math.abs(r.top + r.height / 2 - line)\r\n if (dist < bestDist) { bestDist = dist; best = idx }\r\n }\r\n setActiveIndex(best)\r\n }\r\n updateActive()\r\n const el = document.querySelector('[data-conversation-scroll]')\r\n let scrollTimer: ReturnType<typeof setTimeout> | null = null\r\n const onScroll = () => {\r\n if (scrollTimer !== null) return\r\n scrollTimer = setTimeout(() => { scrollTimer = null; updateActive() }, 60)\r\n }\r\n el?.addEventListener('scroll', onScroll, { passive: true })\r\n const timer = window.setInterval(updateActive, 2000)\r\n return () => {\r\n el?.removeEventListener('scroll', onScroll)\r\n window.clearInterval(timer)\r\n if (scrollTimer !== null) clearTimeout(scrollTimer)\r\n }\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [sessionId, effectiveMessages.length])\r\n\r\n // Scroll the rail so the active item stays centered in its visible area:\r\n // follows the conversation's reading position (and re-centers after a\r\n // session switch, where activeIndex is recomputed for the new session).\r\n useEffect(() => {\r\n if (activeIndex < 0) return\r\n const el = navRef.current\r\n if (el === null) return\r\n const item = el.querySelector<HTMLElement>(`[data-crl-index=\"${activeIndex}\"]`)\r\n if (item === null) return\r\n const target = item.offsetTop - el.clientHeight / 2 + item.clientHeight / 2\r\n el.scrollTo({ top: Math.max(0, target), behavior: 'smooth' })\r\n }, [activeIndex])\r\n\r\n // The rail hides on tiny sessions (fewer than 2 user messages) — but the\r\n // count is the UNFILTERED list: the favorites-only filter may legitimately\r\n // narrow to a single row, and hiding the rail then would strand the user\r\n // with no way to switch the filter back off.\r\n if (sessionId === undefined || messages.length < 2) return null\r\n\r\n // Fav-only filter remaps the tip index into the FULL list (tip content and\r\n // hover data always come from the unfiltered messages array).\r\n const tipIndex = tip === null\r\n ? -1\r\n : favOnly\r\n ? messages.findIndex((m) => effectiveMessages[tip.index] !== undefined\r\n && favoriteIdOfMessage(m) === favoriteIdOfMessage(effectiveMessages[tip.index]))\r\n : tip.index\r\n\r\n const items = effectiveMessages.map((m, i) => {\r\n const key = anchorKeyOf(m)\r\n const starred = favoriteIdOfMessage(m) !== '' && isFavorite(favorites, sessionId, favoriteIdOfMessage(m))\r\n return createElement('button', {\r\n type: 'button',\r\n key: m.seq,\r\n 'data-crl-index': String(i),\r\n className: S.item + (activeIndex === i ? ` ${S.itemActive}` : '') + (starred ? ` ${S.favItem}` : ''),\r\n 'aria-label': `${t.roleUser}: ${m.text.slice(0, 60) || t.noText} (${t.ariaJump})`,\r\n 'aria-current': activeIndex === i ? 'location' : undefined,\r\n disabled: jumping,\r\n onClick: () => {\r\n if (key === undefined || jumping) return\r\n jumpAbortRef.current?.abort()\r\n const controller = new AbortController()\r\n jumpAbortRef.current = controller\r\n setJumping(true)\r\n void jumpToMessage(sessionsService as never, sessionId as string, key, undefined, controller.signal)\r\n .finally(() => setJumping(false))\r\n },\r\n onMouseEnter: () => handleItemEnter(i),\r\n onMouseLeave: () => handleItemLeave(i),\r\n children: [\r\n createElement('span', { className: S.num }, `#${i + 1}`),\r\n starred\r\n ? createElement('span', { className: S.favStar, role: 'img', 'aria-label': t.fav, 'aria-hidden': undefined },\r\n createElement('svg', {\r\n viewBox: '0 0 24 24',\r\n width: 13,\r\n height: 13,\r\n fill: 'currentColor',\r\n stroke: 'none',\r\n 'aria-hidden': true,\r\n },\r\n createElement('path', { d: STAR_PATH, 'aria-hidden': true })))\r\n : null,\r\n createElement('span', { className: S.title }, m.text === '' ? t.noText : m.text),\r\n m.hasImage\r\n ? createElement('span', { className: S.img, role: 'img', 'aria-label': t.hasImage },\r\n createElement('svg', {\r\n viewBox: '0 0 16 16',\r\n width: 12,\r\n height: 12,\r\n fill: 'none',\r\n stroke: 'currentColor',\r\n strokeWidth: 1.4,\r\n strokeLinecap: 'round',\r\n strokeLinejoin: 'round',\r\n 'aria-hidden': true,\r\n },\r\n createElement('rect', { x: 1.7, y: 2.7, width: 12.6, height: 10.6, rx: 2.2 }),\r\n createElement('circle', { cx: 5.6, cy: 6.4, r: 1.15, fill: 'currentColor', stroke: 'none' }),\r\n createElement('path', { d: 'M2.5 11.6l3.6-3.1 2.6 2.2 2.1-1.9 3.3 2.8' })))\r\n : null,\r\n createElement('span', { className: S.time }, relativeTime(m.time, t)),\r\n createElement('span', { className: S.ind, 'aria-hidden': true },\r\n createElement('span', { className: S.line })),\r\n ],\r\n })\r\n })\r\n\r\n return createPortal(\r\n // Fragment: the nav (transformed) and the tip must be siblings — a fixed\r\n // child inside a transformed ancestor is positioned relative to that\r\n // ancestor, breaking the viewport coordinates the tip uses.\r\n [createElement('div', {\r\n ref: navRef,\r\n className: S.nav + (show ? ` ${S.navShow}` : ''),\r\n role: 'navigation',\r\n 'aria-label': t.railLabel,\r\n onMouseEnter: () => setShow(true),\r\n onMouseLeave: () => setShow(false),\r\n children: [\r\n // Favorites-only filter: a star pill pinned at the top of the rail\r\n // (sticky; it never scrolls away with the rows). On state fills the\r\n // star (yellow) and narrows the rail to favorited messages; the label\r\n // (\"bookmarks only\") only shows once the rail expands — the collapsed\r\n // 36px capsule keeps a clean round icon. Hidden entirely when this\r\n // session has no favorites (nothing to filter to).\r\n showFavToggle\r\n ? createElement('button', {\r\n type: 'button',\r\n key: 'favToggle',\r\n className: S.favToggle + (favOnly ? ' crl_on' : ''),\r\n 'aria-pressed': favOnly,\r\n 'aria-label': t.favOnly,\r\n title: t.favOnly,\r\n onMouseEnter: (e: MouseEvent) => e.stopPropagation(),\r\n onClick: () => setFavOnly((v) => !v),\r\n children: [\r\n createElement('svg', {\r\n viewBox: '0 0 24 24',\r\n width: 14,\r\n height: 14,\r\n fill: favOnly ? 'currentColor' : 'none',\r\n stroke: 'currentColor',\r\n strokeWidth: 2,\r\n strokeLinejoin: 'round',\r\n 'aria-hidden': true,\r\n }, createElement('path', { d: STAR_PATH })),\r\n createElement('span', { className: S.favToggleLabel }, t.favOnly),\r\n ],\r\n })\r\n : null,\r\n jumping ? createElement('div', { className: S.loading, key: 'loading' },\r\n createElement('span', { className: S.loadingLabel }, t.loading)) : null,\r\n ...items,\r\n ],\r\n }),\r\n // Full-content panel: anchored to the left of the hovered item. Images\r\n // stack above the text; a message whose image cannot be resolved (no\r\n // projection refs and unloaded node) shows a quiet \"has image\" badge.\r\n tip !== null && tipIndex >= 0 && tipIndex < messages.length\r\n ? createElement('div', {\r\n className: S.tip,\r\n style: { left: `${tip.x}px`, top: `${tip.y}px`, transform: 'translateX(-100%)' },\r\n }, (() => {\r\n const m = messages[tipIndex]\r\n const specs = tipImagesOf(m, nodeSnapshot)\r\n const children: ReactNode[] = []\r\n if (specs.length > 0 && readThumb !== undefined) {\r\n // Only the first image renders; the rest collapse into a \"+N\"\r\n // badge so a many-image message cannot push the tip past its\r\n // viewport cap (the stacked thumbnails did).\r\n children.push(createElement('div', { className: S.tipImgs, key: 'imgs' },\r\n createElement('div', { className: S.tipImgWrap, key: 'img' },\r\n createElement(TipThumb, { spec: specs[0], sessionId: sessionId as string, read: readThumb }),\r\n specs.length > 1\r\n ? createElement('span', { className: S.tipImgCount, 'aria-hidden': true }, `+${specs.length - 1}`)\r\n : null)))\r\n } else if (m.hasImage) {\r\n children.push(createElement('span', { className: S.tipBadge, key: 'badge' }, t.hasImage))\r\n }\r\n children.push(createElement('span', { key: 'text' }, fullTextOf(m, nodeSnapshot) || t.noText))\r\n return children\r\n })())\r\n : null],\r\n document.body,\r\n )\r\n}\r\n\r\nfunction apply(ctx: ClientContext): void {\r\n ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({\r\n name: 'conversation.input.dock',\r\n id: 'chat-rail',\r\n order: 40,\r\n inject: () => ({\r\n sessionsService: ctx.sessions,\r\n // createDraftImages lives on the concrete ConversationController, not\r\n // the outward IConversation face; the runtime service is always the\r\n // controller, so the cast is structural, never a feature guess.\r\n conversation: ctx.conversation as unknown as { createDraftImages(files: readonly File[]): readonly { id: string }[] },\r\n }),\r\n }, TimelineRail))\r\n}\r\n\r\nexport { apply, TimelineRail, imageSpecsOfContent, normalize }\r\n"],"mappings":"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"}
package/lib/index.mjs CHANGED
@@ -1,7 +1,9 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, join } from "node:path";
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
4
  //#region src/index.ts
2
5
  /**
3
6
  * @max-null/dsh-chat-rail — host half.
4
- *
5
7
  * Registers the `chatRail` session projection unit: a complete, durable
6
8
  * enumeration of the session's USER-sent messages (seq / time / preview /
7
9
  * durable message id). The client rail only needs user turns; assistant
@@ -85,10 +87,75 @@ const Config = { "~standard": {
85
87
  vendor: "chat-rail",
86
88
  validate: (value) => ({ value: value ?? {} })
87
89
  } };
90
+ const FAVORITES_PATH = join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "chat-rail-favorites.json");
91
+ const FAVORITES_ROUTE_PREFIX = "/chat-rail/api/favorites";
92
+ function readFavoritesFile() {
93
+ try {
94
+ const parsed = JSON.parse(readFileSync(FAVORITES_PATH, "utf8"));
95
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
96
+ } catch {
97
+ return {};
98
+ }
99
+ }
100
+ function writeFavoritesFile(map) {
101
+ try {
102
+ mkdirSync(dirname(FAVORITES_PATH), { recursive: true });
103
+ writeFileSync(FAVORITES_PATH, JSON.stringify(map), "utf8");
104
+ } catch {}
105
+ }
106
+ function sendJson(res, status, body) {
107
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
108
+ res.end(JSON.stringify(body));
109
+ }
110
+ const favoritesRouteDefinition = {
111
+ kind: "exact",
112
+ path: FAVORITES_ROUTE_PREFIX,
113
+ handler: async (req, res) => {
114
+ if (req.method === "GET") {
115
+ sendJson(res, 200, {
116
+ ok: true,
117
+ value: readFavoritesFile()
118
+ });
119
+ return;
120
+ }
121
+ if (req.method === "PUT") {
122
+ let raw = "";
123
+ req.on("data", (chunk) => {
124
+ raw += chunk;
125
+ });
126
+ await new Promise((resolve) => req.on("end", () => resolve()));
127
+ try {
128
+ const map = JSON.parse(raw)?.favorites;
129
+ if (map === null || typeof map !== "object" || Array.isArray(map)) {
130
+ sendJson(res, 400, {
131
+ ok: false,
132
+ error: "bad-request"
133
+ });
134
+ return;
135
+ }
136
+ writeFavoritesFile(map);
137
+ sendJson(res, 200, { ok: true });
138
+ } catch {
139
+ sendJson(res, 400, {
140
+ ok: false,
141
+ error: "bad-request"
142
+ });
143
+ }
144
+ return;
145
+ }
146
+ sendJson(res, 405, {
147
+ ok: false,
148
+ error: "method-not-allowed"
149
+ });
150
+ }
151
+ };
88
152
  function apply(ctx) {
89
- ctx.inject(["sessionProjections"], (projectionCtx) => {
153
+ ctx.inject(["sessionProjections"], ((projectionCtx) => {
90
154
  projectionCtx.sessionProjections.register(messageIndexProjectionDefinition);
91
- });
155
+ }));
156
+ ctx.inject(["webServer"], ((wsCtx) => {
157
+ wsCtx.webServer.register(favoritesRouteDefinition);
158
+ }));
92
159
  }
93
160
  //#endregion
94
161
  export { Config, apply, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-chat-rail",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "画卷式消息导航栏:右侧竖条 hover 展开画卷,scroll-spy 高亮居中跟随,编号+时间+完整预览,与 better-sidebar 双面板动画同步避让",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",
@@ -63,6 +63,7 @@
63
63
  "react-dom": "^18.2.0"
64
64
  },
65
65
  "devDependencies": {
66
+ "@deepseek-ai/cordis": "^4.0.1",
66
67
  "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.1",
67
68
  "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.1",
68
69
  "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.1",
@@ -72,12 +73,12 @@
72
73
  "@types/react": "~18.3.1",
73
74
  "@types/react-dom": "^18.3.7",
74
75
  "cordis": "^4.0.0-rc.7",
76
+ "playwright-core": "1.57.0",
75
77
  "react": "^18.2.0",
76
78
  "react-dom": "^18.2.0",
77
79
  "tsdown": "^0.22.2",
78
80
  "tsx": "^4.0.0",
79
81
  "typescript": "^5.6.0",
80
- "unrun": "^0.3.1",
81
- "@deepseek-ai/cordis": "^4.0.1"
82
+ "unrun": "^0.3.1"
82
83
  }
83
84
  }
Binary file
Binary file