@max-null/dsh-chat-rail 0.2.0 → 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/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). */
@@ -98,8 +112,9 @@ window.__ModuleLoader__.load({
98
112
  "@keyframes crl-fade{from{opacity:0}to{opacity:1}}",
99
113
  ".crl_ind{flex-shrink:0;display:flex;justify-content:center;align-items:center;width:18px;height:20px}",
100
114
  ".crl_show .crl_ind{margin-left:8px}",
101
- ".crl_img{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:12px;height:12px;margin-left:6px;border:1px solid currentColor;border-radius:2px;opacity:.65}",
102
- ".crl_img::before{content:\"\";width:6px;height:6px;border:1px solid currentColor;border-radius:1px;background:radial-gradient(circle at 30% 30%,currentColor 0 1px,transparent 1.5px)}",
115
+ ".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)}",
116
+ ".crl_show .crl_img{display:inline-flex;animation:crl-fade .18s ease}",
117
+ "body[data-ds-dark-theme] .crl_img,[data-theme='dark'] .crl_img,.dark .crl_img{color:rgba(255,255,255,.38)}",
103
118
  ".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}",
104
119
  ".crl_item:hover .crl_line{background-color:rgba(0,0,0,.9)}",
105
120
  ".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)}",
@@ -108,7 +123,38 @@ window.__ModuleLoader__.load({
108
123
  "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)}",
109
124
  ".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}",
110
125
  "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)))}",
111
- "@media (prefers-reduced-motion:reduce){.crl_nav,.crl_title,.crl_num,.crl_time,.crl_line{transition:none}}"
126
+ ".crl_tipImgs{display:flex;flex-direction:column;gap:6px;margin-bottom:8px}",
127
+ ".crl_tipImgWrap{position:relative;width:fit-content;max-width:100%;max-height:150px}",
128
+ ".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)))}",
129
+ ".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}",
130
+ ".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}",
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)))}",
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)))}",
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)}",
157
+ "@media (prefers-reduced-motion:reduce){.crl_nav,.crl_title,.crl_num,.crl_time,.crl_line{transition:none}.crl_tipImgPh{animation:none}}"
112
158
  ].join("");
113
159
  const STYLE_ID = "@max-null/dsh-chat-rail/styles.module.css";
114
160
  if (typeof document !== "undefined" && document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) === null) {
@@ -131,23 +177,281 @@ window.__ModuleLoader__.load({
131
177
  img: "crl_img",
132
178
  loading: "crl_loading",
133
179
  loadingLabel: "crl_loadingLabel",
134
- tip: "crl_tip"
180
+ tip: "crl_tip",
181
+ tipImgs: "crl_tipImgs",
182
+ tipImgWrap: "crl_tipImgWrap",
183
+ tipImg: "crl_tipImg",
184
+ tipImgCount: "crl_tipImgCount",
185
+ tipImgPh: "crl_tipImgPh",
186
+ tipBadge: "crl_tipBadge",
187
+ favStar: "crl_favStar",
188
+ favItem: "crl_favItem",
189
+ favToggle: "crl_favToggle",
190
+ favToggleLabel: "crl_favToggleLabel"
135
191
  };
136
192
  const NOOP_STORE = {
137
193
  getSnapshot: () => void 0,
138
194
  subscribe: () => () => {}
139
195
  };
140
- /** Extract preview text from a user message's ContentBlock list. */
141
- function userTextOf(content) {
142
- if (!Array.isArray(content)) return "";
143
- let out = "";
144
- for (const block of content) if (block !== null && typeof block === "object" && block.type === "text" && typeof block.text === "string") out += block.text;
145
- return out.trim().slice(0, 80);
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
+ }
146
212
  }
147
- /** Whether a ContentBlock list carries an image block (rc.8 attachments). */
148
- function userHasImage(content) {
149
- if (!Array.isArray(content)) return false;
150
- return content.some((block) => block !== null && typeof block === "object" && block.type === "image");
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
+ }
407
+ /** Extract stored-image references from a ContentBlock list (reference form). */
408
+ function nodeImagesOf(content) {
409
+ if (!Array.isArray(content)) return [];
410
+ const out = [];
411
+ for (const block of content) {
412
+ if (block === null || typeof block !== "object") continue;
413
+ const b = block;
414
+ if (b.type !== "image") continue;
415
+ const a = b.attachment;
416
+ if (a === null || typeof a !== "object") continue;
417
+ const ref = a;
418
+ if (typeof ref.attachmentId !== "string" || ref.attachmentId === "") continue;
419
+ out.push({
420
+ attachmentId: ref.attachmentId,
421
+ mediaType: typeof ref.mediaType === "string" ? ref.mediaType : "image/png",
422
+ width: typeof ref.width === "number" ? ref.width : 0,
423
+ height: typeof ref.height === "number" ? ref.height : 0
424
+ });
425
+ }
426
+ return out;
427
+ }
428
+ /** Convert a ContentBlock list to displayable image specs; inline base64
429
+ * blocks (rare in replayed history) become data URLs on the spot. */
430
+ function imageSpecsOfContent(content) {
431
+ const specs = [];
432
+ if (!Array.isArray(content)) return specs;
433
+ for (const block of content) {
434
+ if (block === null || typeof block !== "object") continue;
435
+ const b = block;
436
+ if (b.type !== "image") continue;
437
+ const a = b.attachment;
438
+ if (a !== null && typeof a === "object") {
439
+ const ref = a;
440
+ if (typeof ref.attachmentId === "string" && ref.attachmentId !== "") {
441
+ specs.push({
442
+ kind: "ref",
443
+ attachmentId: ref.attachmentId,
444
+ mediaType: typeof ref.mediaType === "string" ? ref.mediaType : "image/png"
445
+ });
446
+ continue;
447
+ }
448
+ }
449
+ if (typeof b.data === "string" && b.data !== "") specs.push({
450
+ kind: "data",
451
+ src: `data:${typeof b.mediaType === "string" ? b.mediaType : "image/png"};base64,${b.data}`
452
+ });
453
+ }
454
+ return specs;
151
455
  }
152
456
  /** Normalize one projection entry to a rail message. */
153
457
  function normalize(m) {
@@ -155,15 +459,41 @@ window.__ModuleLoader__.load({
155
459
  const o = m;
156
460
  if (typeof o.seq !== "number") return null;
157
461
  const text = typeof o.text === "string" ? o.text : typeof o.preview === "string" ? o.preview : "";
462
+ const images = Array.isArray(o.images) ? o.images.map(nodeImagesOfEntry).filter((i) => i !== null) : void 0;
158
463
  return {
159
464
  seq: o.seq,
160
465
  time: typeof o.time === "number" ? o.time : 0,
161
466
  text,
162
467
  hasImage: o.hasImage === true,
468
+ ...images !== void 0 && images.length > 0 ? { images } : {},
163
469
  ...typeof o.key === "string" ? { key: o.key } : {},
164
470
  ...typeof o.id === "string" ? { id: o.id } : {}
165
471
  };
166
472
  }
473
+ /** Normalize one projection entry of the `images` array (wire form). */
474
+ function nodeImagesOfEntry(v) {
475
+ if (v === null || typeof v !== "object") return null;
476
+ const o = v;
477
+ if (typeof o.attachmentId !== "string" || o.attachmentId === "") return null;
478
+ return {
479
+ attachmentId: o.attachmentId,
480
+ mediaType: typeof o.mediaType === "string" ? o.mediaType : "image/png",
481
+ width: typeof o.width === "number" ? o.width : 0,
482
+ height: typeof o.height === "number" ? o.height : 0
483
+ };
484
+ }
485
+ /** Extract preview text from a user message's ContentBlock list. */
486
+ function userTextOf(content) {
487
+ if (!Array.isArray(content)) return "";
488
+ let out = "";
489
+ for (const block of content) if (block !== null && typeof block === "object" && block.type === "text" && typeof block.text === "string") out += block.text;
490
+ return out.trim().slice(0, 80);
491
+ }
492
+ /** Whether a ContentBlock list carries an image block (rc.8 attachments). */
493
+ function userHasImage(content) {
494
+ if (!Array.isArray(content)) return false;
495
+ return content.some((block) => block !== null && typeof block === "object" && block.type === "image");
496
+ }
167
497
  /** Fallback collector: enumerate user messages from the loaded chat nodes. */
168
498
  function collectFromNodes(snapshot) {
169
499
  const out = [];
@@ -179,11 +509,13 @@ window.__ModuleLoader__.load({
179
509
  if (typeof data.time !== "number" || !Array.isArray(data.content)) continue;
180
510
  const key = typeof n.key === "string" ? n.key : void 0;
181
511
  if (key === void 0) continue;
512
+ const images = nodeImagesOf(data.content);
182
513
  out.push({
183
514
  seq: typeof n.anchorSeq === "number" ? n.anchorSeq : 0,
184
515
  time: data.time,
185
516
  text: userTextOf(data.content),
186
517
  hasImage: userHasImage(data.content),
518
+ ...images.length > 0 ? { images } : {},
187
519
  key
188
520
  });
189
521
  }
@@ -195,6 +527,33 @@ window.__ModuleLoader__.load({
195
527
  if (typeof m.key === "string" && m.key !== "") return m.key;
196
528
  if (typeof m.id === "string" && m.id !== "") return "13:input-message" + m.id;
197
529
  }
530
+ /** Full text of a rail message from the loaded chat nodes (uncapped),
531
+ * falling back to the projection preview when the node is not mounted. */
532
+ function fullTextOf(m, nodeSnapshot) {
533
+ const key = anchorKeyOf(m);
534
+ const nodes = nodeSnapshot?.chat?.nodes;
535
+ const content = (key === void 0 ? void 0 : nodes?.get(key))?.data?.content;
536
+ if (Array.isArray(content)) {
537
+ let out = "";
538
+ for (const block of content) if (block !== null && typeof block === "object" && block.type === "text" && typeof block.text === "string") out += block.text;
539
+ const full = out.trim();
540
+ if (full !== "") return full;
541
+ }
542
+ return m.text;
543
+ }
544
+ /** Displayable tip images for one rail message: host projection references
545
+ * first (works even before the node window covers the message), then the
546
+ * loaded chat node's content blocks. */
547
+ function tipImagesOf(m, nodeSnapshot) {
548
+ if (Array.isArray(m.images) && m.images.length > 0) return m.images.map((img) => ({
549
+ kind: "ref",
550
+ attachmentId: img.attachmentId,
551
+ mediaType: img.mediaType
552
+ }));
553
+ const key = anchorKeyOf(m);
554
+ const nodes = nodeSnapshot?.chat?.nodes;
555
+ return imageSpecsOfContent((key === void 0 ? void 0 : nodes?.get(key))?.data?.content);
556
+ }
198
557
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
199
558
  /**
200
559
  * Ensure the message node is loaded into the visible window, then scroll to it.
@@ -252,12 +611,84 @@ window.__ModuleLoader__.load({
252
611
  });
253
612
  return true;
254
613
  }
614
+ /** Session-scoped browser URL cache for resolved attachment bytes (one entry
615
+ * per session:attachment; a failed load evicts itself so the next hover
616
+ * retries instead of showing a permanently broken thumbnail). */
617
+ const thumbUrls = /* @__PURE__ */ new Map();
618
+ /** Fallback data URL when the browser cannot mint object URLs. */
619
+ function bytesToDataUrl(data, mediaType) {
620
+ let binary = "";
621
+ const chunk = 32768;
622
+ for (let offset = 0; offset < data.length; offset += chunk) binary += String.fromCharCode(...data.subarray(offset, offset + chunk));
623
+ return `data:${mediaType};base64,${btoa(binary)}`;
624
+ }
625
+ /** Resolve (and cache) one stored image to a displayable URL. */
626
+ function resolveThumb(sessionId, read, image) {
627
+ const key = `${sessionId}:${image.attachmentId}`;
628
+ let pending = thumbUrls.get(key);
629
+ if (pending === void 0) {
630
+ pending = read(image.attachmentId).then((result) => {
631
+ if (!result.ok || result.value === void 0) throw new Error(result.error?.message ?? result.error?.code ?? "readAttachment failed");
632
+ const data = result.value.data;
633
+ if (data === void 0) throw new Error("readAttachment resolved no bytes");
634
+ const mediaType = result.value.attachment?.mediaType ?? image.mediaType;
635
+ if (typeof URL.createObjectURL === "function") return URL.createObjectURL(new Blob([data], { type: mediaType }));
636
+ return bytesToDataUrl(data, mediaType);
637
+ });
638
+ pending.catch(() => {
639
+ thumbUrls.delete(key);
640
+ });
641
+ thumbUrls.set(key, pending);
642
+ }
643
+ return pending;
644
+ }
645
+ /** One tip thumbnail: resolves its attachment lazily, shows a pulsing
646
+ * placeholder while loading, and removes itself on failure. */
647
+ function TipThumb({ spec, sessionId, read }) {
648
+ const [src, setSrc] = (0, react.useState)(null);
649
+ const [failed, setFailed] = (0, react.useState)(false);
650
+ const specKey = spec.kind === "ref" ? `ref:${spec.attachmentId}` : "data";
651
+ (0, react.useEffect)(() => {
652
+ let alive = true;
653
+ setSrc(null);
654
+ setFailed(false);
655
+ if (spec.kind === "data") {
656
+ setSrc(spec.src);
657
+ return () => {
658
+ alive = false;
659
+ };
660
+ }
661
+ resolveThumb(sessionId, read, spec).then((url) => {
662
+ if (alive) setSrc(url);
663
+ }).catch(() => {
664
+ if (alive) setFailed(true);
665
+ });
666
+ return () => {
667
+ alive = false;
668
+ };
669
+ }, [
670
+ specKey,
671
+ sessionId,
672
+ read
673
+ ]);
674
+ if (failed) return null;
675
+ if (src === null) return (0, react.createElement)("div", {
676
+ className: S.tipImgPh,
677
+ "aria-hidden": true
678
+ });
679
+ return (0, react.createElement)("img", {
680
+ className: S.tipImg,
681
+ src,
682
+ alt: "",
683
+ draggable: false
684
+ });
685
+ }
255
686
  /** Resolve copy for the current UI language (document lang, DSH sets zh/en). */
256
687
  function langStrings() {
257
688
  const lang = typeof document !== "undefined" ? (document.documentElement.lang || "zh").toLowerCase() : "zh";
258
689
  return STRINGS[lang.startsWith("zh") ? "zh" : "en"];
259
690
  }
260
- function TimelineRail({ useProjection, sessionId, sessionsService }) {
691
+ function TimelineRail({ useProjection, sessionId, sessionsService, inputActions, conversation }) {
261
692
  const t = langStrings();
262
693
  const projected = useProjection("chatRail");
263
694
  const session = sessionId === void 0 ? void 0 : sessionsService.binding(sessionId)?.session;
@@ -266,14 +697,34 @@ window.__ModuleLoader__.load({
266
697
  let messages = [];
267
698
  if (Array.isArray(projected?.messages) && projected.messages.length > 0) messages = projected.messages.map(normalize).filter((m) => m !== null);
268
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]);
269
707
  const [activeIndex, setActiveIndex] = (0, react.useState)(-1);
270
708
  const [show, setShow] = (0, react.useState)(false);
271
709
  const [jumping, setJumping] = (0, react.useState)(false);
272
710
  const [tip, setTip] = (0, react.useState)(null);
273
711
  const navRef = (0, react.useRef)(null);
274
712
  const expandedRef = (0, react.useRef)(false);
713
+ const lastPointerRef = (0, react.useRef)(null);
275
714
  const jumpAbortRef = (0, react.useRef)(null);
276
715
  (0, react.useEffect)(() => () => jumpAbortRef.current?.abort(), []);
716
+ (0, react.useEffect)(() => {
717
+ const el = navRef.current;
718
+ if (el === null) return;
719
+ const onMove = (e) => {
720
+ lastPointerRef.current = {
721
+ x: e.clientX,
722
+ y: e.clientY
723
+ };
724
+ };
725
+ el.addEventListener("pointermove", onMove, { passive: true });
726
+ return () => el.removeEventListener("pointermove", onMove);
727
+ }, []);
277
728
  /** Position the tip against the item's CURRENT (post-expand) rect. */
278
729
  const positionTip = (index) => {
279
730
  if (index < 0) return;
@@ -306,28 +757,88 @@ window.__ModuleLoader__.load({
306
757
  }
307
758
  const el = navRef.current;
308
759
  if (el === null) return;
309
- const onTransitionEnd = (e) => {
310
- if (e.propertyName !== "width") return;
760
+ let settled = false;
761
+ const settle = () => {
762
+ if (settled) return;
763
+ settled = true;
311
764
  expandedRef.current = true;
312
765
  el.removeEventListener("transitionend", onTransitionEnd);
766
+ clearTimeout(timer);
767
+ const p = lastPointerRef.current;
768
+ if (p !== null) {
769
+ const hit = document.elementFromPoint(p.x, p.y);
770
+ const item = hit === null ? null : hit.closest("[data-crl-index]");
771
+ const idx = item === null ? -1 : Number(item.getAttribute("data-crl-index"));
772
+ if (Number.isInteger(idx) && idx >= 0) positionTip(idx);
773
+ }
774
+ };
775
+ const onTransitionEnd = (e) => {
776
+ if (e.propertyName !== "width") return;
777
+ settle();
313
778
  };
314
779
  el.addEventListener("transitionend", onTransitionEnd);
315
- return () => el.removeEventListener("transitionend", onTransitionEnd);
780
+ const timer = setTimeout(settle, 300);
781
+ return () => {
782
+ el.removeEventListener("transitionend", onTransitionEnd);
783
+ clearTimeout(timer);
784
+ };
316
785
  }, [show]);
317
- /** Full text of a rail message from the loaded chat nodes (uncapped),
318
- * falling back to the projection preview when the node is not mounted. */
319
- const fullTextOf = (m) => {
320
- const key = anchorKeyOf(m);
321
- const nodes = nodeSnapshot?.chat?.nodes;
322
- const content = (key === void 0 ? void 0 : nodes?.get(key))?.data?.content;
323
- if (Array.isArray(content)) {
324
- let out = "";
325
- for (const block of content) if (block !== null && typeof block === "object" && block.type === "text" && typeof block.text === "string") out += block.text;
326
- const full = out.trim();
327
- if (full !== "") return full;
328
- }
329
- return m.text;
330
- };
786
+ const readThumb = (0, react.useMemo)(() => {
787
+ if (sessionId === void 0) return void 0;
788
+ const s = sessionsService.binding(sessionId)?.session;
789
+ return s?.readAttachment === void 0 ? void 0 : (id) => s.readAttachment(id);
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]);
331
842
  (0, react.useEffect)(() => {
332
843
  if (session === void 0) return;
333
844
  if (Array.isArray(projected?.messages) && projected.messages.length > 0) return;
@@ -355,10 +866,10 @@ window.__ModuleLoader__.load({
355
866
  Array.isArray(projected?.messages) && projected.messages.length > 0 ? "have" : "none"
356
867
  ]);
357
868
  (0, react.useEffect)(() => {
358
- if (messages.length === 0) return;
869
+ if (effectiveMessages.length === 0) return;
359
870
  const indexByKey = /* @__PURE__ */ new Map();
360
- for (let i = 0; i < messages.length; i++) {
361
- const key = anchorKeyOf(messages[i]);
871
+ for (let i = 0; i < effectiveMessages.length; i++) {
872
+ const key = anchorKeyOf(effectiveMessages[i]);
362
873
  if (key !== void 0) indexByKey.set(key, i);
363
874
  }
364
875
  const updateActive = () => {
@@ -401,7 +912,7 @@ window.__ModuleLoader__.load({
401
912
  window.clearInterval(timer);
402
913
  if (scrollTimer !== null) clearTimeout(scrollTimer);
403
914
  };
404
- }, [sessionId, messages.length]);
915
+ }, [sessionId, effectiveMessages.length]);
405
916
  (0, react.useEffect)(() => {
406
917
  if (activeIndex < 0) return;
407
918
  const el = navRef.current;
@@ -415,13 +926,15 @@ window.__ModuleLoader__.load({
415
926
  });
416
927
  }, [activeIndex]);
417
928
  if (sessionId === void 0 || messages.length < 2) return null;
418
- 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) => {
419
931
  const key = anchorKeyOf(m);
932
+ const starred = favoriteIdOfMessage(m) !== "" && isFavorite(favorites, sessionId, favoriteIdOfMessage(m));
420
933
  return (0, react.createElement)("button", {
421
934
  type: "button",
422
935
  key: m.seq,
423
936
  "data-crl-index": String(i),
424
- className: S.item + (activeIndex === i ? ` ${S.itemActive}` : ""),
937
+ className: S.item + (activeIndex === i ? ` ${S.itemActive}` : "") + (starred ? ` ${S.favItem}` : ""),
425
938
  "aria-label": `${t.roleUser}: ${m.text.slice(0, 60) || t.noText} (${t.ariaJump})`,
426
939
  "aria-current": activeIndex === i ? "location" : void 0,
427
940
  disabled: jumping,
@@ -437,12 +950,50 @@ window.__ModuleLoader__.load({
437
950
  onMouseLeave: () => handleItemLeave(i),
438
951
  children: [
439
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,
440
969
  (0, react.createElement)("span", { className: S.title }, m.text === "" ? t.noText : m.text),
441
970
  m.hasImage ? (0, react.createElement)("span", {
442
971
  className: S.img,
443
- title: t.hasImage,
972
+ role: "img",
444
973
  "aria-label": t.hasImage
445
- }) : null,
974
+ }, (0, react.createElement)("svg", {
975
+ viewBox: "0 0 16 16",
976
+ width: 12,
977
+ height: 12,
978
+ fill: "none",
979
+ stroke: "currentColor",
980
+ strokeWidth: 1.4,
981
+ strokeLinecap: "round",
982
+ strokeLinejoin: "round",
983
+ "aria-hidden": true
984
+ }, (0, react.createElement)("rect", {
985
+ x: 1.7,
986
+ y: 2.7,
987
+ width: 12.6,
988
+ height: 10.6,
989
+ rx: 2.2
990
+ }), (0, react.createElement)("circle", {
991
+ cx: 5.6,
992
+ cy: 6.4,
993
+ r: 1.15,
994
+ fill: "currentColor",
995
+ stroke: "none"
996
+ }), (0, react.createElement)("path", { d: "M2.5 11.6l3.6-3.1 2.6 2.2 2.1-1.9 3.3 2.8" }))) : null,
446
997
  (0, react.createElement)("span", { className: S.time }, relativeTime(m.time, t)),
447
998
  (0, react.createElement)("span", {
448
999
  className: S.ind,
@@ -458,31 +1009,88 @@ window.__ModuleLoader__.load({
458
1009
  "aria-label": t.railLabel,
459
1010
  onMouseEnter: () => setShow(true),
460
1011
  onMouseLeave: () => setShow(false),
461
- children: [jumping ? (0, react.createElement)("div", {
462
- className: S.loading,
463
- key: "loading"
464
- }, (0, react.createElement)("span", { className: S.loadingLabel }, t.loading)) : null, ...items]
465
- }), 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", {
466
1040
  className: S.tip,
467
1041
  style: {
468
1042
  left: `${tip.x}px`,
469
1043
  top: `${tip.y}px`,
470
1044
  transform: "translateX(-100%)"
471
1045
  }
472
- }, fullTextOf(messages[tip.index]) || t.noText) : null], document.body);
1046
+ }, (() => {
1047
+ const m = messages[tipIndex];
1048
+ const specs = tipImagesOf(m, nodeSnapshot);
1049
+ const children = [];
1050
+ if (specs.length > 0 && readThumb !== void 0) children.push((0, react.createElement)("div", {
1051
+ className: S.tipImgs,
1052
+ key: "imgs"
1053
+ }, (0, react.createElement)("div", {
1054
+ className: S.tipImgWrap,
1055
+ key: "img"
1056
+ }, (0, react.createElement)(TipThumb, {
1057
+ spec: specs[0],
1058
+ sessionId,
1059
+ read: readThumb
1060
+ }), specs.length > 1 ? (0, react.createElement)("span", {
1061
+ className: S.tipImgCount,
1062
+ "aria-hidden": true
1063
+ }, `+${specs.length - 1}`) : null)));
1064
+ else if (m.hasImage) children.push((0, react.createElement)("span", {
1065
+ className: S.tipBadge,
1066
+ key: "badge"
1067
+ }, t.hasImage));
1068
+ children.push((0, react.createElement)("span", { key: "text" }, fullTextOf(m, nodeSnapshot) || t.noText));
1069
+ return children;
1070
+ })()) : null], document.body);
473
1071
  }
474
1072
  function apply(ctx) {
475
1073
  ctx.slots.inject("conversation.input.dock", () => ctx.slots.register({
476
1074
  name: "conversation.input.dock",
477
1075
  id: "chat-rail",
478
1076
  order: 40,
479
- inject: () => ({ sessionsService: ctx.sessions })
1077
+ inject: () => ({
1078
+ sessionsService: ctx.sessions,
1079
+ conversation: ctx.conversation
1080
+ })
480
1081
  }, TimelineRail));
481
1082
  }
482
1083
  //#endregion
483
1084
  exports.TimelineRail = TimelineRail;
484
1085
  exports.apply = apply;
1086
+ exports.favoriteIdsOf = favoriteIdsOf;
1087
+ exports.imageSpecsOfContent = imageSpecsOfContent;
485
1088
  exports.inject = inject;
1089
+ exports.isFavorite = isFavorite;
1090
+ exports.messageIdOfAnchorKey = messageIdOfAnchorKey;
1091
+ exports.normalize = normalize;
1092
+ exports.readFavorites = readFavorites;
1093
+ exports.toggleFavoriteId = toggleFavoriteId;
486
1094
  return module.exports;
487
1095
  }
488
1096
  });