@luziyang2026/dsh-question-nav 0.7.5 → 0.7.7

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
@@ -149,6 +149,37 @@ window.__ModuleLoader__.load({
149
149
  return Math.max(0, Math.min(target, Math.max(0, maxScroll)));
150
150
  }
151
151
  //#endregion
152
+ //#region src/core/active-dot.ts
153
+ /**
154
+ * Pick the dot that marks the reader's position: the LAST row (in dot order)
155
+ * whose top sits at or above the reading line — the turn the reader has
156
+ * reached. When the line sits above every rendered row (reading the intro
157
+ * before the first question), the FIRST row marks the upcoming turn instead,
158
+ * so the marker is always present while questions are on screen. Returns null
159
+ * when no dot row is rendered (trajectory view, or a window that has not
160
+ * mounted any question yet).
161
+ *
162
+ * Rows are expected in dot order; tops are not re-sorted — the scan keeps the
163
+ * last qualifying entry, so one stale out-of-order measurement never moves
164
+ * the marker backwards past a later row.
165
+ */
166
+ function pickActiveDot(rows, readingLine) {
167
+ if (rows.length === 0) return null;
168
+ let active = null;
169
+ for (const row of rows) if (row.top <= readingLine) active = row.key;
170
+ return active ?? rows[0].key;
171
+ }
172
+ /**
173
+ * Reading-line offset from the scrollport top: 35% of the visible height,
174
+ * clamped to at least 48px so short viewports keep a usable top margin. The
175
+ * marker tracks the turn at this line rather than the exact top edge, which
176
+ * reads as "the question I am looking at" instead of "the question that just
177
+ * left the screen".
178
+ */
179
+ function readingLineOffset(scrollportHeight) {
180
+ return Math.max(48, scrollportHeight * .35);
181
+ }
182
+ //#endregion
152
183
  //#region src/core/time.ts
153
184
  /**
154
185
  * Question sent-time formatting for the question-nav surface. Pure: no DSH
@@ -186,7 +217,7 @@ window.__ModuleLoader__.load({
186
217
  }
187
218
  //#endregion
188
219
  //#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
189
- const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_railRight{left:auto}.TWf_pa_queue{box-sizing:border-box;pointer-events:none;flex-direction:column;flex:0 auto;align-items:center;gap:6px;width:100%;min-height:0;max-height:60%;margin:auto 0;display:flex}.TWf_pa_list{scrollbar-width:none;pointer-events:auto;flex-direction:column;flex:auto;align-items:center;gap:6px;width:100%;min-height:0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list::-webkit-scrollbar{width:0;height:0;display:none}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover,.TWf_pa_dot.TWf_pa_focused,.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary)}@keyframes TWf_pa_dotEnterFromBottom{0%{opacity:0;transform:translateY(7px)scale(.4)}60%{opacity:1}to{opacity:1;transform:translateY(0)scale(1)}}@keyframes TWf_pa_dotEnterFromTop{0%{opacity:0;transform:translateY(-7px)scale(.4)}60%{opacity:1}to{opacity:1;transform:translateY(0)scale(1)}}.TWf_pa_dotEnterFromBottom{animation:.32s cubic-bezier(.2,.9,.3,1.2) both TWf_pa_dotEnterFromBottom}.TWf_pa_dotEnterFromTop{animation:.32s cubic-bezier(.2,.9,.3,1.2) both TWf_pa_dotEnterFromTop}@media (prefers-reduced-motion:reduce){.TWf_pa_dotEnterFromBottom,.TWf_pa_dotEnterFromTop{animation:none}}.TWf_pa_count{color:var(--dsw-alias-label-tertiary);user-select:none;flex:none;font-size:10px;font-weight:600;line-height:1}.TWf_pa_dots{flex-direction:column;align-items:center;gap:6px;display:flex}.TWf_pa_navBtn{pointer-events:auto;cursor:pointer;background:0 0;border:none;flex:none;justify-content:center;align-items:center;width:14px;height:12px;padding:0;display:flex}.TWf_pa_navBtn:before{content:\"\";border-left:5px solid #0000;border-right:5px solid #0000;width:0;height:0;transition:border-color .12s;display:block}.TWf_pa_navUp:before{border-bottom:7px solid var(--dsw-alias-border-l3)}.TWf_pa_navDown:before{border-top:7px solid var(--dsw-alias-border-l3)}.TWf_pa_navUp:hover:before{border-bottom-color:var(--dsw-alias-brand-primary)}.TWf_pa_navDown:hover:before{border-top-color:var(--dsw-alias-brand-primary)}.TWf_pa_empty{color:var(--dsw-alias-label-tertiary);text-align:center;word-break:break-word;padding:10px 4px;font-size:11px}.TWf_pa_hint{z-index:30;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);max-width:260px;color:var(--dsw-alias-label-secondary);pointer-events:none;border-radius:6px;padding:6px 10px;font-size:12px;line-height:16px;position:fixed;box-shadow:0 2px 10px #0000002e}.TWf_pa_cascade{z-index:20;pointer-events:none;flex-direction:column;align-items:flex-start;gap:6px;display:flex;position:fixed}.TWf_pa_cascadeRight{align-items:flex-end}.TWf_pa_card{background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);width:380px;color:var(--dsw-alias-label-secondary);white-space:normal;word-break:break-word;pointer-events:auto;cursor:pointer;border-radius:10px;padding:8px 12px 10px;font-size:13px;line-height:18px;transition:border-color .12s,box-shadow .12s;box-shadow:0 1px 6px #0000001a}.TWf_pa_card:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);box-shadow:0 3px 12px #0000002e}.TWf_pa_cardSelected{border-color:var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);box-shadow:inset 3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 20px #00000038}.TWf_pa_cardSelected:hover{border-color:var(--dsw-alias-brand-primary);box-shadow:inset 3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 22px #00000042}.TWf_pa_cardSelectedRight{box-shadow:inset -3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 20px #00000038}.TWf_pa_cardSelectedRight:hover{box-shadow:inset -3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 22px #00000042}.TWf_pa_cardTitle{color:var(--dsw-alias-label-tertiary);margin-bottom:4px;font-size:11px;font-weight:600}.TWf_pa_cardBody{--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);max-height:96px;overflow-y:auto}.TWf_pa_cardClamp{word-break:break-word;-webkit-box-orient:vertical;font-size:13px;line-height:18px;display:-webkit-box;overflow:hidden}.TWf_pa_cardLine+.TWf_pa_cardLine{border-top:1px solid var(--dsw-alias-border-l1);margin-top:6px;padding-top:6px}.TWf_pa_cardTime{color:var(--dsw-alias-label-tertiary);margin-top:6px;font-size:11px}.TWf_pa_settings{flex-direction:column;gap:8px;padding:4px 0;display:flex}.TWf_pa_settingsTitle{color:var(--dsw-alias-label-primary);margin:0;font-size:13px;font-weight:600}.TWf_pa_segmented+.TWf_pa_settingsTitle{margin-top:12px}.TWf_pa_settingsDesc{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:18px}.TWf_pa_segmented{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;align-self:flex-start;display:inline-flex;overflow:hidden}.TWf_pa_segment{color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:8px 16px;font-size:13px;line-height:1;transition:background .12s,color .12s}.TWf_pa_segment+.TWf_pa_segment{border-left:1px solid var(--dsw-alias-border-l1)}.TWf_pa_segment:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.TWf_pa_segmentActive,.TWf_pa_segmentActive:hover{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-inverse)}";
220
+ const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_railRight{left:auto}.TWf_pa_queue{box-sizing:border-box;pointer-events:none;flex-direction:column;flex:0 auto;align-items:center;gap:6px;width:100%;min-height:0;max-height:60%;margin:auto 0;display:flex}.TWf_pa_list{scrollbar-width:none;pointer-events:auto;flex-direction:column;flex:auto;align-items:center;gap:6px;width:100%;min-height:0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list::-webkit-scrollbar{width:0;height:0;display:none}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover,.TWf_pa_dot.TWf_pa_focused,.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary)}.TWf_pa_dot.TWf_pa_current{background:var(--dsw-alias-brand-primary);outline:1.5px solid var(--dsw-alias-brand-primary);outline-offset:2.5px}@keyframes TWf_pa_dotEnterFromBottom{0%{opacity:0;transform:translateY(7px)scale(.4)}60%{opacity:1}to{opacity:1;transform:translateY(0)scale(1)}}@keyframes TWf_pa_dotEnterFromTop{0%{opacity:0;transform:translateY(-7px)scale(.4)}60%{opacity:1}to{opacity:1;transform:translateY(0)scale(1)}}.TWf_pa_dotEnterFromBottom{animation:.32s cubic-bezier(.2,.9,.3,1.2) both TWf_pa_dotEnterFromBottom}.TWf_pa_dotEnterFromTop{animation:.32s cubic-bezier(.2,.9,.3,1.2) both TWf_pa_dotEnterFromTop}@media (prefers-reduced-motion:reduce){.TWf_pa_dotEnterFromBottom,.TWf_pa_dotEnterFromTop{animation:none}}.TWf_pa_count{color:var(--dsw-alias-label-tertiary);user-select:none;flex:none;font-size:10px;font-weight:600;line-height:1}.TWf_pa_dots{flex-direction:column;align-items:center;gap:6px;display:flex}.TWf_pa_navBtn{pointer-events:auto;cursor:pointer;background:0 0;border:none;flex:none;justify-content:center;align-items:center;width:14px;height:12px;padding:0;display:flex}.TWf_pa_navBtn:before{content:\"\";border-left:5px solid #0000;border-right:5px solid #0000;width:0;height:0;transition:border-color .12s;display:block}.TWf_pa_navUp:before{border-bottom:7px solid var(--dsw-alias-border-l3)}.TWf_pa_navDown:before{border-top:7px solid var(--dsw-alias-border-l3)}.TWf_pa_navUp:hover:before{border-bottom-color:var(--dsw-alias-brand-primary)}.TWf_pa_navDown:hover:before{border-top-color:var(--dsw-alias-brand-primary)}.TWf_pa_empty{color:var(--dsw-alias-label-tertiary);text-align:center;word-break:break-word;padding:10px 4px;font-size:11px}.TWf_pa_hint{z-index:30;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);max-width:260px;color:var(--dsw-alias-label-secondary);pointer-events:none;border-radius:6px;padding:6px 10px;font-size:12px;line-height:16px;position:fixed;box-shadow:0 2px 10px #0000002e}.TWf_pa_cascade{z-index:20;pointer-events:none;flex-direction:column;align-items:flex-start;gap:6px;display:flex;position:fixed}.TWf_pa_cascadeRight{align-items:flex-end}.TWf_pa_card{background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);width:380px;color:var(--dsw-alias-label-secondary);white-space:normal;word-break:break-word;pointer-events:auto;cursor:pointer;border-radius:10px;padding:8px 12px 10px;font-size:13px;line-height:18px;transition:border-color .12s,box-shadow .12s;box-shadow:0 1px 6px #0000001a}.TWf_pa_card:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);box-shadow:0 3px 12px #0000002e}.TWf_pa_cardSelected{border-color:var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);box-shadow:inset 3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 20px #00000038}.TWf_pa_cardSelected:hover{border-color:var(--dsw-alias-brand-primary);box-shadow:inset 3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 22px #00000042}.TWf_pa_cardSelectedRight{box-shadow:inset -3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 20px #00000038}.TWf_pa_cardSelectedRight:hover{box-shadow:inset -3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 22px #00000042}.TWf_pa_cardTitle{color:var(--dsw-alias-label-tertiary);margin-bottom:4px;font-size:11px;font-weight:600}.TWf_pa_cardBody{--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);max-height:96px;overflow-y:auto}.TWf_pa_cardClamp{word-break:break-word;-webkit-box-orient:vertical;font-size:13px;line-height:18px;display:-webkit-box;overflow:hidden}.TWf_pa_cardLine+.TWf_pa_cardLine{border-top:1px solid var(--dsw-alias-border-l1);margin-top:6px;padding-top:6px}.TWf_pa_cardTime{color:var(--dsw-alias-label-tertiary);margin-top:6px;font-size:11px}.TWf_pa_settings{flex-direction:column;gap:8px;padding:4px 0;display:flex}.TWf_pa_settingsTitle{color:var(--dsw-alias-label-primary);margin:0;font-size:13px;font-weight:600}.TWf_pa_segmented+.TWf_pa_settingsTitle{margin-top:12px}.TWf_pa_settingsDesc{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:18px}.TWf_pa_segmented{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;align-self:flex-start;display:inline-flex;overflow:hidden}.TWf_pa_segment{color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:8px 16px;font-size:13px;line-height:1;transition:background .12s,color .12s}.TWf_pa_segment+.TWf_pa_segment{border-left:1px solid var(--dsw-alias-border-l1)}.TWf_pa_segment:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.TWf_pa_segmentActive,.TWf_pa_segmentActive:hover{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-inverse)}";
190
221
  const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
191
222
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
192
223
  const tag = document.createElement("style");
@@ -208,6 +239,7 @@ window.__ModuleLoader__.load({
208
239
  "cascade": "TWf_pa_cascade",
209
240
  "cascadeRight": "TWf_pa_cascadeRight",
210
241
  "count": "TWf_pa_count",
242
+ "current": "TWf_pa_current",
211
243
  "dot": "TWf_pa_dot",
212
244
  "dotEnterFromBottom": "TWf_pa_dotEnterFromBottom",
213
245
  "dotEnterFromTop": "TWf_pa_dotEnterFromTop",
@@ -256,6 +288,11 @@ window.__ModuleLoader__.load({
256
288
  * appears only while its direction has more to reveal) — no hover auto-scroll
257
289
  * of any kind.
258
290
  *
291
+ * A scroll-spy marks the reader's position: the dot of the turn at the
292
+ * reading line (35% down the scrollport) carries a ring marker that follows
293
+ * the conversation as it scrolls, streams, or pages its window. The marker
294
+ * never scrolls the band — it is purely a position indicator.
295
+ *
259
296
  * Data source: the host-folded `questionIndex` session projection (whole
260
297
  * history, persisted host-side, pushed live through session/projection
261
298
  * frames) read through the injected `questionProjection` face, plus the live
@@ -303,6 +340,7 @@ window.__ModuleLoader__.load({
303
340
  const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
304
341
  const [hint, setHint] = (0, react.useState)(null);
305
342
  const [focus, setFocus] = (0, react.useState)(null);
343
+ const [currentKey, setCurrentKey] = (0, react.useState)(null);
306
344
  const [align, setAlign] = (0, react.useState)(() => props.align());
307
345
  const [pageSize, setPageSize] = (0, react.useState)(() => props.pageSize());
308
346
  const panelRef = (0, react.useRef)(null);
@@ -448,7 +486,7 @@ window.__ModuleLoader__.load({
448
486
  const top = `${convRect.top - frameRect.top}px`;
449
487
  const height = `${convRect.height}px`;
450
488
  if (align === "right") {
451
- const right = `${frameRect.right - convRect.right}px`;
489
+ const right = `calc(${frameRect.right - convRect.right}px + var(--dsh-scrollbar-width, 8px))`;
452
490
  if (panel.style.top === top && panel.style.height === height && panel.style.right === right && panel.style.left === "") return false;
453
491
  panel.style.top = top;
454
492
  panel.style.height = height;
@@ -510,6 +548,63 @@ window.__ModuleLoader__.load({
510
548
  observer.observe(list);
511
549
  return () => observer.disconnect();
512
550
  }, [dots, align]);
551
+ (0, react.useEffect)(() => {
552
+ if (!visible || dots.length === 0) {
553
+ setCurrentKey(null);
554
+ return;
555
+ }
556
+ let raf = 0;
557
+ let disposed = false;
558
+ let observedFlow = null;
559
+ const compute = () => {
560
+ raf = 0;
561
+ const scrollport = document.querySelector("[data-conversation-scroll]");
562
+ const flow = document.querySelector("[data-chat-flow]");
563
+ if (scrollport === null || flow === null) {
564
+ setCurrentKey(null);
565
+ return;
566
+ }
567
+ if (observer !== null && observedFlow !== flow) {
568
+ observer.disconnect();
569
+ observer.observe(flow);
570
+ observedFlow = flow;
571
+ }
572
+ const portRect = scrollport.getBoundingClientRect();
573
+ if (portRect.height <= 0) return;
574
+ const rendered = /* @__PURE__ */ new Map();
575
+ for (const row of flow.querySelectorAll("[data-chat-anchor-key]")) {
576
+ const key = row.dataset.chatAnchorKey;
577
+ if (key !== void 0 && !rendered.has(key)) rendered.set(key, row);
578
+ }
579
+ const rows = [];
580
+ for (const dot of dots) for (const memberKey of dot.memberKeys) {
581
+ const row = rendered.get(memberKey);
582
+ if (row !== void 0) {
583
+ rows.push({
584
+ key: dot.key,
585
+ top: row.getBoundingClientRect().top - portRect.top
586
+ });
587
+ break;
588
+ }
589
+ }
590
+ const next = pickActiveDot(rows, readingLineOffset(portRect.height));
591
+ setCurrentKey((prev) => prev === next ? prev : next);
592
+ };
593
+ const schedule = () => {
594
+ if (!disposed && raf === 0) raf = requestAnimationFrame(compute);
595
+ };
596
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
597
+ compute();
598
+ document.addEventListener("scroll", schedule, true);
599
+ window.addEventListener("resize", schedule);
600
+ return () => {
601
+ disposed = true;
602
+ if (raf !== 0) cancelAnimationFrame(raf);
603
+ observer?.disconnect();
604
+ document.removeEventListener("scroll", schedule, true);
605
+ window.removeEventListener("resize", schedule);
606
+ };
607
+ }, [visible, dots]);
513
608
  (0, react.useEffect)(() => () => {
514
609
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
515
610
  if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current);
@@ -583,6 +678,7 @@ window.__ModuleLoader__.load({
583
678
  const cls = [question_nav_module_css_default.dot];
584
679
  if (isFocused) cls.push(question_nav_module_css_default.focused);
585
680
  if (jumpingKey === dot.key) cls.push(question_nav_module_css_default.active);
681
+ if (currentKey === dot.key) cls.push(question_nav_module_css_default.current);
586
682
  if (reveal !== void 0) cls.push(reveal.dir === 1 ? question_nav_module_css_default.dotEnterFromBottom : question_nav_module_css_default.dotEnterFromTop);
587
683
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
588
684
  className: cls.join(" "),
package/lib/client.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":["kind","useState","useRef","styles","createPortal","useState","styles"],"sources":["../src/core/turn-dots.ts","../src/core/focus.ts","../src/core/time.ts","../src/client/QuestionNavStrip.tsx","../src/core/align.ts","../src/core/page-size.ts","../src/client/QuestionNavSettingsTab.tsx","../src/client/settings.ts","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Turn-aligned dot model for the question-nav strip. Pure transforms over the\n * `questionIndex` projection value plus the live chat window — no React, no\n * DOM — so the grouping and merging rules are unit-testable in isolation.\n *\n * One dot per turn that claimed at least one user question; turns without a\n * question (retry, goal continuation, cancelled empty turns) produce no dot,\n * so dot turn labels may skip numbers but always match the Trajectory view.\n */\n\nimport type { QuestionEntry } from './question-entry.ts'\nimport type { QuestionNode } from './nodes.ts'\n\n/** The conversation Definition kind whose key a user question node uses. */\nexport const MESSAGE_DEFINITION_KIND = 'input-message'\n\n/**\n * The engine-owned stable chat key for a user question — mirrors\n * `conversationContextKey('input-message', String(id))` from the DSH runtime\n * (verified against that formula in the unit test).\n */\nexport function questionKey(id: unknown): string {\n const kind = MESSAGE_DEFINITION_KIND\n return `${kind.length}:${kind}${String(id)}`\n}\n\n/** One strip dot: a turn's questions (grouped) or one ungrouped live question. */\nexport interface TurnDot {\n /** Owning turn number; null for live questions the projection has not seen. */\n readonly turn: number | null\n /** Jump anchor: the chat key of the turn's FIRST question. */\n readonly key: string\n /** Anchor seq of the first question (ordering + jump target). */\n readonly anchorSeq: number\n /** Unix ms of the first question. */\n readonly time: number\n /** Every question text of this dot, in order (tooltip lists them all). */\n readonly texts: readonly string[]\n /** Chat keys of every question folded into this dot (live-merge dedupe). */\n readonly memberKeys: readonly string[]\n}\n\n/** True when entries are already in non-decreasing seq order (the projection\n * appends in event order, so this is the common case and skips the sort). */\nfunction isSortedBySeq(entries: readonly QuestionEntry[]): boolean {\n for (let i = 1; i < entries.length; i++) {\n if (entries[i].seq < entries[i - 1].seq) return false\n }\n return true\n}\n\n/**\n * Fold the projection's question list into one dot per turn. Entries arrive\n * in event order; consecutive same-turn entries merge into a single dot whose\n * anchor is the turn's first question. The input is expected to be sorted by\n * seq; the defensive sort is skipped when it already is, so a long session\n * never pays an O(n log n) sort on every content update.\n */\nexport function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {\n const sorted = isSortedBySeq(entries) ? entries : [...entries].sort((a, b) => a.seq - b.seq)\n const dots: TurnDot[] = []\n for (const entry of sorted) {\n const key = questionKey(entry.id)\n const last = dots.at(-1)\n if (last !== undefined && last.turn === entry.turn) {\n dots[dots.length - 1] = {\n ...last,\n texts: [...last.texts, entry.text],\n memberKeys: [...last.memberKeys, key],\n }\n continue\n }\n dots.push({\n turn: entry.turn,\n key,\n anchorSeq: entry.seq,\n time: entry.time,\n texts: [entry.text],\n memberKeys: [key],\n })\n }\n return dots\n}\n\n/**\n * Merge live-window questions the projection has not recorded yet (the brief\n * window before the session/projection push frame lands). A live question\n * whose key is already folded into a dot is dropped (the projected copy\n * wins); the rest become single-question dots with `turn: null`, inserted in\n * anchor-seq order so the strip stays strictly chronological.\n *\n * Fast path: when nothing new arrives the SAME array is returned (no copy),\n * so the caller can bail out of a re-render on identical reference.\n */\nexport function mergeLiveQuestions(\n dots: readonly TurnDot[],\n live: readonly QuestionNode[],\n): TurnDot[] {\n if (live.length === 0) return dots as TurnDot[]\n const known = new Set<string>()\n for (const dot of dots) {\n for (const key of dot.memberKeys) known.add(key)\n }\n const extras: TurnDot[] = []\n for (const question of live) {\n if (known.has(question.key)) continue\n extras.push({\n turn: null,\n key: question.key,\n anchorSeq: question.anchorSeq,\n time: question.time,\n texts: [question.text],\n memberKeys: [question.key],\n })\n }\n // Nothing new from the live window — reuse the input array unchanged.\n if (extras.length === 0) return dots as TurnDot[]\n // Both `dots` and `extras` are sorted by anchorSeq (dots from the projection\n // order, extras from the live window order): merge linearly instead of\n // re-sorting the whole list. Ties keep the projected dot first (stable\n // sort semantics), matching the previous [...dots, ...extras].sort().\n const out: TurnDot[] = []\n let i = 0\n for (const extra of extras) {\n while (i < dots.length && dots[i].anchorSeq <= extra.anchorSeq) {\n out.push(dots[i])\n i += 1\n }\n out.push(extra)\n }\n while (i < dots.length) {\n out.push(dots[i])\n i += 1\n }\n return out\n}\n","/**\n * Focus-magnification model for the question-nav rail. Pure index/scale math\n * for the hover-selected dot's neighborhood window, its progressive\n * magnification tiers, and the vertical cascade of question cards (one per\n * window dot, non-overlapping, sizes descending away from the selected one).\n * No React, no DOM — every function here is unit-testable in isolation and\n * safe to inline into the client bundle.\n *\n * @module dsh-question-nav/focus\n */\n\n/** How many dots stay enlarged on each side of the selected dot. */\nexport const FOCUS_RADIUS = 2\n\n/** Magnification scale per tier, by distance from the selected dot\n * (0 = selected, 1 = immediate neighbor, 2 = outer window edge). */\nexport const FOCUS_SCALES = [2.0, 1.55, 1.25] as const\n\n/**\n * The focus tier of a dot at `distance` from the selected dot: 0..FOCUS_RADIUS\n * while inside the magnification window, null beyond it (base scale).\n */\nexport function focusTier(distance: number): number | null {\n const d = Math.abs(distance)\n if (d > FOCUS_RADIUS) return null\n return d\n}\n\n/** Magnification scale for a dot at `distance`; 1 (base) outside the window. */\nexport function focusScale(distance: number): number {\n const tier = focusTier(distance)\n return tier === null ? 1 : FOCUS_SCALES[tier]\n}\n\n/** Presentation metrics for the question card of a dot at `distance` from the\n * selected one. The hierarchy is two-tier: the selected card is the focus\n * (widest, full text, brand accent — styled via the selected CSS class), the\n * four neighbors are context cards (same clean surface, narrower, clamped to\n * fewer lines). Cards are never dimmed or blurred: context must stay crisp\n * and readable so it can be recognized — and clicked. Null outside the\n * window (no card). */\nexport interface FocusCardMetrics {\n /** Card width in px (narrows away from the selected card). */\n widthPx: number\n /** Text font size (px); cascade cards are slightly smaller. */\n fontSize: number\n /** Max text lines before clamping (cascade cards). */\n maxLines: number\n}\n\nexport function focusCardMetrics(distance: number): FocusCardMetrics | null {\n const tier = focusTier(distance)\n if (tier === null) return null\n switch (tier) {\n case 0:\n return { widthPx: 380, fontSize: 13, maxLines: 6 }\n case 1:\n return { widthPx: 300, fontSize: 12.5, maxLines: 2 }\n default:\n return { widthPx: 240, fontSize: 12, maxLines: 1 }\n }\n}\n\n/**\n * Indices of the focus window: the selected dot plus `radius` on each side,\n * clipped to the dot list. Empty when the list is empty or `selected` is out\n * of range.\n */\nexport function magnificationWindow(total: number, selected: number, radius: number = FOCUS_RADIUS): number[] {\n if (total <= 0 || selected < 0 || selected >= total || radius < 0) return []\n const out: number[] = []\n const lo = Math.max(0, selected - radius)\n const hi = Math.min(total - 1, selected + radius)\n for (let i = lo; i <= hi; i++) out.push(i)\n return out\n}\n\n/**\n * Paging model for the dot band. Overflowing dots are not auto-scrolled by\n * hovering the edges or hovering a clipped dot: instead the user pages them\n * with two triangle buttons (▲ above the dot queue, ▼ below it), each click\n * revealing `DOT_PAGE_ROWS` hidden dots. Pure arithmetic — no React, no DOM.\n */\n\n/** How many hidden dots one click of a paging triangle reveals. */\nexport const DOT_PAGE_ROWS = 5\n\n/** Base dot diameter (px) and gap (px), matching the rail CSS — the paging\n * step is expressed in whole dot rows, so it tracks the visible geometry. */\nexport const DOT_SIZE = 8\nexport const DOT_GAP = 6\n\n/** Scroll step (px) for one paging click: `rows` whole dot rows. */\nexport function pageStep(dotSize: number = DOT_SIZE, gap: number = DOT_GAP, rows: number = DOT_PAGE_ROWS): number {\n return rows * (dotSize + gap)\n}\n\n/** Clamp a target scrollTop into the band's valid range (0..maxScroll). */\nexport function clampScrollTop(target: number, maxScroll: number): number {\n return Math.max(0, Math.min(target, Math.max(0, maxScroll)))\n}\n\n/** Whether hidden dots remain above the band (show ▲). */\nexport function canScrollAbove(scrollTop: number): boolean {\n return scrollTop > 0\n}\n\n/** Whether hidden dots remain below the band (show ▼). */\nexport function canScrollBelow(scrollTop: number, maxScroll: number): boolean {\n return scrollTop < maxScroll\n}\n","/**\n * Question sent-time formatting for the question-nav surface. Pure: no DSH\n * imports, deterministic for a given (time, now) pair — unit-testable in\n * isolation and safe to inline into the client bundle.\n *\n * @module dsh-question-nav/time\n */\n\nfunction pad2(n: number): string {\n return n < 10 ? `0${n}` : String(n)\n}\n\n/** Whether `time` falls on the same calendar day as `now`. */\nexport function isSameDay(time: number, now: number): boolean {\n const a = new Date(time)\n const b = new Date(now)\n return a.getFullYear() === b.getFullYear()\n && a.getMonth() === b.getMonth()\n && a.getDate() === b.getDate()\n}\n\n/**\n * Smart question sent-time, relative to `now`:\n * - same calendar day → `HH:MM`\n * - same calendar year → `MM-DD HH:MM`\n * - otherwise → `YYYY-MM-DD HH:MM`\n *\n * Returns `''` for a missing/invalid timestamp (e.g. a live node that never\n * reported one), so callers can hide the time line without branching.\n */\nexport function formatQuestionTime(time: number, now: number): string {\n if (!Number.isFinite(time) || time <= 0) return ''\n const d = new Date(time)\n const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`\n if (isSameDay(time, now)) return hm\n const md = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`\n if (d.getFullYear() === new Date(now).getFullYear()) return `${md} ${hm}`\n return `${d.getFullYear()}-${md} ${hm}`\n}\n","/**\n * Question-nav minimap. Renders a vertical column of small round dots overlaid\n * on the LEFT edge of the conversation column (via the frame-wide\n * `shell.overlay` floating layer): one dot per turn that claimed at least one\n * user question — strictly aligned with the Trajectory view's turn numbering\n * (turns without a question produce no dot). The dot column is vertically\n * centered and clamped to at most 60% of the conversation height, scrolling\n * within that band when the session has more dots than fit.\n *\n * Hovering a dot \"selects\" it: the selected dot plus its two immediate\n * neighbors on each side magnify (progressively smaller with distance), and a\n * vertical cascade of question cards opens — the selected (center) card is the\n * focus (brand accent, elevated, full question text), the four neighbors are\n * narrower context cards clamped to fewer lines. Every card is clickable and\n * jumps to its question, exactly like clicking the dot. Hovering never scrolls\n * the band — the dot column stays put while you browse.\n *\n * Overflow is paged, not auto-scrolled: two small triangle buttons in the dot\n * style sit above and below the dot queue (▲ / ▼), each click revealing one\n * page of hidden dots (the page size is configurable in the plugin settings,\n * default 5) with a staggered pop-in animation as click feedback. The native\n * scrollbar stays hidden; the triangles themselves are the overflow cue (each\n * appears only while its direction has more to reveal) — no hover auto-scroll\n * of any kind.\n *\n * Data source: the host-folded `questionIndex` session projection (whole\n * history, persisted host-side, pushed live through session/projection\n * frames) read through the injected `questionProjection` face, plus the live\n * chat window's questions merged on top for the brief window before a\n * just-sent question lands in the projection. No render-window expansion, no\n * client-side history paging.\n *\n * Data arrives through the props shares: the framework `useSessions` hook\n * (current session), the registrant inject face (read/subscribe/project/\n * jump), and the bound locale translator.\n */\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\nimport type { QuestionNode } from '../core/nodes.ts'\nimport type { QuestionEntry } from '../core/question-entry.ts'\nimport { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'\nimport type { AlignPreference } from '../core/align.ts'\nimport type { PageSize } from '../core/page-size.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport { DOT_GAP, DOT_SIZE, FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, pageStep } from '../core/focus.ts'\nimport { formatQuestionTime } from '../core/time.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Minimal observable shape of a session projection face. */\nexport interface ObservableFace {\n /** Current projection value (unknown — validated structurally at read). */\n getSnapshot: () => unknown\n /** Subscribe to value changes; returns an unsubscribe. */\n subscribe: (listener: () => void) => () => void\n}\n\n/** Values the registrant inject face supplies (wired in src/client/index.ts). */\nexport interface QuestionNavInjected {\n /** Extract the user questions of a session's currently loaded window. */\n readQuestions: (sessionId: SessionId) => QuestionNode[]\n /** Subscribe to the session list; returns an unsubscribe. */\n subscribeList: (cb: () => void) => () => void\n /** Subscribe to a session's content; returns an unsubscribe. */\n subscribeContent: (sessionId: SessionId, cb: () => void) => () => void\n /** The session's `questionIndex` projection face, when the host unit is registered. */\n questionProjection: (sessionId: SessionId) => ObservableFace | undefined\n /** Jump the chat to a question row (pages the window on demand). */\n jump: (sessionId: SessionId, key: string) => void\n /** Current rail anchor edge (defaults to 'left' before the settings section is ready). */\n align: () => AlignPreference\n /** Observe plugin-settings changes (align, page size); returns an unsubscribe. */\n subscribeSettings: (cb: () => void) => () => void\n /** Persist a new anchor edge. */\n setAlign: (align: AlignPreference) => void\n /** Current ▲/▼ page size (defaults to DEFAULT_PAGE_SIZE before the settings section is ready). */\n pageSize: () => PageSize\n /** Persist a new page size. */\n setPageSize: (pageSize: PageSize) => void\n}\n\ntype ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\nconst FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {\n VIEW_INACTIVE: 'jump.inactive',\n TARGET_HIDDEN: 'jump.hidden',\n NOT_FOUND: 'jump.notfound',\n TIMEOUT: 'jump.timeout',\n}\n\n/** One card of the focus cascade: a dot inside the magnification window. */\ninterface FocusWindowItem {\n dot: TurnDot\n /** Distance from the selected dot (0 = selected, 1/2 = cascade neighbors). */\n distance: number\n}\n\n/** The focus cascade: a vertical column of one card per window dot,\n * non-overlapping, centered on the selected dot. */\ninterface FocusState {\n /** The selected (hovered) dot's key. */\n key: string\n /** Every dot of the focus window, in dot order. */\n items: FocusWindowItem[]\n /** Vertical center (viewport px) of the selected dot — the column centers on it. */\n centerY: number\n /** Horizontal anchor — left edge (left-aligned rail) or right edge (right-aligned). */\n left?: number\n right?: number\n}\n\n/** Read the projection face value as a question-entry list (structural guard). */\nfunction projectionEntries(face: ObservableFace | undefined): QuestionEntry[] {\n const value = face?.getSnapshot()\n if (!Array.isArray(value)) return []\n return value.filter((item): item is QuestionEntry =>\n typeof item === 'object' && item !== null\n && typeof (item as QuestionEntry).id === 'string'\n && typeof (item as QuestionEntry).seq === 'number'\n && typeof (item as QuestionEntry).turn === 'number')\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\n/** Structural equality of two dot lists (member keys fully capture a dot's\n * folded questions, so identical key sequences mean identical content).\n * Lets the strip skip a re-render when a refresh produced no change. */\nfunction sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i++) {\n const da = a[i]\n const db = b[i]\n if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length) return false\n for (let j = 0; j < da.memberKeys.length; j++) {\n if (da.memberKeys[j] !== db.memberKeys[j]) return false\n }\n }\n return true\n}\n\nexport function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {\n const current = props.useSessions((s) => s.current)\n const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))\n const visible = current !== undefined && summary !== undefined && summary.blank !== true\n\n const [dots, setDots] = useState<TurnDot[]>([])\n const [jumpingKey, setJumpingKey] = useState<string | null>(null)\n const [hint, setHint] = useState<string | null>(null)\n const [focus, setFocus] = useState<FocusState | null>(null)\n const [align, setAlign] = useState<AlignPreference>(() => props.align())\n // Dots revealed per ▲/▼ click (settings scope; drives pageBy's step).\n const [pageSize, setPageSize] = useState<PageSize>(() => props.pageSize())\n const panelRef = useRef<HTMLDivElement | null>(null)\n const listRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n // Pending focus-clear timer: lets the mouse move from the rail onto a\n // cascade card without dropping the cascade mid-flight.\n const clearFocusTimerRef = useRef<number | null>(null)\n // Last rendered dot list, for the change-detection bail-out below.\n const lastDotsRef = useRef<TurnDot[]>([])\n // Whether the dot band overflows its 60% clamp (drives the ▲/▼ paging\n // buttons).\n const [scrollable, setScrollable] = useState(false)\n // Scroll offset of the band + its max offset: drives the ▲/▼ visibility\n // (each direction hides once there is nothing more to reveal).\n const [scrollPos, setScrollPos] = useState<{ top: number; max: number }>({ top: 0, max: 0 })\n // Dots revealed by the latest ▲/▼ click: key → stagger order + travel\n // direction. Drives a short staggered pop-in animation so the click has a\n // visible effect; cleared by a timer once the animation has played out.\n const [revealed, setRevealed] = useState<ReadonlyMap<string, { dir: 1 | -1; order: number }> | null>(null)\n const revealTimerRef = useRef<number | null>(null)\n\n const syncScroll = (): void => {\n const list = listRef.current\n if (list === null) return\n const max = Math.max(0, list.scrollHeight - list.clientHeight)\n setScrollPos({ top: list.scrollTop, max })\n }\n\n // Page the band by one DOT_PAGE_ROWS click in the given direction, then mark\n // the dots the page just brought into view so they play a staggered pop-in\n // animation in the direction of travel (▼ → rise from below, ▲ → drop from\n // above) — the visual confirmation that the click revealed new dots.\n const pageBy = (dir: 1 | -1): void => {\n const list = listRef.current\n if (list === null) return\n const max = Math.max(0, list.scrollHeight - list.clientHeight)\n const target = clampScrollTop(list.scrollTop + dir * pageStep(DOT_SIZE, DOT_GAP, pageSize), max)\n if (target === list.scrollTop) return\n // Snapshot which dots are geometrically inside the band before the jump\n // (the container rect does not move when its content scrolls).\n const band = list.getBoundingClientRect()\n const els = Array.from(list.querySelectorAll<HTMLElement>('[data-question-nav-key]'))\n const wasVisible = els.map((el) => {\n const r = el.getBoundingClientRect()\n return r.bottom > band.top && r.top < band.bottom\n })\n list.scrollTop = target\n syncScroll()\n const next = new Map<string, { dir: 1 | -1; order: number }>()\n const fresh: string[] = []\n els.forEach((el, i) => {\n if (wasVisible[i]) return\n const r = el.getBoundingClientRect()\n const key = el.dataset.questionNavKey\n if (key !== undefined && r.bottom > band.top && r.top < band.bottom) {\n fresh.push(key)\n }\n })\n // Stagger radiates from the clicked triangle: paging down (▼) starts at\n // the bottom of the fresh batch, paging up (▲) at the top.\n if (dir === 1) fresh.reverse()\n fresh.forEach((key, order) => next.set(key, { dir, order }))\n if (next.size === 0) return\n setRevealed(next)\n if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current)\n // Animation (320ms) + per-dot stagger (35ms each) + slack.\n revealTimerRef.current = window.setTimeout(() => setRevealed(null), 320 + next.size * 35 + 120)\n }\n\n const canPageUp = scrollable && scrollPos.top > 0\n const canPageDown = scrollable && scrollPos.top < scrollPos.max\n\n // Focus persists briefly after leaving the rail, so the mouse can reach the\n // clickable cascade cards; entering a card cancels the clear, leaving\n // everything re-arms it.\n const cancelClearFocus = (): void => {\n if (clearFocusTimerRef.current !== null) {\n window.clearTimeout(clearFocusTimerRef.current)\n clearFocusTimerRef.current = null\n }\n }\n const scheduleClearFocus = (): void => {\n cancelClearFocus()\n clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240)\n }\n\n // Follow the plugin settings from the settings scope (an align change\n // re-anchors the rail through the layout effect below; a page-size change\n // steps the ▲/▼ paging).\n useEffect(() => props.subscribeSettings(() => {\n setAlign(props.align())\n setPageSize(props.pageSize())\n }), [props])\n\n const showHint = (message: string): void => {\n setHint(message)\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)\n }\n\n // Recompute the dot list from the projection + live window. Subscribed to\n // the projection push frames and the session's content only — session\n // switches are covered by `current` below (the effect re-runs on change),\n // so the session-list feed is not subscribed: it would re-run the full\n // recompute for unrelated list churn.\n useEffect(() => {\n if (!visible || current === undefined) {\n setDots([])\n lastDotsRef.current = []\n return\n }\n const sessionId = current\n const face = props.questionProjection(sessionId)\n const refresh = (): void => {\n const grouped = groupQuestionsByTurn(projectionEntries(face))\n const next = mergeLiveQuestions(grouped, props.readQuestions(sessionId))\n // Identical content (a streaming update that added no question): re-use\n // the previous array reference so React bails out of re-rendering the\n // strip — the common case during assistant streaming.\n if (sameDots(next, lastDotsRef.current)) {\n setDots(lastDotsRef.current)\n return\n }\n lastDotsRef.current = next\n setDots(next)\n }\n refresh()\n const unsubProjection = face?.subscribe(refresh) ?? (() => {})\n const unsubContent = props.subscribeContent(sessionId, refresh)\n return () => {\n unsubProjection()\n unsubContent()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [visible, current, props])\n\n // Listen for jump-failure events and surface the hint.\n useEffect(() => {\n const onJumpFailed = (event: Event): void => {\n const code = (event as CustomEvent<JumpFailureCode>).detail\n showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'))\n }\n window.addEventListener('question-nav:jump-failed', onJumpFailed)\n return () => window.removeEventListener('question-nav:jump-failed', onJumpFailed)\n }, [props])\n\n // Anchor the minimap to the conversation column: position it at the left\n // edge of the conversation root and reserve a thin rail with padding-left.\n //\n // A layout-correction loop keeps the rail pinned to the conversation even\n // when an outside panel (e.g. a browser-extension sidebar like Doubao) moves\n // or resizes the frame without resizing the browser window: `window resize`\n // never fires for in-page panels, and a ResizeObserver on the conversation\n // root alone misses remounts and the tail of the frame's grid-column\n // transition, which used to leave the rail drifting into the session list.\n // The loop runs while the layout is still moving, then parks itself after a\n // few stable frames; observers re-wake it on the next change.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let idle = 0\n let stopped = false\n let observer: ResizeObserver | null = null\n const observed = { frame: null as Element | null, convRoot: null as Element | null }\n\n const applyLayout = (): boolean => {\n const panel = panelRef.current\n if (panel === null) return false\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n // Keep looping while the anchors are not both present (conversation not\n // mounted yet / mid-reflow), so a late mount still aligns.\n if (frame === null || convRoot === null) return true\n // Keep observing the live nodes: the conversation root may remount\n // (e.g. after a panel-triggered reflow), which silently detaches an\n // earlier ResizeObserver target.\n if (observer !== null) {\n if (observed.frame !== frame) {\n observer.observe(frame, { box: 'border-box' })\n observed.frame = frame\n }\n if (observed.convRoot !== convRoot) {\n observer.observe(convRoot, { box: 'border-box' })\n observed.convRoot = convRoot\n }\n }\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n // Never snap onto a transient box: keep correcting until the\n // conversation has a real footprint again.\n if (convRect.height <= 0 || convRect.width <= 0) return true\n const top = `${convRect.top - frameRect.top}px`\n const height = `${convRect.height}px`\n // Anchor to the configured edge: keep the other edge's inline style\n // cleared so the CSS class (left: auto on .railRight) owns it.\n if (align === 'right') {\n const right = `${frameRect.right - convRect.right}px`\n if (panel.style.top === top && panel.style.height === height\n && panel.style.right === right && panel.style.left === '') {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.right = right\n panel.style.left = ''\n return true\n }\n const left = `${convRect.left - frameRect.left}px`\n if (panel.style.top === top && panel.style.height === height\n && panel.style.left === left && panel.style.right === '') {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.left = left\n panel.style.right = ''\n return true\n }\n\n const loop = (): void => {\n if (stopped) return\n raf = 0\n idle = applyLayout() ? 0 : idle + 1\n if (idle < 3) raf = requestAnimationFrame(loop)\n }\n const wake = (): void => {\n if (stopped) return\n idle = 0\n if (raf === 0) raf = requestAnimationFrame(loop)\n }\n\n observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake)\n if (observer !== null) {\n const panel = panelRef.current\n const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame !== null) { observer.observe(frame, { box: 'border-box' }); observed.frame = frame }\n if (convRoot !== null) { observer.observe(convRoot, { box: 'border-box' }); observed.convRoot = convRoot }\n }\n\n // Initial alignment; the loop keeps correcting through layout transitions.\n wake()\n window.addEventListener('resize', wake)\n return () => {\n stopped = true\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', wake)\n }\n }, [visible, align])\n\n // Detect band overflow: drives the ▲/▼ paging buttons. Re-checked when the\n // dots change and whenever the band itself resizes (the layout loop above\n // clamps it to the conversation height).\n useLayoutEffect(() => {\n const list = listRef.current\n if (list === null) return\n const check = (): void => {\n setScrollable(list.scrollHeight > list.clientHeight + 1)\n syncScroll()\n }\n check()\n if (typeof ResizeObserver === 'undefined') return\n const observer = new ResizeObserver(check)\n observer.observe(list)\n return () => observer.disconnect()\n }, [dots, align])\n\n // Clear any pending timers on unmount.\n useEffect(() => () => {\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)\n if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n if (!visible) return null\n\n const onJump = (dot: TurnDot): void => {\n if (current === undefined) return\n setJumpingKey(dot.key)\n props.jump(current, dot.key)\n window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)\n }\n\n const openFocus = (dot: TurnDot, target: HTMLElement): void => {\n const index = dots.findIndex((d) => d.key === dot.key)\n if (index < 0) return\n cancelClearFocus()\n const r = target.getBoundingClientRect()\n // The cascade opens away from the rail: to the right of the dots on a\n // left-anchored rail, to the left on a right-anchored one. The column is\n // vertically centered on the selected dot (top: centerY + translateY(-50%)).\n const horizontal = align === 'right'\n ? { right: window.innerWidth - r.left + 10 }\n : { left: r.right + 10 }\n const lo = Math.max(0, index - FOCUS_RADIUS)\n const hi = Math.min(dots.length - 1, index + FOCUS_RADIUS)\n const items: FocusWindowItem[] = []\n for (let i = lo; i <= hi; i++) {\n items.push({ dot: dots[i], distance: Math.abs(i - index) })\n }\n setFocus({ key: dot.key, items, centerY: r.top + r.height / 2, ...horizontal })\n }\n\n const t = props.t\n const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key)\n\n return (\n <div\n ref={panelRef}\n className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}\n data-question-nav=\"rail\"\n >\n <div className={styles.queue}>\n {dots.length === 0 ? (\n <div className={styles.empty}>{t('strip.empty')}</div>\n ) : (\n <>\n <span className={styles.count}>{dots.length}</span>\n {scrollable ? (\n <button\n type=\"button\"\n className={`${styles.navBtn} ${styles.navUp}`}\n aria-label={t('strip.up')}\n style={{ visibility: canPageUp ? 'visible' : 'hidden' }}\n onMouseEnter={cancelClearFocus}\n onMouseLeave={scheduleClearFocus}\n onClick={() => pageBy(-1)}\n />\n ) : null}\n <div\n ref={listRef}\n className={styles.list}\n onScroll={syncScroll}\n onMouseLeave={scheduleClearFocus}\n >\n <div className={styles.dots}>\n {dots.map((dot, index) => {\n const isFocused = focus !== null && focus.key === dot.key\n // Progressive magnification: selected largest, ±1 smaller, ±2\n // smaller still, the rest base scale.\n const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)\n const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1\n // Click-paging feedback: dots just paged into view play a\n // staggered pop-in, traveling in the paging direction.\n const reveal = revealed?.get(dot.key)\n const cls = [styles.dot]\n if (isFocused) cls.push(styles.focused)\n if (jumpingKey === dot.key) cls.push(styles.active)\n if (reveal !== undefined) {\n cls.push(reveal.dir === 1 ? styles.dotEnterFromBottom : styles.dotEnterFromTop)\n }\n return (\n <button\n key={dot.key}\n className={cls.join(' ')}\n style={{\n transform: `scale(${scale})`,\n ...(reveal !== undefined ? { animationDelay: `${reveal.order * 35}ms` } : {}),\n }}\n data-question-nav-focused={isFocused ? 'true' : undefined}\n data-question-nav-index={index}\n data-question-nav-key={dot.key}\n aria-label={dot.texts[0] ?? ''}\n onMouseEnter={(e) => openFocus(dot, e.currentTarget)}\n onClick={() => onJump(dot)}\n />\n )\n })}\n </div>\n </div>\n {scrollable ? (\n <button\n type=\"button\"\n className={`${styles.navBtn} ${styles.navDown}`}\n aria-label={t('strip.down')}\n style={{ visibility: canPageDown ? 'visible' : 'hidden' }}\n onMouseEnter={cancelClearFocus}\n onMouseLeave={scheduleClearFocus}\n onClick={() => pageBy(1)}\n />\n ) : null}\n </>\n )}\n </div>\n {hint !== null\n ? createPortal(\n <div\n className={styles.hint}\n style={align === 'right' ? { right: 60, top: 20 } : { left: 60, top: 20 }}\n >\n {hint}\n </div>,\n document.body,\n )\n : null}\n {focus !== null\n ? createPortal(\n /* The vertical cascade: one crisp card per window dot, stacked\n non-overlapping, vertically centered on the selected dot.\n Hovering the cascade keeps it alive (the rail only re-arms its\n clear once the mouse leaves both). */\n <div\n className={align === 'right' ? `${styles.cascade} ${styles.cascadeRight}` : styles.cascade}\n style={{\n ...(focus.left !== undefined ? { left: focus.left } : {}),\n ...(focus.right !== undefined ? { right: focus.right } : {}),\n top: focus.centerY,\n transform: 'translateY(-50%)',\n }}\n onMouseEnter={cancelClearFocus}\n onMouseLeave={scheduleClearFocus}\n >\n {focus.items.map((item) => {\n const metrics = focusCardMetrics(item.distance)\n if (metrics === null) return null\n const isSelected = item.distance === 0\n // Cascade cards join their dot's texts into one clamped line;\n // the selected card keeps every line. Every card shows the\n // question's sent time, and clicking any card jumps to its\n // question exactly like clicking the dot.\n const text = item.dot.texts.join(' · ')\n const cls = isSelected\n ? `${styles.card} ${styles.cardSelected}${align === 'right' ? ` ${styles.cardSelectedRight}` : ''}`\n : styles.card\n return (\n <div\n key={item.dot.key}\n className={cls}\n role=\"button\"\n tabIndex={-1}\n style={{ width: metrics.widthPx }}\n onClick={() => onJump(item.dot)}\n >\n {isSelected && item.dot.turn !== null ? (\n <div className={styles.cardTitle}>Turn {item.dot.turn}</div>\n ) : null}\n <div\n className={isSelected ? styles.cardBody : styles.cardClamp}\n style={\n isSelected\n ? undefined\n : ({ WebkitLineClamp: metrics.maxLines, fontSize: metrics.fontSize } as React.CSSProperties)\n }\n >\n {isSelected\n ? item.dot.texts.map((line, lineIndex) => (\n <div key={lineIndex} className={styles.cardLine}>{line}</div>\n ))\n : text}\n </div>\n <div className={styles.cardTime}>{formatQuestionTime(item.dot.time, Date.now())}</div>\n </div>\n )\n })}\n </div>,\n document.body,\n )\n : null}\n </div>\n )\n}\n","/**\n * Rail anchor-edge constants shared by the host schema and the browser\n * settings scope. Pure data: no DSH imports, so the client bundle may inline\n * this module (a Host import here would leak into the browser half).\n *\n * @module dsh-question-nav/align\n */\n\n/** Supported rail anchor edges. */\nexport const ALIGN_OPTIONS = ['left', 'right'] as const\n\n/** Rail anchor edge preference. */\nexport type AlignPreference = typeof ALIGN_OPTIONS[number]\n\n/** Default anchor edge when the user-settings document has no override. */\nexport const DEFAULT_ALIGN: AlignPreference = 'left'\n\n/** Settings namespace owned by this plugin (spelled here rather than\n * imported: the client bundle must not depend on a Host package). */\nexport const QUESTION_NAV_SETTINGS_NS = 'question-nav'\n\n/** Field carrying the selected anchor edge. */\nexport const ALIGN_FIELD = 'align'\n","/**\n * Page-size constants for the ▲/▼ paging buttons, shared by the host schema\n * and the browser settings scope. Pure data: no DSH imports, so the client\n * bundle may inline this module (a Host import here would leak into the\n * browser half).\n *\n * @module dsh-question-nav/page-size\n */\n\n/** Selectable page sizes: how many hidden dots one ▲/▼ click reveals. */\nexport const PAGE_SIZE_OPTIONS = [3, 5, 8, 10] as const\n\n/** Page-size preference (dots revealed per paging-button click). */\nexport type PageSize = typeof PAGE_SIZE_OPTIONS[number]\n\n/** Default page size when the user-settings document has no override. */\nexport const DEFAULT_PAGE_SIZE: PageSize = 5\n\n/** Field carrying the selected page size. */\nexport const PAGE_SIZE_FIELD = 'pageSize'\n","/**\n * The plugin's settings page inside the shell's Plugins section\n * (`settings.plugins.tab`): two segmented controls — which edge of the\n * conversation column the dot rail anchors to, and how many hidden dots one\n * ▲/▼ paging-button click reveals. The choices are written to the\n * `question-nav` settings namespace (registered by the host half); the strip\n * re-anchors and re-steps live when the snapshot changes.\n *\n * @module dsh-question-nav/client/settings-tab\n */\n\nimport { useEffect, useState } from 'react'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\n// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab').\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { ALIGN_OPTIONS } from '../core/align.ts'\nimport { PAGE_SIZE_OPTIONS } from '../core/page-size.ts'\nimport type { QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\ntype ComponentProps = PropsRuntime<'settings.plugins.tab'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\n/** Re-render on settings snapshot changes (the register inject face is static). */\nfunction useSettingsTick(subscribe: QuestionNavInjected['subscribeSettings']): void {\n const [, bump] = useState(0)\n useEffect(() => subscribe(() => bump((n) => n + 1)), [subscribe])\n}\n\nexport function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element | null {\n useSettingsTick(props.subscribeSettings)\n const align = props.align()\n const pageSize = props.pageSize()\n const t = props.t\n\n return (\n <div className={styles.settings}>\n <p className={styles.settingsTitle}>{t('settings.align.title')}</p>\n <p className={styles.settingsDesc}>{t('settings.align.desc')}</p>\n <div className={styles.segmented} role=\"radiogroup\" aria-label={t('settings.align.title')}>\n {ALIGN_OPTIONS.map((option) => (\n <button\n key={option}\n type=\"button\"\n role=\"radio\"\n aria-checked={align === option}\n className={align === option ? `${styles.segment} ${styles.segmentActive}` : styles.segment}\n onClick={() => props.setAlign(option)}\n >\n {t(option === 'left' ? 'settings.align.left' : 'settings.align.right')}\n </button>\n ))}\n </div>\n <p className={styles.settingsTitle}>{t('settings.pagesize.title')}</p>\n <p className={styles.settingsDesc}>{t('settings.pagesize.desc')}</p>\n <div className={styles.segmented} role=\"radiogroup\" aria-label={t('settings.pagesize.title')}>\n {PAGE_SIZE_OPTIONS.map((option) => (\n <button\n key={option}\n type=\"button\"\n role=\"radio\"\n aria-checked={pageSize === option}\n className={pageSize === option ? `${styles.segment} ${styles.segmentActive}` : styles.segment}\n onClick={() => props.setPageSize(option)}\n >\n {option}\n </button>\n ))}\n </div>\n </div>\n )\n}\n","/**\n * Browser mirror of the `question-nav` settings namespace: reads the rail\n * anchor edge and the ▲/▼ page size from the settings scope\n * (`ctx.settingsScope.bind`) and routes the user's choices back through\n * `scope.set`. The namespace itself is registered by the host half\n * (src/settings.ts).\n *\n * The settings surface is optional and may apply after this plugin, so the\n * controller starts unbound and degrades to the defaults until {@link attach}\n * binds the scope (called from a fiber that injects `settingsScope`). The\n * plugin keeps working everywhere it already did.\n *\n * @module dsh-question-nav/client/settings\n */\n\nimport type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'\nimport {\n ALIGN_FIELD, ALIGN_OPTIONS, DEFAULT_ALIGN, QUESTION_NAV_SETTINGS_NS,\n type AlignPreference,\n} from '../core/align.ts'\nimport {\n DEFAULT_PAGE_SIZE, PAGE_SIZE_FIELD, PAGE_SIZE_OPTIONS,\n type PageSize,\n} from '../core/page-size.ts'\n\n/** Narrow a raw section value to the anchor field. */\nfunction isAlignPreference(value: unknown): value is AlignPreference {\n return ALIGN_OPTIONS.some((option) => option === value)\n}\n\n/** Narrow a raw section value to the page-size field. */\nfunction isPageSize(value: unknown): value is PageSize {\n return (PAGE_SIZE_OPTIONS as readonly unknown[]).includes(value)\n}\n\n/** The minimal face of the settings scope service this controller needs.\n * Kept structural (bind only) so the controller stays decoupled from the\n * full service and is unit-testable with a stub binder. */\nexport interface SettingsScopeBinderLike {\n bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T>\n}\n\n/** Wire section this plugin owns (the Host schema's fields). */\ninterface QuestionNavSection {\n align?: unknown\n pageSize?: unknown\n}\n\n/** Snapshot consumed by the strip and the settings rows. */\nexport interface QuestionNavSettingsState {\n /** Last accepted anchor edge (default while the scope is absent/loading). */\n align: AlignPreference\n /** Last accepted page size (default while the scope is absent/loading). */\n pageSize: PageSize\n /** Whether the user layer overrides the composition default. */\n overridden: boolean\n}\n\n/** Reactive handle over the plugin's durable settings section. */\nexport class QuestionNavSettingsController {\n private scope: SettingsScope<QuestionNavSection> | undefined\n private readonly listeners = new Set<() => void>()\n private unsubscribe: () => void = () => {}\n private state: QuestionNavSettingsState = { align: DEFAULT_ALIGN, pageSize: DEFAULT_PAGE_SIZE, overridden: false }\n\n /**\n * Bind the namespace scope once the settings surface is present. Called\n * from a fiber that injects `settingsScope`, so the scope subscription\n * lives on that fiber and is released with it. A no-op after the first\n * bind.\n * @param binder - the settings scope service.\n */\n attach(binder: SettingsScopeBinderLike): void {\n if (this.scope !== undefined) return\n this.scope = binder.bind<QuestionNavSection>({ namespace: QUESTION_NAV_SETTINGS_NS })\n this.state = this.derive(this.scope.getSnapshot())\n this.unsubscribe = this.scope.subscribe(() => {\n if (this.scope === undefined) return\n const next = this.derive(this.scope.getSnapshot())\n if (next.align === this.state.align && next.pageSize === this.state.pageSize\n && next.overridden === this.state.overridden) return\n this.state = next\n for (const listener of this.listeners) listener()\n })\n }\n\n private derive(snapshot: SettingsScopeSnapshot<QuestionNavSection>): QuestionNavSettingsState {\n const user = snapshot.user as QuestionNavSection | undefined\n return {\n align: snapshot.status === 'ready' && isAlignPreference(snapshot.value?.align)\n ? snapshot.value.align\n : DEFAULT_ALIGN,\n pageSize: snapshot.status === 'ready' && isPageSize(snapshot.value?.pageSize)\n ? snapshot.value.pageSize\n : DEFAULT_PAGE_SIZE,\n overridden: user !== undefined && (user.align !== undefined || user.pageSize !== undefined),\n }\n }\n\n /** Release the scope subscription (bound on the settings fiber's lifecycle). */\n dispose(): void {\n this.unsubscribe()\n this.listeners.clear()\n }\n\n /** @returns the current state (stable reference until the next change). */\n getSnapshot(): QuestionNavSettingsState {\n return this.state\n }\n\n /** Observe state replacements; returns the disposer. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => { this.listeners.delete(listener) }\n }\n\n /** Route the user's anchor-edge choice to the Host document. */\n setAlign(align: AlignPreference): void {\n if (this.scope === undefined) return\n void this.scope.set(ALIGN_FIELD, align)\n }\n\n /** Route the user's page-size choice to the Host document. */\n setPageSize(pageSize: PageSize): void {\n if (this.scope === undefined) return\n void this.scope.set(PAGE_SIZE_FIELD, pageSize)\n }\n}\n","/**\n * Locale dictionaries for the question-nav surface (zh/en). Registered under\n * the `question-nav` namespace; keys are consumed through the bound translator.\n */\nexport const zh = {\n 'strip.empty': '本会话还没有提问',\n 'strip.up': '向上翻出更多提问圆点',\n 'strip.down': '向下翻出更多提问圆点',\n 'jump.inactive': '聊天视图未激活',\n 'jump.hidden': '目标无独立气泡,已定位到邻近内容',\n 'jump.notfound': '目标未加载或不存在(可能已压缩)',\n 'jump.timeout': '加载历史超时,可重试',\n 'settings.tab': '提问导航',\n 'settings.align.title': '导航条对齐',\n 'settings.align.desc': '选择圆点导航条锚定在对话栏的哪一侧。',\n 'settings.align.left': '左侧',\n 'settings.align.right': '右侧',\n 'settings.pagesize.title': '每次翻出数量',\n 'settings.pagesize.desc': '点击 ▲/▼ 三角按钮时,每次翻出的隐藏圆点数量。',\n} as const\n\nexport const en = {\n 'strip.empty': 'No questions in this session yet',\n 'strip.up': 'Reveal more question dots above',\n 'strip.down': 'Reveal more question dots below',\n 'jump.inactive': 'Chat view is not active',\n 'jump.hidden': 'No dedicated bubble; landed on nearby content',\n 'jump.notfound': 'Target not loaded or missing (maybe compacted)',\n 'jump.timeout': 'Timed out loading history; retry',\n 'settings.tab': 'Question Nav',\n 'settings.align.title': 'Rail alignment',\n 'settings.align.desc': 'Choose which edge of the conversation column the dot rail anchors to.',\n 'settings.align.left': 'Left',\n 'settings.align.right': 'Right',\n 'settings.pagesize.title': 'Dots per page',\n 'settings.pagesize.desc': 'How many hidden dots one click of the ▲/▼ paging buttons reveals.',\n} as const\n\nexport type QuestionNavKey = keyof typeof zh\n","/**\n * Pure node-indexing logic for the question-nav plugin. No React, no DOM, no\n * Cordis — every function here is a pure transform over chat-node data so it\n * can be unit-tested in isolation (and reused by the browser half).\n */\n\n/** One user question as shown in the strip and targeted by a jump. */\nexport interface QuestionNode {\n /** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */\n key: string\n /** Monotone anchor sequence for ordering and window-min detection. */\n anchorSeq: number\n /** Event seq of the user message. */\n seq: number\n /** Unix ms timestamp. */\n time: number\n /** Full question text — shown in the hover tooltip (not truncated). */\n text: string\n}\n\n/** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */\nexport interface ChatNodeLike {\n key: string\n anchorSeq: number\n visibility?: string\n kind?: string\n /** Kind-specific payload (a UserMessageNode for `user`/`steering`). */\n data?: unknown\n}\n\n/** Kinds counted as a user question (turn-opening and steering admissions). */\nexport const QUESTION_KINDS = ['user', 'steering'] as const\n\n/** Narrow `node.data` to the user-message payload we read. */\ninterface UserDataLike {\n content?: readonly { type?: string; text?: string }[]\n seq?: number\n time?: number\n}\n\nfunction userData(data: unknown): UserDataLike | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n return data as UserDataLike\n}\n\n/** First text block of a user message; falls back to the raw first block. */\nexport function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {\n if (content === undefined || content.length === 0) return ''\n const first = content[0]\n if (typeof first?.text === 'string') return first.text\n return ''\n}\n\n/** Extract the user questions from a chat-node window, ordered by anchorSeq. */\nexport function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[] {\n const out: QuestionNode[] = []\n for (const node of nodes) {\n if (!QUESTION_KINDS.includes(node.kind as (typeof QUESTION_KINDS)[number])) continue\n const payload = userData(node.data)\n out.push({\n key: node.key,\n anchorSeq: node.anchorSeq,\n seq: payload?.seq ?? -1,\n time: payload?.time ?? 0,\n // Full question text: shown in the hover tooltip (not truncated).\n text: messageText(payload?.content),\n })\n }\n out.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return out\n}\n\n/** Whether a node is actually rendered (visible rows only are scroll targets). */\nexport function isRenderable(node: ChatNodeLike): boolean {\n return node.visibility !== 'hidden'\n}\n\n/** The row of the window that renders the given key (exact match). */\nexport function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null {\n for (const node of nodes) {\n if (node.key === key) return node\n }\n return null\n}\n\n/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */\nexport function nearestRenderable(\n nodes: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,\n excludeKey: string | undefined,\n): { key: string; anchorSeq: number } | null {\n let best: { key: string; anchorSeq: number } | null = null\n for (const node of nodes) {\n if (node.visibility === 'hidden') continue\n if (node.key === excludeKey) continue\n if (best === null || node.anchorSeq < best.anchorSeq) best = { key: node.key, anchorSeq: node.anchorSeq }\n }\n return best\n}\n","/**\n * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot\n * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the\n * paging/timeout/fallback loop is unit-testable without a real browser or\n * session. The browser half wires these ports to ctx.sessions + the DOM.\n */\n\nimport { nearestRenderable } from './nodes.ts'\n\n/** The bits of a session snapshot the jump loop needs. */\nexport interface JumpSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n /** Renderable chat rows as a key->renderable map (or iterable of rows). */\n rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>\n}\n\nexport interface JumpPorts {\n /** Read the current snapshot; undefined when the session/view is unavailable. */\n snapshot: () => JumpSnapshot | undefined\n /** Expand the window backwards; rejects/throws on failure. */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Find the DOM row for a chat anchor key; null when not rendered. */\n findRow: (key: string) => HTMLElement | null\n /** Scroll a row into view at the top. */\n scrollIntoView: (row: HTMLElement) => void\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n /** Report a terminal failure to the caller (for a hint). */\n report?: (code: JumpFailureCode, fallback?: boolean) => void\n}\n\nexport type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'\n\nexport interface JumpResult {\n ok: boolean\n code?: JumpFailureCode\n /** True when we landed on a fallback row rather than the exact target. */\n fallback?: boolean\n}\n\nexport interface JumpOptions {\n /** Total wall-clock budget for loadOlder paging. */\n totalTimeoutMs?: number\n /** Max loadOlder pages before giving up. */\n maxPages?: number\n /** Poll interval for the row to render after it is known to be in the window. */\n rowWaitMs?: number\n /** Poll interval for state transitions (loadingOlder / openState). */\n pollMs?: number\n}\n\nconst DEFAULTS = {\n totalTimeoutMs: 15_000,\n maxPages: 100,\n rowWaitMs: 8_000,\n pollMs: 60,\n}\n\nfunction minAnchorSeq(rows: Iterable<{ anchorSeq: number }>): number | null {\n let min: number | null = null\n for (const row of rows) {\n if (min === null || row.anchorSeq < min) min = row.anchorSeq\n }\n return min\n}\n\nfunction renderable(rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>): { key: string; anchorSeq: number }[] {\n const out: { key: string; anchorSeq: number }[] = []\n for (const row of rows) {\n if (row.visibility === 'hidden') continue\n out.push({ key: row.key, anchorSeq: row.anchorSeq })\n }\n return out\n}\n\n/**\n * Jump to the row for `key`, paging older content until it is rendered (or the\n * budget is exhausted). Falls back to the nearest renderable row when the\n * exact row is hidden/absent.\n */\nexport async function jumpToQuestion(ports: JumpPorts, key: string, options: JumpOptions = {}): Promise<JumpResult> {\n const cfg = { ...DEFAULTS, ...options }\n const fail = (code: JumpFailureCode, fallback = false): JumpResult => {\n ports.report?.(code, fallback)\n return fallback ? { ok: false, code, fallback: true } : { ok: false, code }\n }\n\n if (!ports.isViewActive()) return fail('VIEW_INACTIVE')\n\n const deadline = ports.now() + cfg.totalTimeoutMs\n let pages = 0\n\n // Phase 1: page older until the key appears in the loaded window.\n while (true) {\n const snap = ports.snapshot()\n if (snap === undefined) return fail('VIEW_INACTIVE')\n const rows = renderable(snap.rows)\n if (rows.some((r) => r.key === key)) break\n if (snap.openState !== 'open') {\n if (snap.openState === 'error' || ports.now() > deadline) {\n return fail(snap.openState === 'error' ? 'VIEW_INACTIVE' : 'TIMEOUT')\n }\n await ports.sleep(cfg.pollMs)\n continue\n }\n if (snap.hasMore !== true) return fail('NOT_FOUND')\n if (pages >= cfg.maxPages || ports.now() > deadline) return fail('TIMEOUT')\n if (snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n const before = minAnchorSeq(rows)\n await ports.loadOlder()\n pages += 1\n const afterSnap = ports.snapshot()\n const after = minAnchorSeq(afterSnap === undefined ? [] : afterSnap.rows)\n if (after === null || (before !== null && after >= before)) return fail('NOT_FOUND')\n }\n\n // Phase 2: wait for the row to render, then scroll. Fall back if hidden.\n const waitedFor = async (rowKey: string): Promise<HTMLElement | null> => {\n for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {\n if (!ports.isViewActive()) return null\n const row = ports.findRow(rowKey)\n if (row !== null) return row\n await ports.sleep(cfg.pollMs)\n }\n return null\n }\n\n const row = await waitedFor(key)\n if (row !== null) {\n ports.scrollIntoView(row)\n return { ok: true }\n }\n\n const snap = ports.snapshot()\n const fallback = nearestRenderable(snap === undefined ? [] : snap.rows, key)\n if (fallback !== null) {\n const fbRow = await waitedFor(fallback.key)\n if (fbRow !== null) {\n ports.scrollIntoView(fbRow)\n return fail('TARGET_HIDDEN', true)\n }\n }\n return fail('TARGET_HIDDEN', false)\n}\n","/**\n * Browser-half entry for the dsh-question-nav plugin.\n *\n * Registers one surface into the frame-wide floating layer (`shell.overlay`):\n * a vertical strip on the LEFT edge of the conversation column listing every\n * user question in the current session as a small button, one dot per turn.\n * Clicking a button scrolls the chat to that turn's first question.\n *\n * The dots are driven by the host-folded `questionIndex` session projection\n * (registered by the plugin's host half): the projection registry folds the\n * WHOLE event log without touching the chat's paged render window, the\n * projection cache persists it, and the standard carriers (history tail-page\n * baseline + session/projection push frames) keep it live. Live-window\n * questions not yet recorded by the projection are merged on top so a\n * just-sent question appears immediately.\n *\n * Failure policy: nothing here throws at apply time — an external plugin must\n * never take the GUI down.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\n// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab')\n// and the ctx.settingsScope Context merge.\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport type { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport { QuestionNavSettingsTab } from './QuestionNavSettingsTab.tsx'\nimport { QuestionNavSettingsController } from './settings.ts'\nimport { en, zh, type QuestionNavKey } from './locales.ts'\nimport { extractQuestions } from '../core/nodes.ts'\nimport { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'\n\n/** Locale namespace this plugin owns. */\nconst NS = 'question-nav'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** question-nav surface copy. */\n 'question-nav': QuestionNavKey\n }\n}\n\n/** Services required by this plugin. */\nexport const inject = ['slots', 'locale', 'sessions']\n\n/** Single-instance guard: a duplicated client injection must not mount twice. */\ndeclare global {\n // eslint-disable-next-line no-var\n var __dshQuestionNavApplied: boolean | undefined\n}\n\nfunction claimApply(): boolean {\n if (globalThis.__dshQuestionNavApplied === true) return false\n globalThis.__dshQuestionNavApplied = true\n return true\n}\n\nfunction releaseApply(): void {\n globalThis.__dshQuestionNavApplied = undefined\n}\n\n/** Map the session snapshot to the jump-loop port surface. */\nfunction jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {\n return {\n snapshot: () => {\n const binding = ctx.sessions.binding(sessionId)\n const snap = binding?.session.getSnapshot()\n if (snap === undefined) return undefined\n return {\n openState: snap.openState,\n hasMore: snap.hasMore,\n loadingOlder: snap.loadingOlder,\n rows: snap.chat.nodes.values(),\n }\n },\n loadOlder: async () => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) throw new Error('session unavailable')\n await binding.session.loadOlder()\n },\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n findRow: (key: string) => {\n for (const candidate of Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))) {\n if (candidate.dataset.chatAnchorKey === key) return candidate\n }\n return null\n },\n scrollIntoView: (row) => row.scrollIntoView({ block: 'start' }),\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\n/**\n * The session's `questionIndex` projection face (getSnapshot + subscribe).\n * Undefined when the session is not bound or the host unit is not registered\n * (e.g. a headless composition) — the strip then shows live-window dots only.\n */\nfunction questionProjectionOf(ctx: ClientContext, sessionId: SessionId): ObservableFace | undefined {\n const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf('questionIndex')\n if (face === undefined) return undefined\n return {\n getSnapshot: () => face.getSnapshot(),\n subscribe: (listener) => face.subscribe(listener),\n }\n}\n\nfunction createInject(ctx: ClientContext, settings: QuestionNavSettingsController): QuestionNavInjected {\n return {\n readQuestions: (sessionId) => {\n const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()\n if (snap === undefined) return []\n return extractQuestions(snap.chat.nodes.values())\n },\n subscribeList: (cb) => ctx.sessions.list.subscribe(cb),\n subscribeContent: (sessionId, cb) => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return () => {}\n return binding.session.subscribe(cb)\n },\n questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),\n jump: (sessionId, key) => {\n const ports = jumpPortsFor(ctx, sessionId)\n ports.report = (code: JumpFailureCode) => {\n // Surface the failure through the component via a DOM event the\n // strip listens for; simplest reliable cross-boundary channel here.\n window.dispatchEvent(new CustomEvent('question-nav:jump-failed', { detail: code }))\n }\n void jumpToQuestion(ports, key)\n },\n align: () => settings.getSnapshot().align,\n subscribeSettings: (cb) => settings.subscribe(cb),\n setAlign: (align) => settings.setAlign(align),\n pageSize: () => settings.getSnapshot().pageSize,\n setPageSize: (pageSize) => settings.setPageSize(pageSize),\n }\n}\n\n/**\n * Register the question-nav surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n if (!claimApply()) return\n ctx.effect(() => releaseApply, 'question-nav: apply claim')\n\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')\n const t = ctx.locale.bind(NS)\n\n const settings = new QuestionNavSettingsController()\n const injected = createInject(ctx, settings)\n\n ctx.slots.inject('shell.overlay', () => ctx.slots.register({\n name: 'shell.overlay',\n id: 'question-nav',\n order: 900,\n locale: NS,\n inject: () => injected,\n }, QuestionNavStrip))\n\n // The settings surface is optional and may apply after this plugin, so the\n // namespace binds and the settings page mounts in a fiber that waits for\n // it. Without it the strip simply keeps the default left alignment.\n ctx.inject(['settingsScope'], (scopeCtx) => {\n settings.attach(scopeCtx.get('settingsScope') as SettingsScopeBinder)\n ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({\n name: 'settings.plugins.tab',\n id: 'question-nav',\n order: 100,\n label: () => t('settings.tab'),\n locale: NS,\n inject: () => injected,\n }, QuestionNavSettingsTab))\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"client.js","names":["kind","useState","useRef","styles","createPortal","useState","styles"],"sources":["../src/core/turn-dots.ts","../src/core/focus.ts","../src/core/active-dot.ts","../src/core/time.ts","../src/client/QuestionNavStrip.tsx","../src/core/align.ts","../src/core/page-size.ts","../src/client/QuestionNavSettingsTab.tsx","../src/client/settings.ts","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Turn-aligned dot model for the question-nav strip. Pure transforms over the\n * `questionIndex` projection value plus the live chat window — no React, no\n * DOM — so the grouping and merging rules are unit-testable in isolation.\n *\n * One dot per turn that claimed at least one user question; turns without a\n * question (retry, goal continuation, cancelled empty turns) produce no dot,\n * so dot turn labels may skip numbers but always match the Trajectory view.\n */\n\nimport type { QuestionEntry } from './question-entry.ts'\nimport type { QuestionNode } from './nodes.ts'\n\n/** The conversation Definition kind whose key a user question node uses. */\nexport const MESSAGE_DEFINITION_KIND = 'input-message'\n\n/**\n * The engine-owned stable chat key for a user question — mirrors\n * `conversationContextKey('input-message', String(id))` from the DSH runtime\n * (verified against that formula in the unit test).\n */\nexport function questionKey(id: unknown): string {\n const kind = MESSAGE_DEFINITION_KIND\n return `${kind.length}:${kind}${String(id)}`\n}\n\n/** One strip dot: a turn's questions (grouped) or one ungrouped live question. */\nexport interface TurnDot {\n /** Owning turn number; null for live questions the projection has not seen. */\n readonly turn: number | null\n /** Jump anchor: the chat key of the turn's FIRST question. */\n readonly key: string\n /** Anchor seq of the first question (ordering + jump target). */\n readonly anchorSeq: number\n /** Unix ms of the first question. */\n readonly time: number\n /** Every question text of this dot, in order (tooltip lists them all). */\n readonly texts: readonly string[]\n /** Chat keys of every question folded into this dot (live-merge dedupe). */\n readonly memberKeys: readonly string[]\n}\n\n/** True when entries are already in non-decreasing seq order (the projection\n * appends in event order, so this is the common case and skips the sort). */\nfunction isSortedBySeq(entries: readonly QuestionEntry[]): boolean {\n for (let i = 1; i < entries.length; i++) {\n if (entries[i].seq < entries[i - 1].seq) return false\n }\n return true\n}\n\n/**\n * Fold the projection's question list into one dot per turn. Entries arrive\n * in event order; consecutive same-turn entries merge into a single dot whose\n * anchor is the turn's first question. The input is expected to be sorted by\n * seq; the defensive sort is skipped when it already is, so a long session\n * never pays an O(n log n) sort on every content update.\n */\nexport function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {\n const sorted = isSortedBySeq(entries) ? entries : [...entries].sort((a, b) => a.seq - b.seq)\n const dots: TurnDot[] = []\n for (const entry of sorted) {\n const key = questionKey(entry.id)\n const last = dots.at(-1)\n if (last !== undefined && last.turn === entry.turn) {\n dots[dots.length - 1] = {\n ...last,\n texts: [...last.texts, entry.text],\n memberKeys: [...last.memberKeys, key],\n }\n continue\n }\n dots.push({\n turn: entry.turn,\n key,\n anchorSeq: entry.seq,\n time: entry.time,\n texts: [entry.text],\n memberKeys: [key],\n })\n }\n return dots\n}\n\n/**\n * Merge live-window questions the projection has not recorded yet (the brief\n * window before the session/projection push frame lands). A live question\n * whose key is already folded into a dot is dropped (the projected copy\n * wins); the rest become single-question dots with `turn: null`, inserted in\n * anchor-seq order so the strip stays strictly chronological.\n *\n * Fast path: when nothing new arrives the SAME array is returned (no copy),\n * so the caller can bail out of a re-render on identical reference.\n */\nexport function mergeLiveQuestions(\n dots: readonly TurnDot[],\n live: readonly QuestionNode[],\n): TurnDot[] {\n if (live.length === 0) return dots as TurnDot[]\n const known = new Set<string>()\n for (const dot of dots) {\n for (const key of dot.memberKeys) known.add(key)\n }\n const extras: TurnDot[] = []\n for (const question of live) {\n if (known.has(question.key)) continue\n extras.push({\n turn: null,\n key: question.key,\n anchorSeq: question.anchorSeq,\n time: question.time,\n texts: [question.text],\n memberKeys: [question.key],\n })\n }\n // Nothing new from the live window — reuse the input array unchanged.\n if (extras.length === 0) return dots as TurnDot[]\n // Both `dots` and `extras` are sorted by anchorSeq (dots from the projection\n // order, extras from the live window order): merge linearly instead of\n // re-sorting the whole list. Ties keep the projected dot first (stable\n // sort semantics), matching the previous [...dots, ...extras].sort().\n const out: TurnDot[] = []\n let i = 0\n for (const extra of extras) {\n while (i < dots.length && dots[i].anchorSeq <= extra.anchorSeq) {\n out.push(dots[i])\n i += 1\n }\n out.push(extra)\n }\n while (i < dots.length) {\n out.push(dots[i])\n i += 1\n }\n return out\n}\n","/**\n * Focus-magnification model for the question-nav rail. Pure index/scale math\n * for the hover-selected dot's neighborhood window, its progressive\n * magnification tiers, and the vertical cascade of question cards (one per\n * window dot, non-overlapping, sizes descending away from the selected one).\n * No React, no DOM — every function here is unit-testable in isolation and\n * safe to inline into the client bundle.\n *\n * @module dsh-question-nav/focus\n */\n\n/** How many dots stay enlarged on each side of the selected dot. */\nexport const FOCUS_RADIUS = 2\n\n/** Magnification scale per tier, by distance from the selected dot\n * (0 = selected, 1 = immediate neighbor, 2 = outer window edge). */\nexport const FOCUS_SCALES = [2.0, 1.55, 1.25] as const\n\n/**\n * The focus tier of a dot at `distance` from the selected dot: 0..FOCUS_RADIUS\n * while inside the magnification window, null beyond it (base scale).\n */\nexport function focusTier(distance: number): number | null {\n const d = Math.abs(distance)\n if (d > FOCUS_RADIUS) return null\n return d\n}\n\n/** Magnification scale for a dot at `distance`; 1 (base) outside the window. */\nexport function focusScale(distance: number): number {\n const tier = focusTier(distance)\n return tier === null ? 1 : FOCUS_SCALES[tier]\n}\n\n/** Presentation metrics for the question card of a dot at `distance` from the\n * selected one. The hierarchy is two-tier: the selected card is the focus\n * (widest, full text, brand accent — styled via the selected CSS class), the\n * four neighbors are context cards (same clean surface, narrower, clamped to\n * fewer lines). Cards are never dimmed or blurred: context must stay crisp\n * and readable so it can be recognized — and clicked. Null outside the\n * window (no card). */\nexport interface FocusCardMetrics {\n /** Card width in px (narrows away from the selected card). */\n widthPx: number\n /** Text font size (px); cascade cards are slightly smaller. */\n fontSize: number\n /** Max text lines before clamping (cascade cards). */\n maxLines: number\n}\n\nexport function focusCardMetrics(distance: number): FocusCardMetrics | null {\n const tier = focusTier(distance)\n if (tier === null) return null\n switch (tier) {\n case 0:\n return { widthPx: 380, fontSize: 13, maxLines: 6 }\n case 1:\n return { widthPx: 300, fontSize: 12.5, maxLines: 2 }\n default:\n return { widthPx: 240, fontSize: 12, maxLines: 1 }\n }\n}\n\n/**\n * Indices of the focus window: the selected dot plus `radius` on each side,\n * clipped to the dot list. Empty when the list is empty or `selected` is out\n * of range.\n */\nexport function magnificationWindow(total: number, selected: number, radius: number = FOCUS_RADIUS): number[] {\n if (total <= 0 || selected < 0 || selected >= total || radius < 0) return []\n const out: number[] = []\n const lo = Math.max(0, selected - radius)\n const hi = Math.min(total - 1, selected + radius)\n for (let i = lo; i <= hi; i++) out.push(i)\n return out\n}\n\n/**\n * Paging model for the dot band. Overflowing dots are not auto-scrolled by\n * hovering the edges or hovering a clipped dot: instead the user pages them\n * with two triangle buttons (▲ above the dot queue, ▼ below it), each click\n * revealing `DOT_PAGE_ROWS` hidden dots. Pure arithmetic — no React, no DOM.\n */\n\n/** How many hidden dots one click of a paging triangle reveals. */\nexport const DOT_PAGE_ROWS = 5\n\n/** Base dot diameter (px) and gap (px), matching the rail CSS — the paging\n * step is expressed in whole dot rows, so it tracks the visible geometry. */\nexport const DOT_SIZE = 8\nexport const DOT_GAP = 6\n\n/** Scroll step (px) for one paging click: `rows` whole dot rows. */\nexport function pageStep(dotSize: number = DOT_SIZE, gap: number = DOT_GAP, rows: number = DOT_PAGE_ROWS): number {\n return rows * (dotSize + gap)\n}\n\n/** Clamp a target scrollTop into the band's valid range (0..maxScroll). */\nexport function clampScrollTop(target: number, maxScroll: number): number {\n return Math.max(0, Math.min(target, Math.max(0, maxScroll)))\n}\n\n/** Whether hidden dots remain above the band (show ▲). */\nexport function canScrollAbove(scrollTop: number): boolean {\n return scrollTop > 0\n}\n\n/** Whether hidden dots remain below the band (show ▼). */\nexport function canScrollBelow(scrollTop: number, maxScroll: number): boolean {\n return scrollTop < maxScroll\n}\n","/**\n * Scroll-spy rule for the question-nav strip: which dot marks the reader's\n * current position in the chat. Pure over pre-measured row tops (scrollport\n * coordinates), so the decision is unit-testable without a DOM — the browser\n * half only measures and feeds rows in dot order.\n */\n\n/** One rendered dot row's position relative to the scrollport's top edge. */\nexport interface ActiveDotRow {\n /** Dot key (the turn's jump anchor). */\n key: string\n /** Row top minus scrollport top, in px. */\n top: number\n}\n\n/**\n * Pick the dot that marks the reader's position: the LAST row (in dot order)\n * whose top sits at or above the reading line — the turn the reader has\n * reached. When the line sits above every rendered row (reading the intro\n * before the first question), the FIRST row marks the upcoming turn instead,\n * so the marker is always present while questions are on screen. Returns null\n * when no dot row is rendered (trajectory view, or a window that has not\n * mounted any question yet).\n *\n * Rows are expected in dot order; tops are not re-sorted — the scan keeps the\n * last qualifying entry, so one stale out-of-order measurement never moves\n * the marker backwards past a later row.\n */\nexport function pickActiveDot(rows: readonly ActiveDotRow[], readingLine: number): string | null {\n if (rows.length === 0) return null\n let active: string | null = null\n for (const row of rows) {\n if (row.top <= readingLine) active = row.key\n }\n return active ?? rows[0].key\n}\n\n/**\n * Reading-line offset from the scrollport top: 35% of the visible height,\n * clamped to at least 48px so short viewports keep a usable top margin. The\n * marker tracks the turn at this line rather than the exact top edge, which\n * reads as \"the question I am looking at\" instead of \"the question that just\n * left the screen\".\n */\nexport function readingLineOffset(scrollportHeight: number): number {\n return Math.max(48, scrollportHeight * 0.35)\n}\n","/**\n * Question sent-time formatting for the question-nav surface. Pure: no DSH\n * imports, deterministic for a given (time, now) pair — unit-testable in\n * isolation and safe to inline into the client bundle.\n *\n * @module dsh-question-nav/time\n */\n\nfunction pad2(n: number): string {\n return n < 10 ? `0${n}` : String(n)\n}\n\n/** Whether `time` falls on the same calendar day as `now`. */\nexport function isSameDay(time: number, now: number): boolean {\n const a = new Date(time)\n const b = new Date(now)\n return a.getFullYear() === b.getFullYear()\n && a.getMonth() === b.getMonth()\n && a.getDate() === b.getDate()\n}\n\n/**\n * Smart question sent-time, relative to `now`:\n * - same calendar day → `HH:MM`\n * - same calendar year → `MM-DD HH:MM`\n * - otherwise → `YYYY-MM-DD HH:MM`\n *\n * Returns `''` for a missing/invalid timestamp (e.g. a live node that never\n * reported one), so callers can hide the time line without branching.\n */\nexport function formatQuestionTime(time: number, now: number): string {\n if (!Number.isFinite(time) || time <= 0) return ''\n const d = new Date(time)\n const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`\n if (isSameDay(time, now)) return hm\n const md = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`\n if (d.getFullYear() === new Date(now).getFullYear()) return `${md} ${hm}`\n return `${d.getFullYear()}-${md} ${hm}`\n}\n","/**\n * Question-nav minimap. Renders a vertical column of small round dots overlaid\n * on the LEFT edge of the conversation column (via the frame-wide\n * `shell.overlay` floating layer): one dot per turn that claimed at least one\n * user question — strictly aligned with the Trajectory view's turn numbering\n * (turns without a question produce no dot). The dot column is vertically\n * centered and clamped to at most 60% of the conversation height, scrolling\n * within that band when the session has more dots than fit.\n *\n * Hovering a dot \"selects\" it: the selected dot plus its two immediate\n * neighbors on each side magnify (progressively smaller with distance), and a\n * vertical cascade of question cards opens — the selected (center) card is the\n * focus (brand accent, elevated, full question text), the four neighbors are\n * narrower context cards clamped to fewer lines. Every card is clickable and\n * jumps to its question, exactly like clicking the dot. Hovering never scrolls\n * the band — the dot column stays put while you browse.\n *\n * Overflow is paged, not auto-scrolled: two small triangle buttons in the dot\n * style sit above and below the dot queue (▲ / ▼), each click revealing one\n * page of hidden dots (the page size is configurable in the plugin settings,\n * default 5) with a staggered pop-in animation as click feedback. The native\n * scrollbar stays hidden; the triangles themselves are the overflow cue (each\n * appears only while its direction has more to reveal) — no hover auto-scroll\n * of any kind.\n *\n * A scroll-spy marks the reader's position: the dot of the turn at the\n * reading line (35% down the scrollport) carries a ring marker that follows\n * the conversation as it scrolls, streams, or pages its window. The marker\n * never scrolls the band — it is purely a position indicator.\n *\n * Data source: the host-folded `questionIndex` session projection (whole\n * history, persisted host-side, pushed live through session/projection\n * frames) read through the injected `questionProjection` face, plus the live\n * chat window's questions merged on top for the brief window before a\n * just-sent question lands in the projection. No render-window expansion, no\n * client-side history paging.\n *\n * Data arrives through the props shares: the framework `useSessions` hook\n * (current session), the registrant inject face (read/subscribe/project/\n * jump), and the bound locale translator.\n */\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\nimport type { QuestionNode } from '../core/nodes.ts'\nimport type { QuestionEntry } from '../core/question-entry.ts'\nimport { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'\nimport type { AlignPreference } from '../core/align.ts'\nimport type { PageSize } from '../core/page-size.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport { DOT_GAP, DOT_SIZE, FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, pageStep } from '../core/focus.ts'\nimport { pickActiveDot, readingLineOffset, type ActiveDotRow } from '../core/active-dot.ts'\nimport { formatQuestionTime } from '../core/time.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Minimal observable shape of a session projection face. */\nexport interface ObservableFace {\n /** Current projection value (unknown — validated structurally at read). */\n getSnapshot: () => unknown\n /** Subscribe to value changes; returns an unsubscribe. */\n subscribe: (listener: () => void) => () => void\n}\n\n/** Values the registrant inject face supplies (wired in src/client/index.ts). */\nexport interface QuestionNavInjected {\n /** Extract the user questions of a session's currently loaded window. */\n readQuestions: (sessionId: SessionId) => QuestionNode[]\n /** Subscribe to the session list; returns an unsubscribe. */\n subscribeList: (cb: () => void) => () => void\n /** Subscribe to a session's content; returns an unsubscribe. */\n subscribeContent: (sessionId: SessionId, cb: () => void) => () => void\n /** The session's `questionIndex` projection face, when the host unit is registered. */\n questionProjection: (sessionId: SessionId) => ObservableFace | undefined\n /** Jump the chat to a question row (pages the window on demand). */\n jump: (sessionId: SessionId, key: string) => void\n /** Current rail anchor edge (defaults to 'left' before the settings section is ready). */\n align: () => AlignPreference\n /** Observe plugin-settings changes (align, page size); returns an unsubscribe. */\n subscribeSettings: (cb: () => void) => () => void\n /** Persist a new anchor edge. */\n setAlign: (align: AlignPreference) => void\n /** Current ▲/▼ page size (defaults to DEFAULT_PAGE_SIZE before the settings section is ready). */\n pageSize: () => PageSize\n /** Persist a new page size. */\n setPageSize: (pageSize: PageSize) => void\n}\n\ntype ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\nconst FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {\n VIEW_INACTIVE: 'jump.inactive',\n TARGET_HIDDEN: 'jump.hidden',\n NOT_FOUND: 'jump.notfound',\n TIMEOUT: 'jump.timeout',\n}\n\n/** One card of the focus cascade: a dot inside the magnification window. */\ninterface FocusWindowItem {\n dot: TurnDot\n /** Distance from the selected dot (0 = selected, 1/2 = cascade neighbors). */\n distance: number\n}\n\n/** The focus cascade: a vertical column of one card per window dot,\n * non-overlapping, centered on the selected dot. */\ninterface FocusState {\n /** The selected (hovered) dot's key. */\n key: string\n /** Every dot of the focus window, in dot order. */\n items: FocusWindowItem[]\n /** Vertical center (viewport px) of the selected dot — the column centers on it. */\n centerY: number\n /** Horizontal anchor — left edge (left-aligned rail) or right edge (right-aligned). */\n left?: number\n right?: number\n}\n\n/** Read the projection face value as a question-entry list (structural guard). */\nfunction projectionEntries(face: ObservableFace | undefined): QuestionEntry[] {\n const value = face?.getSnapshot()\n if (!Array.isArray(value)) return []\n return value.filter((item): item is QuestionEntry =>\n typeof item === 'object' && item !== null\n && typeof (item as QuestionEntry).id === 'string'\n && typeof (item as QuestionEntry).seq === 'number'\n && typeof (item as QuestionEntry).turn === 'number')\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\n/** Structural equality of two dot lists (member keys fully capture a dot's\n * folded questions, so identical key sequences mean identical content).\n * Lets the strip skip a re-render when a refresh produced no change. */\nfunction sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i++) {\n const da = a[i]\n const db = b[i]\n if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length) return false\n for (let j = 0; j < da.memberKeys.length; j++) {\n if (da.memberKeys[j] !== db.memberKeys[j]) return false\n }\n }\n return true\n}\n\nexport function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {\n const current = props.useSessions((s) => s.current)\n const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))\n const visible = current !== undefined && summary !== undefined && summary.blank !== true\n\n const [dots, setDots] = useState<TurnDot[]>([])\n const [jumpingKey, setJumpingKey] = useState<string | null>(null)\n const [hint, setHint] = useState<string | null>(null)\n const [focus, setFocus] = useState<FocusState | null>(null)\n // Scroll-spy: the dot marking the reader's current turn (null outside chat).\n const [currentKey, setCurrentKey] = useState<string | null>(null)\n const [align, setAlign] = useState<AlignPreference>(() => props.align())\n // Dots revealed per ▲/▼ click (settings scope; drives pageBy's step).\n const [pageSize, setPageSize] = useState<PageSize>(() => props.pageSize())\n const panelRef = useRef<HTMLDivElement | null>(null)\n const listRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n // Pending focus-clear timer: lets the mouse move from the rail onto a\n // cascade card without dropping the cascade mid-flight.\n const clearFocusTimerRef = useRef<number | null>(null)\n // Last rendered dot list, for the change-detection bail-out below.\n const lastDotsRef = useRef<TurnDot[]>([])\n // Whether the dot band overflows its 60% clamp (drives the ▲/▼ paging\n // buttons).\n const [scrollable, setScrollable] = useState(false)\n // Scroll offset of the band + its max offset: drives the ▲/▼ visibility\n // (each direction hides once there is nothing more to reveal).\n const [scrollPos, setScrollPos] = useState<{ top: number; max: number }>({ top: 0, max: 0 })\n // Dots revealed by the latest ▲/▼ click: key → stagger order + travel\n // direction. Drives a short staggered pop-in animation so the click has a\n // visible effect; cleared by a timer once the animation has played out.\n const [revealed, setRevealed] = useState<ReadonlyMap<string, { dir: 1 | -1; order: number }> | null>(null)\n const revealTimerRef = useRef<number | null>(null)\n\n const syncScroll = (): void => {\n const list = listRef.current\n if (list === null) return\n const max = Math.max(0, list.scrollHeight - list.clientHeight)\n setScrollPos({ top: list.scrollTop, max })\n }\n\n // Page the band by one DOT_PAGE_ROWS click in the given direction, then mark\n // the dots the page just brought into view so they play a staggered pop-in\n // animation in the direction of travel (▼ → rise from below, ▲ → drop from\n // above) — the visual confirmation that the click revealed new dots.\n const pageBy = (dir: 1 | -1): void => {\n const list = listRef.current\n if (list === null) return\n const max = Math.max(0, list.scrollHeight - list.clientHeight)\n const target = clampScrollTop(list.scrollTop + dir * pageStep(DOT_SIZE, DOT_GAP, pageSize), max)\n if (target === list.scrollTop) return\n // Snapshot which dots are geometrically inside the band before the jump\n // (the container rect does not move when its content scrolls).\n const band = list.getBoundingClientRect()\n const els = Array.from(list.querySelectorAll<HTMLElement>('[data-question-nav-key]'))\n const wasVisible = els.map((el) => {\n const r = el.getBoundingClientRect()\n return r.bottom > band.top && r.top < band.bottom\n })\n list.scrollTop = target\n syncScroll()\n const next = new Map<string, { dir: 1 | -1; order: number }>()\n const fresh: string[] = []\n els.forEach((el, i) => {\n if (wasVisible[i]) return\n const r = el.getBoundingClientRect()\n const key = el.dataset.questionNavKey\n if (key !== undefined && r.bottom > band.top && r.top < band.bottom) {\n fresh.push(key)\n }\n })\n // Stagger radiates from the clicked triangle: paging down (▼) starts at\n // the bottom of the fresh batch, paging up (▲) at the top.\n if (dir === 1) fresh.reverse()\n fresh.forEach((key, order) => next.set(key, { dir, order }))\n if (next.size === 0) return\n setRevealed(next)\n if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current)\n // Animation (320ms) + per-dot stagger (35ms each) + slack.\n revealTimerRef.current = window.setTimeout(() => setRevealed(null), 320 + next.size * 35 + 120)\n }\n\n const canPageUp = scrollable && scrollPos.top > 0\n const canPageDown = scrollable && scrollPos.top < scrollPos.max\n\n // Focus persists briefly after leaving the rail, so the mouse can reach the\n // clickable cascade cards; entering a card cancels the clear, leaving\n // everything re-arms it.\n const cancelClearFocus = (): void => {\n if (clearFocusTimerRef.current !== null) {\n window.clearTimeout(clearFocusTimerRef.current)\n clearFocusTimerRef.current = null\n }\n }\n const scheduleClearFocus = (): void => {\n cancelClearFocus()\n clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240)\n }\n\n // Follow the plugin settings from the settings scope (an align change\n // re-anchors the rail through the layout effect below; a page-size change\n // steps the ▲/▼ paging).\n useEffect(() => props.subscribeSettings(() => {\n setAlign(props.align())\n setPageSize(props.pageSize())\n }), [props])\n\n const showHint = (message: string): void => {\n setHint(message)\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)\n }\n\n // Recompute the dot list from the projection + live window. Subscribed to\n // the projection push frames and the session's content only — session\n // switches are covered by `current` below (the effect re-runs on change),\n // so the session-list feed is not subscribed: it would re-run the full\n // recompute for unrelated list churn.\n useEffect(() => {\n if (!visible || current === undefined) {\n setDots([])\n lastDotsRef.current = []\n return\n }\n const sessionId = current\n const face = props.questionProjection(sessionId)\n const refresh = (): void => {\n const grouped = groupQuestionsByTurn(projectionEntries(face))\n const next = mergeLiveQuestions(grouped, props.readQuestions(sessionId))\n // Identical content (a streaming update that added no question): re-use\n // the previous array reference so React bails out of re-rendering the\n // strip — the common case during assistant streaming.\n if (sameDots(next, lastDotsRef.current)) {\n setDots(lastDotsRef.current)\n return\n }\n lastDotsRef.current = next\n setDots(next)\n }\n refresh()\n const unsubProjection = face?.subscribe(refresh) ?? (() => {})\n const unsubContent = props.subscribeContent(sessionId, refresh)\n return () => {\n unsubProjection()\n unsubContent()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [visible, current, props])\n\n // Listen for jump-failure events and surface the hint.\n useEffect(() => {\n const onJumpFailed = (event: Event): void => {\n const code = (event as CustomEvent<JumpFailureCode>).detail\n showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'))\n }\n window.addEventListener('question-nav:jump-failed', onJumpFailed)\n return () => window.removeEventListener('question-nav:jump-failed', onJumpFailed)\n }, [props])\n\n // Anchor the minimap to the conversation column: position it at the left\n // edge of the conversation root and reserve a thin rail with padding-left.\n //\n // A layout-correction loop keeps the rail pinned to the conversation even\n // when an outside panel (e.g. a browser-extension sidebar like Doubao) moves\n // or resizes the frame without resizing the browser window: `window resize`\n // never fires for in-page panels, and a ResizeObserver on the conversation\n // root alone misses remounts and the tail of the frame's grid-column\n // transition, which used to leave the rail drifting into the session list.\n // The loop runs while the layout is still moving, then parks itself after a\n // few stable frames; observers re-wake it on the next change.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let idle = 0\n let stopped = false\n let observer: ResizeObserver | null = null\n const observed = { frame: null as Element | null, convRoot: null as Element | null }\n\n const applyLayout = (): boolean => {\n const panel = panelRef.current\n if (panel === null) return false\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n // Keep looping while the anchors are not both present (conversation not\n // mounted yet / mid-reflow), so a late mount still aligns.\n if (frame === null || convRoot === null) return true\n // Keep observing the live nodes: the conversation root may remount\n // (e.g. after a panel-triggered reflow), which silently detaches an\n // earlier ResizeObserver target.\n if (observer !== null) {\n if (observed.frame !== frame) {\n observer.observe(frame, { box: 'border-box' })\n observed.frame = frame\n }\n if (observed.convRoot !== convRoot) {\n observer.observe(convRoot, { box: 'border-box' })\n observed.convRoot = convRoot\n }\n }\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n // Never snap onto a transient box: keep correcting until the\n // conversation has a real footprint again.\n if (convRect.height <= 0 || convRect.width <= 0) return true\n const top = `${convRect.top - frameRect.top}px`\n const height = `${convRect.height}px`\n // Anchor to the configured edge: keep the other edge's inline style\n // cleared so the CSS class (left: auto on .railRight) owns it.\n if (align === 'right') {\n // Give back the conversation scrollbar's layout width so the 44px\n // rail never covers the bar: the WebKit path consumes 8px at the\n // column's right edge, which the rail would otherwise render on top\n // of, making the bar unclickable. ui-theme mirrors that width as\n // --dsh-scrollbar-width (the overlay composer seat offsets `right`\n // by the same variable); 8px fallback covers engines where the\n // variable is absent.\n const right = `calc(${frameRect.right - convRect.right}px + var(--dsh-scrollbar-width, 8px))`\n if (panel.style.top === top && panel.style.height === height\n && panel.style.right === right && panel.style.left === '') {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.right = right\n panel.style.left = ''\n return true\n }\n const left = `${convRect.left - frameRect.left}px`\n if (panel.style.top === top && panel.style.height === height\n && panel.style.left === left && panel.style.right === '') {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.left = left\n panel.style.right = ''\n return true\n }\n\n const loop = (): void => {\n if (stopped) return\n raf = 0\n idle = applyLayout() ? 0 : idle + 1\n if (idle < 3) raf = requestAnimationFrame(loop)\n }\n const wake = (): void => {\n if (stopped) return\n idle = 0\n if (raf === 0) raf = requestAnimationFrame(loop)\n }\n\n observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake)\n if (observer !== null) {\n const panel = panelRef.current\n const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame !== null) { observer.observe(frame, { box: 'border-box' }); observed.frame = frame }\n if (convRoot !== null) { observer.observe(convRoot, { box: 'border-box' }); observed.convRoot = convRoot }\n }\n\n // Initial alignment; the loop keeps correcting through layout transitions.\n wake()\n window.addEventListener('resize', wake)\n return () => {\n stopped = true\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', wake)\n }\n }, [visible, align])\n\n // Detect band overflow: drives the ▲/▼ paging buttons. Re-checked when the\n // dots change and whenever the band itself resizes (the layout loop above\n // clamps it to the conversation height).\n useLayoutEffect(() => {\n const list = listRef.current\n if (list === null) return\n const check = (): void => {\n setScrollable(list.scrollHeight > list.clientHeight + 1)\n syncScroll()\n }\n check()\n if (typeof ResizeObserver === 'undefined') return\n const observer = new ResizeObserver(check)\n observer.observe(list)\n return () => observer.disconnect()\n }, [dots, align])\n\n // Scroll-spy: mark the dot whose turn sits at the reader's position and\n // follow the conversation as it scrolls. The reading line is 35% down the\n // scrollport (see core/active-dot.ts), so the marker reads as \"the question\n // I am looking at\". Only rendered rows participate: a paged-out window\n // unmounts older questions, and the marker tracks what is on screen.\n useEffect(() => {\n if (!visible || dots.length === 0) {\n setCurrentKey(null)\n return\n }\n let raf = 0\n let disposed = false\n let observedFlow: Element | null = null\n\n const compute = (): void => {\n raf = 0\n const scrollport = document.querySelector<HTMLElement>('[data-conversation-scroll]')\n const flow = document.querySelector<HTMLElement>('[data-chat-flow]')\n // Trajectory view / chat not mounted: no marker.\n if (scrollport === null || flow === null) {\n setCurrentKey(null)\n return\n }\n // Content growth (streaming, paging) shifts row tops without a scroll\n // event; re-observe the flow element across remounts.\n if (observer !== null && observedFlow !== flow) {\n observer.disconnect()\n observer.observe(flow)\n observedFlow = flow\n }\n const portRect = scrollport.getBoundingClientRect()\n if (portRect.height <= 0) return\n // Key lookup without interpolating a selector (chat keys contain ':').\n const rendered = new Map<string, HTMLElement>()\n for (const row of flow.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')) {\n const key = row.dataset.chatAnchorKey\n if (key !== undefined && !rendered.has(key)) rendered.set(key, row)\n }\n const rows: ActiveDotRow[] = []\n for (const dot of dots) {\n // The turn's first rendered question row anchors the dot (memberKeys\n // are in seq order, so the first hit is the turn's topmost row).\n for (const memberKey of dot.memberKeys) {\n const row = rendered.get(memberKey)\n if (row !== undefined) {\n rows.push({ key: dot.key, top: row.getBoundingClientRect().top - portRect.top })\n break\n }\n }\n }\n const next = pickActiveDot(rows, readingLineOffset(portRect.height))\n setCurrentKey((prev) => (prev === next ? prev : next))\n }\n const schedule = (): void => {\n if (!disposed && raf === 0) raf = requestAnimationFrame(compute)\n }\n const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule)\n\n compute()\n // Scroll events do not bubble; capturing at the document reaches the\n // conversation scrollport today and after any remount without tracking\n // the node. rAF-throttled, so hot scroll streams coalesce into one\n // measurement per frame.\n document.addEventListener('scroll', schedule, true)\n window.addEventListener('resize', schedule)\n return () => {\n disposed = true\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n document.removeEventListener('scroll', schedule, true)\n window.removeEventListener('resize', schedule)\n }\n }, [visible, dots])\n\n // Clear any pending timers on unmount.\n useEffect(() => () => {\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)\n if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n if (!visible) return null\n\n const onJump = (dot: TurnDot): void => {\n if (current === undefined) return\n setJumpingKey(dot.key)\n props.jump(current, dot.key)\n window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)\n }\n\n const openFocus = (dot: TurnDot, target: HTMLElement): void => {\n const index = dots.findIndex((d) => d.key === dot.key)\n if (index < 0) return\n cancelClearFocus()\n const r = target.getBoundingClientRect()\n // The cascade opens away from the rail: to the right of the dots on a\n // left-anchored rail, to the left on a right-anchored one. The column is\n // vertically centered on the selected dot (top: centerY + translateY(-50%)).\n const horizontal = align === 'right'\n ? { right: window.innerWidth - r.left + 10 }\n : { left: r.right + 10 }\n const lo = Math.max(0, index - FOCUS_RADIUS)\n const hi = Math.min(dots.length - 1, index + FOCUS_RADIUS)\n const items: FocusWindowItem[] = []\n for (let i = lo; i <= hi; i++) {\n items.push({ dot: dots[i], distance: Math.abs(i - index) })\n }\n setFocus({ key: dot.key, items, centerY: r.top + r.height / 2, ...horizontal })\n }\n\n const t = props.t\n const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key)\n\n return (\n <div\n ref={panelRef}\n className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}\n data-question-nav=\"rail\"\n >\n <div className={styles.queue}>\n {dots.length === 0 ? (\n <div className={styles.empty}>{t('strip.empty')}</div>\n ) : (\n <>\n <span className={styles.count}>{dots.length}</span>\n {scrollable ? (\n <button\n type=\"button\"\n className={`${styles.navBtn} ${styles.navUp}`}\n aria-label={t('strip.up')}\n style={{ visibility: canPageUp ? 'visible' : 'hidden' }}\n onMouseEnter={cancelClearFocus}\n onMouseLeave={scheduleClearFocus}\n onClick={() => pageBy(-1)}\n />\n ) : null}\n <div\n ref={listRef}\n className={styles.list}\n onScroll={syncScroll}\n onMouseLeave={scheduleClearFocus}\n >\n <div className={styles.dots}>\n {dots.map((dot, index) => {\n const isFocused = focus !== null && focus.key === dot.key\n // Progressive magnification: selected largest, ±1 smaller, ±2\n // smaller still, the rest base scale.\n const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)\n const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1\n // Click-paging feedback: dots just paged into view play a\n // staggered pop-in, traveling in the paging direction.\n const reveal = revealed?.get(dot.key)\n const cls = [styles.dot]\n if (isFocused) cls.push(styles.focused)\n if (jumpingKey === dot.key) cls.push(styles.active)\n if (currentKey === dot.key) cls.push(styles.current)\n if (reveal !== undefined) {\n cls.push(reveal.dir === 1 ? styles.dotEnterFromBottom : styles.dotEnterFromTop)\n }\n return (\n <button\n key={dot.key}\n className={cls.join(' ')}\n style={{\n transform: `scale(${scale})`,\n ...(reveal !== undefined ? { animationDelay: `${reveal.order * 35}ms` } : {}),\n }}\n data-question-nav-focused={isFocused ? 'true' : undefined}\n data-question-nav-index={index}\n data-question-nav-key={dot.key}\n aria-label={dot.texts[0] ?? ''}\n onMouseEnter={(e) => openFocus(dot, e.currentTarget)}\n onClick={() => onJump(dot)}\n />\n )\n })}\n </div>\n </div>\n {scrollable ? (\n <button\n type=\"button\"\n className={`${styles.navBtn} ${styles.navDown}`}\n aria-label={t('strip.down')}\n style={{ visibility: canPageDown ? 'visible' : 'hidden' }}\n onMouseEnter={cancelClearFocus}\n onMouseLeave={scheduleClearFocus}\n onClick={() => pageBy(1)}\n />\n ) : null}\n </>\n )}\n </div>\n {hint !== null\n ? createPortal(\n <div\n className={styles.hint}\n style={align === 'right' ? { right: 60, top: 20 } : { left: 60, top: 20 }}\n >\n {hint}\n </div>,\n document.body,\n )\n : null}\n {focus !== null\n ? createPortal(\n /* The vertical cascade: one crisp card per window dot, stacked\n non-overlapping, vertically centered on the selected dot.\n Hovering the cascade keeps it alive (the rail only re-arms its\n clear once the mouse leaves both). */\n <div\n className={align === 'right' ? `${styles.cascade} ${styles.cascadeRight}` : styles.cascade}\n style={{\n ...(focus.left !== undefined ? { left: focus.left } : {}),\n ...(focus.right !== undefined ? { right: focus.right } : {}),\n top: focus.centerY,\n transform: 'translateY(-50%)',\n }}\n onMouseEnter={cancelClearFocus}\n onMouseLeave={scheduleClearFocus}\n >\n {focus.items.map((item) => {\n const metrics = focusCardMetrics(item.distance)\n if (metrics === null) return null\n const isSelected = item.distance === 0\n // Cascade cards join their dot's texts into one clamped line;\n // the selected card keeps every line. Every card shows the\n // question's sent time, and clicking any card jumps to its\n // question exactly like clicking the dot.\n const text = item.dot.texts.join(' · ')\n const cls = isSelected\n ? `${styles.card} ${styles.cardSelected}${align === 'right' ? ` ${styles.cardSelectedRight}` : ''}`\n : styles.card\n return (\n <div\n key={item.dot.key}\n className={cls}\n role=\"button\"\n tabIndex={-1}\n style={{ width: metrics.widthPx }}\n onClick={() => onJump(item.dot)}\n >\n {isSelected && item.dot.turn !== null ? (\n <div className={styles.cardTitle}>Turn {item.dot.turn}</div>\n ) : null}\n <div\n className={isSelected ? styles.cardBody : styles.cardClamp}\n style={\n isSelected\n ? undefined\n : ({ WebkitLineClamp: metrics.maxLines, fontSize: metrics.fontSize } as React.CSSProperties)\n }\n >\n {isSelected\n ? item.dot.texts.map((line, lineIndex) => (\n <div key={lineIndex} className={styles.cardLine}>{line}</div>\n ))\n : text}\n </div>\n <div className={styles.cardTime}>{formatQuestionTime(item.dot.time, Date.now())}</div>\n </div>\n )\n })}\n </div>,\n document.body,\n )\n : null}\n </div>\n )\n}\n","/**\n * Rail anchor-edge constants shared by the host schema and the browser\n * settings scope. Pure data: no DSH imports, so the client bundle may inline\n * this module (a Host import here would leak into the browser half).\n *\n * @module dsh-question-nav/align\n */\n\n/** Supported rail anchor edges. */\nexport const ALIGN_OPTIONS = ['left', 'right'] as const\n\n/** Rail anchor edge preference. */\nexport type AlignPreference = typeof ALIGN_OPTIONS[number]\n\n/** Default anchor edge when the user-settings document has no override. */\nexport const DEFAULT_ALIGN: AlignPreference = 'left'\n\n/** Settings namespace owned by this plugin (spelled here rather than\n * imported: the client bundle must not depend on a Host package). */\nexport const QUESTION_NAV_SETTINGS_NS = 'question-nav'\n\n/** Field carrying the selected anchor edge. */\nexport const ALIGN_FIELD = 'align'\n","/**\n * Page-size constants for the ▲/▼ paging buttons, shared by the host schema\n * and the browser settings scope. Pure data: no DSH imports, so the client\n * bundle may inline this module (a Host import here would leak into the\n * browser half).\n *\n * @module dsh-question-nav/page-size\n */\n\n/** Selectable page sizes: how many hidden dots one ▲/▼ click reveals. */\nexport const PAGE_SIZE_OPTIONS = [3, 5, 8, 10] as const\n\n/** Page-size preference (dots revealed per paging-button click). */\nexport type PageSize = typeof PAGE_SIZE_OPTIONS[number]\n\n/** Default page size when the user-settings document has no override. */\nexport const DEFAULT_PAGE_SIZE: PageSize = 5\n\n/** Field carrying the selected page size. */\nexport const PAGE_SIZE_FIELD = 'pageSize'\n","/**\n * The plugin's settings page inside the shell's Plugins section\n * (`settings.plugins.tab`): two segmented controls — which edge of the\n * conversation column the dot rail anchors to, and how many hidden dots one\n * ▲/▼ paging-button click reveals. The choices are written to the\n * `question-nav` settings namespace (registered by the host half); the strip\n * re-anchors and re-steps live when the snapshot changes.\n *\n * @module dsh-question-nav/client/settings-tab\n */\n\nimport { useEffect, useState } from 'react'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\n// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab').\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { ALIGN_OPTIONS } from '../core/align.ts'\nimport { PAGE_SIZE_OPTIONS } from '../core/page-size.ts'\nimport type { QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\ntype ComponentProps = PropsRuntime<'settings.plugins.tab'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\n/** Re-render on settings snapshot changes (the register inject face is static). */\nfunction useSettingsTick(subscribe: QuestionNavInjected['subscribeSettings']): void {\n const [, bump] = useState(0)\n useEffect(() => subscribe(() => bump((n) => n + 1)), [subscribe])\n}\n\nexport function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element | null {\n useSettingsTick(props.subscribeSettings)\n const align = props.align()\n const pageSize = props.pageSize()\n const t = props.t\n\n return (\n <div className={styles.settings}>\n <p className={styles.settingsTitle}>{t('settings.align.title')}</p>\n <p className={styles.settingsDesc}>{t('settings.align.desc')}</p>\n <div className={styles.segmented} role=\"radiogroup\" aria-label={t('settings.align.title')}>\n {ALIGN_OPTIONS.map((option) => (\n <button\n key={option}\n type=\"button\"\n role=\"radio\"\n aria-checked={align === option}\n className={align === option ? `${styles.segment} ${styles.segmentActive}` : styles.segment}\n onClick={() => props.setAlign(option)}\n >\n {t(option === 'left' ? 'settings.align.left' : 'settings.align.right')}\n </button>\n ))}\n </div>\n <p className={styles.settingsTitle}>{t('settings.pagesize.title')}</p>\n <p className={styles.settingsDesc}>{t('settings.pagesize.desc')}</p>\n <div className={styles.segmented} role=\"radiogroup\" aria-label={t('settings.pagesize.title')}>\n {PAGE_SIZE_OPTIONS.map((option) => (\n <button\n key={option}\n type=\"button\"\n role=\"radio\"\n aria-checked={pageSize === option}\n className={pageSize === option ? `${styles.segment} ${styles.segmentActive}` : styles.segment}\n onClick={() => props.setPageSize(option)}\n >\n {option}\n </button>\n ))}\n </div>\n </div>\n )\n}\n","/**\n * Browser mirror of the `question-nav` settings namespace: reads the rail\n * anchor edge and the ▲/▼ page size from the settings scope\n * (`ctx.settingsScope.bind`) and routes the user's choices back through\n * `scope.set`. The namespace itself is registered by the host half\n * (src/settings.ts).\n *\n * The settings surface is optional and may apply after this plugin, so the\n * controller starts unbound and degrades to the defaults until {@link attach}\n * binds the scope (called from a fiber that injects `settingsScope`). The\n * plugin keeps working everywhere it already did.\n *\n * @module dsh-question-nav/client/settings\n */\n\nimport type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'\nimport {\n ALIGN_FIELD, ALIGN_OPTIONS, DEFAULT_ALIGN, QUESTION_NAV_SETTINGS_NS,\n type AlignPreference,\n} from '../core/align.ts'\nimport {\n DEFAULT_PAGE_SIZE, PAGE_SIZE_FIELD, PAGE_SIZE_OPTIONS,\n type PageSize,\n} from '../core/page-size.ts'\n\n/** Narrow a raw section value to the anchor field. */\nfunction isAlignPreference(value: unknown): value is AlignPreference {\n return ALIGN_OPTIONS.some((option) => option === value)\n}\n\n/** Narrow a raw section value to the page-size field. */\nfunction isPageSize(value: unknown): value is PageSize {\n return (PAGE_SIZE_OPTIONS as readonly unknown[]).includes(value)\n}\n\n/** The minimal face of the settings scope service this controller needs.\n * Kept structural (bind only) so the controller stays decoupled from the\n * full service and is unit-testable with a stub binder. */\nexport interface SettingsScopeBinderLike {\n bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T>\n}\n\n/** Wire section this plugin owns (the Host schema's fields). */\ninterface QuestionNavSection {\n align?: unknown\n pageSize?: unknown\n}\n\n/** Snapshot consumed by the strip and the settings rows. */\nexport interface QuestionNavSettingsState {\n /** Last accepted anchor edge (default while the scope is absent/loading). */\n align: AlignPreference\n /** Last accepted page size (default while the scope is absent/loading). */\n pageSize: PageSize\n /** Whether the user layer overrides the composition default. */\n overridden: boolean\n}\n\n/** Reactive handle over the plugin's durable settings section. */\nexport class QuestionNavSettingsController {\n private scope: SettingsScope<QuestionNavSection> | undefined\n private readonly listeners = new Set<() => void>()\n private unsubscribe: () => void = () => {}\n private state: QuestionNavSettingsState = { align: DEFAULT_ALIGN, pageSize: DEFAULT_PAGE_SIZE, overridden: false }\n\n /**\n * Bind the namespace scope once the settings surface is present. Called\n * from a fiber that injects `settingsScope`, so the scope subscription\n * lives on that fiber and is released with it. A no-op after the first\n * bind.\n * @param binder - the settings scope service.\n */\n attach(binder: SettingsScopeBinderLike): void {\n if (this.scope !== undefined) return\n this.scope = binder.bind<QuestionNavSection>({ namespace: QUESTION_NAV_SETTINGS_NS })\n this.state = this.derive(this.scope.getSnapshot())\n this.unsubscribe = this.scope.subscribe(() => {\n if (this.scope === undefined) return\n const next = this.derive(this.scope.getSnapshot())\n if (next.align === this.state.align && next.pageSize === this.state.pageSize\n && next.overridden === this.state.overridden) return\n this.state = next\n for (const listener of this.listeners) listener()\n })\n }\n\n private derive(snapshot: SettingsScopeSnapshot<QuestionNavSection>): QuestionNavSettingsState {\n const user = snapshot.user as QuestionNavSection | undefined\n return {\n align: snapshot.status === 'ready' && isAlignPreference(snapshot.value?.align)\n ? snapshot.value.align\n : DEFAULT_ALIGN,\n pageSize: snapshot.status === 'ready' && isPageSize(snapshot.value?.pageSize)\n ? snapshot.value.pageSize\n : DEFAULT_PAGE_SIZE,\n overridden: user !== undefined && (user.align !== undefined || user.pageSize !== undefined),\n }\n }\n\n /** Release the scope subscription (bound on the settings fiber's lifecycle). */\n dispose(): void {\n this.unsubscribe()\n this.listeners.clear()\n }\n\n /** @returns the current state (stable reference until the next change). */\n getSnapshot(): QuestionNavSettingsState {\n return this.state\n }\n\n /** Observe state replacements; returns the disposer. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => { this.listeners.delete(listener) }\n }\n\n /** Route the user's anchor-edge choice to the Host document. */\n setAlign(align: AlignPreference): void {\n if (this.scope === undefined) return\n void this.scope.set(ALIGN_FIELD, align)\n }\n\n /** Route the user's page-size choice to the Host document. */\n setPageSize(pageSize: PageSize): void {\n if (this.scope === undefined) return\n void this.scope.set(PAGE_SIZE_FIELD, pageSize)\n }\n}\n","/**\n * Locale dictionaries for the question-nav surface (zh/en). Registered under\n * the `question-nav` namespace; keys are consumed through the bound translator.\n */\nexport const zh = {\n 'strip.empty': '本会话还没有提问',\n 'strip.up': '向上翻出更多提问圆点',\n 'strip.down': '向下翻出更多提问圆点',\n 'jump.inactive': '聊天视图未激活',\n 'jump.hidden': '目标无独立气泡,已定位到邻近内容',\n 'jump.notfound': '目标未加载或不存在(可能已压缩)',\n 'jump.timeout': '加载历史超时,可重试',\n 'settings.tab': '提问导航',\n 'settings.align.title': '导航条对齐',\n 'settings.align.desc': '选择圆点导航条锚定在对话栏的哪一侧。',\n 'settings.align.left': '左侧',\n 'settings.align.right': '右侧',\n 'settings.pagesize.title': '每次翻出数量',\n 'settings.pagesize.desc': '点击 ▲/▼ 三角按钮时,每次翻出的隐藏圆点数量。',\n} as const\n\nexport const en = {\n 'strip.empty': 'No questions in this session yet',\n 'strip.up': 'Reveal more question dots above',\n 'strip.down': 'Reveal more question dots below',\n 'jump.inactive': 'Chat view is not active',\n 'jump.hidden': 'No dedicated bubble; landed on nearby content',\n 'jump.notfound': 'Target not loaded or missing (maybe compacted)',\n 'jump.timeout': 'Timed out loading history; retry',\n 'settings.tab': 'Question Nav',\n 'settings.align.title': 'Rail alignment',\n 'settings.align.desc': 'Choose which edge of the conversation column the dot rail anchors to.',\n 'settings.align.left': 'Left',\n 'settings.align.right': 'Right',\n 'settings.pagesize.title': 'Dots per page',\n 'settings.pagesize.desc': 'How many hidden dots one click of the ▲/▼ paging buttons reveals.',\n} as const\n\nexport type QuestionNavKey = keyof typeof zh\n","/**\n * Pure node-indexing logic for the question-nav plugin. No React, no DOM, no\n * Cordis — every function here is a pure transform over chat-node data so it\n * can be unit-tested in isolation (and reused by the browser half).\n */\n\n/** One user question as shown in the strip and targeted by a jump. */\nexport interface QuestionNode {\n /** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */\n key: string\n /** Monotone anchor sequence for ordering and window-min detection. */\n anchorSeq: number\n /** Event seq of the user message. */\n seq: number\n /** Unix ms timestamp. */\n time: number\n /** Full question text — shown in the hover tooltip (not truncated). */\n text: string\n}\n\n/** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */\nexport interface ChatNodeLike {\n key: string\n anchorSeq: number\n visibility?: string\n kind?: string\n /** Kind-specific payload (a UserMessageNode for `user`/`steering`). */\n data?: unknown\n}\n\n/** Kinds counted as a user question (turn-opening and steering admissions). */\nexport const QUESTION_KINDS = ['user', 'steering'] as const\n\n/** Narrow `node.data` to the user-message payload we read. */\ninterface UserDataLike {\n content?: readonly { type?: string; text?: string }[]\n seq?: number\n time?: number\n}\n\nfunction userData(data: unknown): UserDataLike | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n return data as UserDataLike\n}\n\n/** First text block of a user message; falls back to the raw first block. */\nexport function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {\n if (content === undefined || content.length === 0) return ''\n const first = content[0]\n if (typeof first?.text === 'string') return first.text\n return ''\n}\n\n/** Extract the user questions from a chat-node window, ordered by anchorSeq. */\nexport function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[] {\n const out: QuestionNode[] = []\n for (const node of nodes) {\n if (!QUESTION_KINDS.includes(node.kind as (typeof QUESTION_KINDS)[number])) continue\n const payload = userData(node.data)\n out.push({\n key: node.key,\n anchorSeq: node.anchorSeq,\n seq: payload?.seq ?? -1,\n time: payload?.time ?? 0,\n // Full question text: shown in the hover tooltip (not truncated).\n text: messageText(payload?.content),\n })\n }\n out.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return out\n}\n\n/** Whether a node is actually rendered (visible rows only are scroll targets). */\nexport function isRenderable(node: ChatNodeLike): boolean {\n return node.visibility !== 'hidden'\n}\n\n/** The row of the window that renders the given key (exact match). */\nexport function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null {\n for (const node of nodes) {\n if (node.key === key) return node\n }\n return null\n}\n\n/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */\nexport function nearestRenderable(\n nodes: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,\n excludeKey: string | undefined,\n): { key: string; anchorSeq: number } | null {\n let best: { key: string; anchorSeq: number } | null = null\n for (const node of nodes) {\n if (node.visibility === 'hidden') continue\n if (node.key === excludeKey) continue\n if (best === null || node.anchorSeq < best.anchorSeq) best = { key: node.key, anchorSeq: node.anchorSeq }\n }\n return best\n}\n","/**\n * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot\n * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the\n * paging/timeout/fallback loop is unit-testable without a real browser or\n * session. The browser half wires these ports to ctx.sessions + the DOM.\n */\n\nimport { nearestRenderable } from './nodes.ts'\n\n/** The bits of a session snapshot the jump loop needs. */\nexport interface JumpSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n /** Renderable chat rows as a key->renderable map (or iterable of rows). */\n rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>\n}\n\nexport interface JumpPorts {\n /** Read the current snapshot; undefined when the session/view is unavailable. */\n snapshot: () => JumpSnapshot | undefined\n /** Expand the window backwards; rejects/throws on failure. */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Find the DOM row for a chat anchor key; null when not rendered. */\n findRow: (key: string) => HTMLElement | null\n /** Scroll a row into view at the top. */\n scrollIntoView: (row: HTMLElement) => void\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n /** Report a terminal failure to the caller (for a hint). */\n report?: (code: JumpFailureCode, fallback?: boolean) => void\n}\n\nexport type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'\n\nexport interface JumpResult {\n ok: boolean\n code?: JumpFailureCode\n /** True when we landed on a fallback row rather than the exact target. */\n fallback?: boolean\n}\n\nexport interface JumpOptions {\n /** Total wall-clock budget for loadOlder paging. */\n totalTimeoutMs?: number\n /** Max loadOlder pages before giving up. */\n maxPages?: number\n /** Poll interval for the row to render after it is known to be in the window. */\n rowWaitMs?: number\n /** Poll interval for state transitions (loadingOlder / openState). */\n pollMs?: number\n}\n\nconst DEFAULTS = {\n totalTimeoutMs: 15_000,\n maxPages: 100,\n rowWaitMs: 8_000,\n pollMs: 60,\n}\n\nfunction minAnchorSeq(rows: Iterable<{ anchorSeq: number }>): number | null {\n let min: number | null = null\n for (const row of rows) {\n if (min === null || row.anchorSeq < min) min = row.anchorSeq\n }\n return min\n}\n\nfunction renderable(rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>): { key: string; anchorSeq: number }[] {\n const out: { key: string; anchorSeq: number }[] = []\n for (const row of rows) {\n if (row.visibility === 'hidden') continue\n out.push({ key: row.key, anchorSeq: row.anchorSeq })\n }\n return out\n}\n\n/**\n * Jump to the row for `key`, paging older content until it is rendered (or the\n * budget is exhausted). Falls back to the nearest renderable row when the\n * exact row is hidden/absent.\n */\nexport async function jumpToQuestion(ports: JumpPorts, key: string, options: JumpOptions = {}): Promise<JumpResult> {\n const cfg = { ...DEFAULTS, ...options }\n const fail = (code: JumpFailureCode, fallback = false): JumpResult => {\n ports.report?.(code, fallback)\n return fallback ? { ok: false, code, fallback: true } : { ok: false, code }\n }\n\n if (!ports.isViewActive()) return fail('VIEW_INACTIVE')\n\n const deadline = ports.now() + cfg.totalTimeoutMs\n let pages = 0\n\n // Phase 1: page older until the key appears in the loaded window.\n while (true) {\n const snap = ports.snapshot()\n if (snap === undefined) return fail('VIEW_INACTIVE')\n const rows = renderable(snap.rows)\n if (rows.some((r) => r.key === key)) break\n if (snap.openState !== 'open') {\n if (snap.openState === 'error' || ports.now() > deadline) {\n return fail(snap.openState === 'error' ? 'VIEW_INACTIVE' : 'TIMEOUT')\n }\n await ports.sleep(cfg.pollMs)\n continue\n }\n if (snap.hasMore !== true) return fail('NOT_FOUND')\n if (pages >= cfg.maxPages || ports.now() > deadline) return fail('TIMEOUT')\n if (snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n const before = minAnchorSeq(rows)\n await ports.loadOlder()\n pages += 1\n const afterSnap = ports.snapshot()\n const after = minAnchorSeq(afterSnap === undefined ? [] : afterSnap.rows)\n if (after === null || (before !== null && after >= before)) return fail('NOT_FOUND')\n }\n\n // Phase 2: wait for the row to render, then scroll. Fall back if hidden.\n const waitedFor = async (rowKey: string): Promise<HTMLElement | null> => {\n for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {\n if (!ports.isViewActive()) return null\n const row = ports.findRow(rowKey)\n if (row !== null) return row\n await ports.sleep(cfg.pollMs)\n }\n return null\n }\n\n const row = await waitedFor(key)\n if (row !== null) {\n ports.scrollIntoView(row)\n return { ok: true }\n }\n\n const snap = ports.snapshot()\n const fallback = nearestRenderable(snap === undefined ? [] : snap.rows, key)\n if (fallback !== null) {\n const fbRow = await waitedFor(fallback.key)\n if (fbRow !== null) {\n ports.scrollIntoView(fbRow)\n return fail('TARGET_HIDDEN', true)\n }\n }\n return fail('TARGET_HIDDEN', false)\n}\n","/**\n * Browser-half entry for the dsh-question-nav plugin.\n *\n * Registers one surface into the frame-wide floating layer (`shell.overlay`):\n * a vertical strip on the LEFT edge of the conversation column listing every\n * user question in the current session as a small button, one dot per turn.\n * Clicking a button scrolls the chat to that turn's first question.\n *\n * The dots are driven by the host-folded `questionIndex` session projection\n * (registered by the plugin's host half): the projection registry folds the\n * WHOLE event log without touching the chat's paged render window, the\n * projection cache persists it, and the standard carriers (history tail-page\n * baseline + session/projection push frames) keep it live. Live-window\n * questions not yet recorded by the projection are merged on top so a\n * just-sent question appears immediately.\n *\n * Failure policy: nothing here throws at apply time — an external plugin must\n * never take the GUI down.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\n// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab')\n// and the ctx.settingsScope Context merge.\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport type { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport { QuestionNavSettingsTab } from './QuestionNavSettingsTab.tsx'\nimport { QuestionNavSettingsController } from './settings.ts'\nimport { en, zh, type QuestionNavKey } from './locales.ts'\nimport { extractQuestions } from '../core/nodes.ts'\nimport { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'\n\n/** Locale namespace this plugin owns. */\nconst NS = 'question-nav'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** question-nav surface copy. */\n 'question-nav': QuestionNavKey\n }\n}\n\n/** Services required by this plugin. */\nexport const inject = ['slots', 'locale', 'sessions']\n\n/** Single-instance guard: a duplicated client injection must not mount twice. */\ndeclare global {\n // eslint-disable-next-line no-var\n var __dshQuestionNavApplied: boolean | undefined\n}\n\nfunction claimApply(): boolean {\n if (globalThis.__dshQuestionNavApplied === true) return false\n globalThis.__dshQuestionNavApplied = true\n return true\n}\n\nfunction releaseApply(): void {\n globalThis.__dshQuestionNavApplied = undefined\n}\n\n/** Map the session snapshot to the jump-loop port surface. */\nfunction jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {\n return {\n snapshot: () => {\n const binding = ctx.sessions.binding(sessionId)\n const snap = binding?.session.getSnapshot()\n if (snap === undefined) return undefined\n return {\n openState: snap.openState,\n hasMore: snap.hasMore,\n loadingOlder: snap.loadingOlder,\n rows: snap.chat.nodes.values(),\n }\n },\n loadOlder: async () => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) throw new Error('session unavailable')\n await binding.session.loadOlder()\n },\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n findRow: (key: string) => {\n for (const candidate of Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))) {\n if (candidate.dataset.chatAnchorKey === key) return candidate\n }\n return null\n },\n scrollIntoView: (row) => row.scrollIntoView({ block: 'start' }),\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\n/**\n * The session's `questionIndex` projection face (getSnapshot + subscribe).\n * Undefined when the session is not bound or the host unit is not registered\n * (e.g. a headless composition) — the strip then shows live-window dots only.\n */\nfunction questionProjectionOf(ctx: ClientContext, sessionId: SessionId): ObservableFace | undefined {\n const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf('questionIndex')\n if (face === undefined) return undefined\n return {\n getSnapshot: () => face.getSnapshot(),\n subscribe: (listener) => face.subscribe(listener),\n }\n}\n\nfunction createInject(ctx: ClientContext, settings: QuestionNavSettingsController): QuestionNavInjected {\n return {\n readQuestions: (sessionId) => {\n const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()\n if (snap === undefined) return []\n return extractQuestions(snap.chat.nodes.values())\n },\n subscribeList: (cb) => ctx.sessions.list.subscribe(cb),\n subscribeContent: (sessionId, cb) => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return () => {}\n return binding.session.subscribe(cb)\n },\n questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),\n jump: (sessionId, key) => {\n const ports = jumpPortsFor(ctx, sessionId)\n ports.report = (code: JumpFailureCode) => {\n // Surface the failure through the component via a DOM event the\n // strip listens for; simplest reliable cross-boundary channel here.\n window.dispatchEvent(new CustomEvent('question-nav:jump-failed', { detail: code }))\n }\n void jumpToQuestion(ports, key)\n },\n align: () => settings.getSnapshot().align,\n subscribeSettings: (cb) => settings.subscribe(cb),\n setAlign: (align) => settings.setAlign(align),\n pageSize: () => settings.getSnapshot().pageSize,\n setPageSize: (pageSize) => settings.setPageSize(pageSize),\n }\n}\n\n/**\n * Register the question-nav surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n if (!claimApply()) return\n ctx.effect(() => releaseApply, 'question-nav: apply claim')\n\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')\n const t = ctx.locale.bind(NS)\n\n const settings = new QuestionNavSettingsController()\n const injected = createInject(ctx, settings)\n\n ctx.slots.inject('shell.overlay', () => ctx.slots.register({\n name: 'shell.overlay',\n id: 'question-nav',\n order: 900,\n locale: NS,\n inject: () => injected,\n }, QuestionNavStrip))\n\n // The settings surface is optional and may apply after this plugin, so the\n // namespace binds and the settings page mounts in a fiber that waits for\n // it. Without it the strip simply keeps the default left alignment.\n ctx.inject(['settingsScope'], (scopeCtx) => {\n settings.attach(scopeCtx.get('settingsScope') as SettingsScopeBinder)\n ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({\n name: 'settings.plugins.tab',\n id: 'question-nav',\n order: 100,\n label: () => t('settings.tab'),\n locale: NS,\n inject: () => injected,\n }, QuestionNavSettingsTab))\n })\n}\n"],"mappings":";;;;;;;;;;;EAcA,MAAa,0BAA0B;;;;;;EAOvC,SAAgB,YAAY,IAAqB;GAE/C,OAAO,MAAkBA,0BAAO,OAAO,EAAE;EAC3C;;;EAoBA,SAAS,cAAc,SAA4C;GACjE,KAAK,IAAI,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAClC,IAAI,QAAQ,EAAE,CAAC,MAAM,QAAQ,IAAI,EAAE,CAAC,KAAK,OAAO;GAElD,OAAO;EACT;;;;;;;;EASA,SAAgB,qBAAqB,SAA8C;GACjF,MAAM,SAAS,cAAc,OAAO,IAAI,UAAU,CAAC,GAAG,OAAO,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,MAAM,EAAE,GAAG;GAC3F,MAAM,OAAkB,CAAC;GACzB,KAAK,MAAM,SAAS,QAAQ;IAC1B,MAAM,MAAM,YAAY,MAAM,EAAE;IAChC,MAAM,OAAO,KAAK,GAAG,EAAE;IACvB,IAAI,SAAS,KAAA,KAAa,KAAK,SAAS,MAAM,MAAM;KAClD,KAAK,KAAK,SAAS,KAAK;MACtB,GAAG;MACH,OAAO,CAAC,GAAG,KAAK,OAAO,MAAM,IAAI;MACjC,YAAY,CAAC,GAAG,KAAK,YAAY,GAAG;KACtC;KACA;IACF;IACA,KAAK,KAAK;KACR,MAAM,MAAM;KACZ;KACA,WAAW,MAAM;KACjB,MAAM,MAAM;KACZ,OAAO,CAAC,MAAM,IAAI;KAClB,YAAY,CAAC,GAAG;IAClB,CAAC;GACH;GACA,OAAO;EACT;;;;;;;;;;;EAYA,SAAgB,mBACd,MACA,MACW;GACX,IAAI,KAAK,WAAW,GAAG,OAAO;GAC9B,MAAM,wBAAQ,IAAI,IAAY;GAC9B,KAAK,MAAM,OAAO,MAChB,KAAK,MAAM,OAAO,IAAI,YAAY,MAAM,IAAI,GAAG;GAEjD,MAAM,SAAoB,CAAC;GAC3B,KAAK,MAAM,YAAY,MAAM;IAC3B,IAAI,MAAM,IAAI,SAAS,GAAG,GAAG;IAC7B,OAAO,KAAK;KACV,MAAM;KACN,KAAK,SAAS;KACd,WAAW,SAAS;KACpB,MAAM,SAAS;KACf,OAAO,CAAC,SAAS,IAAI;KACrB,YAAY,CAAC,SAAS,GAAG;IAC3B,CAAC;GACH;GAEA,IAAI,OAAO,WAAW,GAAG,OAAO;GAKhC,MAAM,MAAiB,CAAC;GACxB,IAAI,IAAI;GACR,KAAK,MAAM,SAAS,QAAQ;IAC1B,OAAO,IAAI,KAAK,UAAU,KAAK,EAAE,CAAC,aAAa,MAAM,WAAW;KAC9D,IAAI,KAAK,KAAK,EAAE;KAChB,KAAK;IACP;IACA,IAAI,KAAK,KAAK;GAChB;GACA,OAAO,IAAI,KAAK,QAAQ;IACtB,IAAI,KAAK,KAAK,EAAE;IAChB,KAAK;GACP;GACA,OAAO;EACT;;;ECvHA,MAAa,eAAe;GAAC;GAAK;GAAM;EAAI;;;;;EAM5C,SAAgB,UAAU,UAAiC;GACzD,MAAM,IAAI,KAAK,IAAI,QAAQ;GAC3B,IAAI,IAAA,GAAkB,OAAO;GAC7B,OAAO;EACT;;EAGA,SAAgB,WAAW,UAA0B;GACnD,MAAM,OAAO,UAAU,QAAQ;GAC/B,OAAO,SAAS,OAAO,IAAI,aAAa;EAC1C;EAkBA,SAAgB,iBAAiB,UAA2C;GAC1E,MAAM,OAAO,UAAU,QAAQ;GAC/B,IAAI,SAAS,MAAM,OAAO;GAC1B,QAAQ,MAAR;IACE,KAAK,GACH,OAAO;KAAE,SAAS;KAAK,UAAU;KAAI,UAAU;IAAE;IACnD,KAAK,GACH,OAAO;KAAE,SAAS;KAAK,UAAU;KAAM,UAAU;IAAE;IACrD,SACE,OAAO;KAAE,SAAS;KAAK,UAAU;KAAI,UAAU;IAAE;GACrD;EACF;;EAgCA,SAAgB,SAAS,UAAA,GAA4B,MAAA,GAAuB,OAAA,GAAsC;GAChH,OAAO,QAAQ,UAAU;EAC3B;;EAGA,SAAgB,eAAe,QAAgB,WAA2B;GACxE,OAAO,KAAK,IAAI,GAAG,KAAK,IAAI,QAAQ,KAAK,IAAI,GAAG,SAAS,CAAC,CAAC;EAC7D;;;;;;;;;;;;;;;;ECxEA,SAAgB,cAAc,MAA+B,aAAoC;GAC/F,IAAI,KAAK,WAAW,GAAG,OAAO;GAC9B,IAAI,SAAwB;GAC5B,KAAK,MAAM,OAAO,MAChB,IAAI,IAAI,OAAO,aAAa,SAAS,IAAI;GAE3C,OAAO,UAAU,KAAK,EAAE,CAAC;EAC3B;;;;;;;;EASA,SAAgB,kBAAkB,kBAAkC;GAClE,OAAO,KAAK,IAAI,IAAI,mBAAmB,GAAI;EAC7C;;;;;;;;;;ECtCA,SAAS,KAAK,GAAmB;GAC/B,OAAO,IAAI,KAAK,IAAI,MAAM,OAAO,CAAC;EACpC;;EAGA,SAAgB,UAAU,MAAc,KAAsB;GAC5D,MAAM,IAAI,IAAI,KAAK,IAAI;GACvB,MAAM,IAAI,IAAI,KAAK,GAAG;GACtB,OAAO,EAAE,YAAY,MAAM,EAAE,YAAY,KACpC,EAAE,SAAS,MAAM,EAAE,SAAS,KAC5B,EAAE,QAAQ,MAAM,EAAE,QAAQ;EACjC;;;;;;;;;;EAWA,SAAgB,mBAAmB,MAAc,KAAqB;GACpE,IAAI,CAAC,OAAO,SAAS,IAAI,KAAK,QAAQ,GAAG,OAAO;GAChD,MAAM,IAAI,IAAI,KAAK,IAAI;GACvB,MAAM,KAAK,GAAG,KAAK,EAAE,SAAS,CAAC,EAAE,GAAG,KAAK,EAAE,WAAW,CAAC;GACvD,IAAI,UAAU,MAAM,GAAG,GAAG,OAAO;GACjC,MAAM,KAAK,GAAG,KAAK,EAAE,SAAS,IAAI,CAAC,EAAE,GAAG,KAAK,EAAE,QAAQ,CAAC;GACxD,IAAI,EAAE,YAAY,MAAM,IAAI,KAAK,GAAG,CAAC,CAAC,YAAY,GAAG,OAAO,GAAG,GAAG,GAAG;GACrE,OAAO,GAAG,EAAE,YAAY,EAAE,GAAG,GAAG,GAAG;EACrC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ECuDA,MAAM,gBAAyD;GAC7D,eAAe;GACf,eAAe;GACf,WAAW;GACX,SAAS;EACX;;EAwBA,SAAS,kBAAkB,MAAmD;GAC5E,MAAM,QAAQ,MAAM,YAAY;GAChC,IAAI,CAAC,MAAM,QAAQ,KAAK,GAAG,OAAO,CAAC;GACnC,OAAO,MAAM,QAAQ,SACnB,OAAO,SAAS,YAAY,SAAS,QAClC,OAAQ,KAAuB,OAAO,YACtC,OAAQ,KAAuB,QAAQ,YACvC,OAAQ,KAAuB,SAAS,QAAQ;EACvD;EAEA,SAAS,eAAmC;GAC1C,OAAO,SAAS,cAA2B,gDAA8C;EAC3F;;;;EAKA,SAAS,SAAS,GAAuB,GAAgC;GACvE,IAAI,EAAE,WAAW,EAAE,QAAQ,OAAO;GAClC,KAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;IACjC,MAAM,KAAK,EAAE;IACb,MAAM,KAAK,EAAE;IACb,IAAI,GAAG,QAAQ,GAAG,OAAO,GAAG,WAAW,WAAW,GAAG,WAAW,QAAQ,OAAO;IAC/E,KAAK,IAAI,IAAI,GAAG,IAAI,GAAG,WAAW,QAAQ,KACxC,IAAI,GAAG,WAAW,OAAO,GAAG,WAAW,IAAI,OAAO;GAEtD;GACA,OAAO;EACT;EAEA,SAAgB,iBAAiB,OAAiD;GAChF,MAAM,UAAU,MAAM,aAAa,MAAM,EAAE,OAAO;GAClD,MAAM,UAAU,MAAM,aAAa,MAAO,EAAE,YAAY,KAAA,IAAY,KAAA,IAAY,EAAE,KAAK,EAAE,QAAS;GAClG,MAAM,UAAU,YAAY,KAAA,KAAa,YAAY,KAAA,KAAa,QAAQ,UAAU;GAEpF,MAAM,CAAC,MAAM,YAAA,GAAWC,MAAAA,SAAAA,CAAoB,CAAC,CAAC;GAC9C,MAAM,CAAC,YAAY,kBAAA,GAAiBA,MAAAA,SAAAA,CAAwB,IAAI;GAChE,MAAM,CAAC,MAAM,YAAA,GAAWA,MAAAA,SAAAA,CAAwB,IAAI;GACpD,MAAM,CAAC,OAAO,aAAA,GAAYA,MAAAA,SAAAA,CAA4B,IAAI;GAE1D,MAAM,CAAC,YAAY,kBAAA,GAAiBA,MAAAA,SAAAA,CAAwB,IAAI;GAChE,MAAM,CAAC,OAAO,aAAA,GAAYA,MAAAA,SAAAA,OAAgC,MAAM,MAAM,CAAC;GAEvE,MAAM,CAAC,UAAU,gBAAA,GAAeA,MAAAA,SAAAA,OAAyB,MAAM,SAAS,CAAC;GACzE,MAAM,YAAA,GAAWC,MAAAA,OAAAA,CAA8B,IAAI;GACnD,MAAM,WAAA,GAAUA,MAAAA,OAAAA,CAA8B,IAAI;GAClD,MAAM,gBAAA,GAAeA,MAAAA,OAAAA,CAAsB,IAAI;GAG/C,MAAM,sBAAA,GAAqBA,MAAAA,OAAAA,CAAsB,IAAI;GAErD,MAAM,eAAA,GAAcA,MAAAA,OAAAA,CAAkB,CAAC,CAAC;GAGxC,MAAM,CAAC,YAAY,kBAAA,GAAiBD,MAAAA,SAAAA,CAAS,KAAK;GAGlD,MAAM,CAAC,WAAW,iBAAA,GAAgBA,MAAAA,SAAAA,CAAuC;IAAE,KAAK;IAAG,KAAK;GAAE,CAAC;GAI3F,MAAM,CAAC,UAAU,gBAAA,GAAeA,MAAAA,SAAAA,CAAqE,IAAI;GACzG,MAAM,kBAAA,GAAiBC,MAAAA,OAAAA,CAAsB,IAAI;GAEjD,MAAM,mBAAyB;IAC7B,MAAM,OAAO,QAAQ;IACrB,IAAI,SAAS,MAAM;IACnB,MAAM,MAAM,KAAK,IAAI,GAAG,KAAK,eAAe,KAAK,YAAY;IAC7D,aAAa;KAAE,KAAK,KAAK;KAAW;IAAI,CAAC;GAC3C;GAMA,MAAM,UAAU,QAAsB;IACpC,MAAM,OAAO,QAAQ;IACrB,IAAI,SAAS,MAAM;IACnB,MAAM,MAAM,KAAK,IAAI,GAAG,KAAK,eAAe,KAAK,YAAY;IAC7D,MAAM,SAAS,eAAe,KAAK,YAAY,MAAM,SAAA,GAAA,GAA4B,QAAQ,GAAG,GAAG;IAC/F,IAAI,WAAW,KAAK,WAAW;IAG/B,MAAM,OAAO,KAAK,sBAAsB;IACxC,MAAM,MAAM,MAAM,KAAK,KAAK,iBAA8B,yBAAyB,CAAC;IACpF,MAAM,aAAa,IAAI,KAAK,OAAO;KACjC,MAAM,IAAI,GAAG,sBAAsB;KACnC,OAAO,EAAE,SAAS,KAAK,OAAO,EAAE,MAAM,KAAK;IAC7C,CAAC;IACD,KAAK,YAAY;IACjB,WAAW;IACX,MAAM,uBAAO,IAAI,IAA4C;IAC7D,MAAM,QAAkB,CAAC;IACzB,IAAI,SAAS,IAAI,MAAM;KACrB,IAAI,WAAW,IAAI;KACnB,MAAM,IAAI,GAAG,sBAAsB;KACnC,MAAM,MAAM,GAAG,QAAQ;KACvB,IAAI,QAAQ,KAAA,KAAa,EAAE,SAAS,KAAK,OAAO,EAAE,MAAM,KAAK,QAC3D,MAAM,KAAK,GAAG;IAElB,CAAC;IAGD,IAAI,QAAQ,GAAG,MAAM,QAAQ;IAC7B,MAAM,SAAS,KAAK,UAAU,KAAK,IAAI,KAAK;KAAE;KAAK;IAAM,CAAC,CAAC;IAC3D,IAAI,KAAK,SAAS,GAAG;IACrB,YAAY,IAAI;IAChB,IAAI,eAAe,YAAY,MAAM,OAAO,aAAa,eAAe,OAAO;IAE/E,eAAe,UAAU,OAAO,iBAAiB,YAAY,IAAI,GAAG,MAAM,KAAK,OAAO,KAAK,GAAG;GAChG;GAEA,MAAM,YAAY,cAAc,UAAU,MAAM;GAChD,MAAM,cAAc,cAAc,UAAU,MAAM,UAAU;GAK5D,MAAM,yBAA+B;IACnC,IAAI,mBAAmB,YAAY,MAAM;KACvC,OAAO,aAAa,mBAAmB,OAAO;KAC9C,mBAAmB,UAAU;IAC/B;GACF;GACA,MAAM,2BAAiC;IACrC,iBAAiB;IACjB,mBAAmB,UAAU,OAAO,iBAAiB,SAAS,IAAI,GAAG,GAAG;GAC1E;GAKA,CAAA,GAAA,MAAA,UAAA,OAAgB,MAAM,wBAAwB;IAC5C,SAAS,MAAM,MAAM,CAAC;IACtB,YAAY,MAAM,SAAS,CAAC;GAC9B,CAAC,GAAG,CAAC,KAAK,CAAC;GAEX,MAAM,YAAY,YAA0B;IAC1C,QAAQ,OAAO;IACf,IAAI,aAAa,YAAY,MAAM,OAAO,aAAa,aAAa,OAAO;IAC3E,aAAa,UAAU,OAAO,iBAAiB,QAAQ,IAAI,GAAG,IAAI;GACpE;GAOA,CAAA,GAAA,MAAA,UAAA,OAAgB;IACd,IAAI,CAAC,WAAW,YAAY,KAAA,GAAW;KACrC,QAAQ,CAAC,CAAC;KACV,YAAY,UAAU,CAAC;KACvB;IACF;IACA,MAAM,YAAY;IAClB,MAAM,OAAO,MAAM,mBAAmB,SAAS;IAC/C,MAAM,gBAAsB;KAE1B,MAAM,OAAO,mBADG,qBAAqB,kBAAkB,IAAI,CAC3B,GAAS,MAAM,cAAc,SAAS,CAAC;KAIvE,IAAI,SAAS,MAAM,YAAY,OAAO,GAAG;MACvC,QAAQ,YAAY,OAAO;MAC3B;KACF;KACA,YAAY,UAAU;KACtB,QAAQ,IAAI;IACd;IACA,QAAQ;IACR,MAAM,kBAAkB,MAAM,UAAU,OAAO,YAAY,CAAC;IAC5D,MAAM,eAAe,MAAM,iBAAiB,WAAW,OAAO;IAC9D,aAAa;KACX,gBAAgB;KAChB,aAAa;IACf;GAEF,GAAG;IAAC;IAAS;IAAS;GAAK,CAAC;GAG5B,CAAA,GAAA,MAAA,UAAA,OAAgB;IACd,MAAM,gBAAgB,UAAuB;KAC3C,MAAM,OAAQ,MAAuC;KACrD,SAAS,MAAM,EAAE,cAAc,SAAS,cAAc,CAAC;IACzD;IACA,OAAO,iBAAiB,4BAA4B,YAAY;IAChE,aAAa,OAAO,oBAAoB,4BAA4B,YAAY;GAClF,GAAG,CAAC,KAAK,CAAC;GAaV,CAAA,GAAA,MAAA,gBAAA,OAAsB;IACpB,IAAI,CAAC,SAAS;IACd,IAAI,MAAM;IACV,IAAI,OAAO;IACX,IAAI,UAAU;IACd,IAAI,WAAkC;IACtC,MAAM,WAAW;KAAE,OAAO;KAAwB,UAAU;IAAuB;IAEnF,MAAM,oBAA6B;KACjC,MAAM,QAAQ,SAAS;KACvB,IAAI,UAAU,MAAM,OAAO;KAC3B,MAAM,QAAQ,MAAM,QAAQ,sBAAsB,CAAC,EAAE,iBAAiB;KACtE,MAAM,WAAW,aAAa;KAG9B,IAAI,UAAU,QAAQ,aAAa,MAAM,OAAO;KAIhD,IAAI,aAAa,MAAM;MACrB,IAAI,SAAS,UAAU,OAAO;OAC5B,SAAS,QAAQ,OAAO,EAAE,KAAK,aAAa,CAAC;OAC7C,SAAS,QAAQ;MACnB;MACA,IAAI,SAAS,aAAa,UAAU;OAClC,SAAS,QAAQ,UAAU,EAAE,KAAK,aAAa,CAAC;OAChD,SAAS,WAAW;MACtB;KACF;KACA,MAAM,YAAY,MAAM,sBAAsB;KAC9C,MAAM,WAAW,SAAS,sBAAsB;KAGhD,IAAI,SAAS,UAAU,KAAK,SAAS,SAAS,GAAG,OAAO;KACxD,MAAM,MAAM,GAAG,SAAS,MAAM,UAAU,IAAI;KAC5C,MAAM,SAAS,GAAG,SAAS,OAAO;KAGlC,IAAI,UAAU,SAAS;MAQrB,MAAM,QAAQ,QAAQ,UAAU,QAAQ,SAAS,MAAM;MACvD,IAAI,MAAM,MAAM,QAAQ,OAAO,MAAM,MAAM,WAAW,UACjD,MAAM,MAAM,UAAU,SAAS,MAAM,MAAM,SAAS,IACvD,OAAO;MAET,MAAM,MAAM,MAAM;MAClB,MAAM,MAAM,SAAS;MACrB,MAAM,MAAM,QAAQ;MACpB,MAAM,MAAM,OAAO;MACnB,OAAO;KACT;KACA,MAAM,OAAO,GAAG,SAAS,OAAO,UAAU,KAAK;KAC/C,IAAI,MAAM,MAAM,QAAQ,OAAO,MAAM,MAAM,WAAW,UACjD,MAAM,MAAM,SAAS,QAAQ,MAAM,MAAM,UAAU,IACtD,OAAO;KAET,MAAM,MAAM,MAAM;KAClB,MAAM,MAAM,SAAS;KACrB,MAAM,MAAM,OAAO;KACnB,MAAM,MAAM,QAAQ;KACpB,OAAO;IACT;IAEA,MAAM,aAAmB;KACvB,IAAI,SAAS;KACb,MAAM;KACN,OAAO,YAAY,IAAI,IAAI,OAAO;KAClC,IAAI,OAAO,GAAG,MAAM,sBAAsB,IAAI;IAChD;IACA,MAAM,aAAmB;KACvB,IAAI,SAAS;KACb,OAAO;KACP,IAAI,QAAQ,GAAG,MAAM,sBAAsB,IAAI;IACjD;IAEA,WAAW,OAAO,mBAAmB,cAAc,OAAO,IAAI,eAAe,IAAI;IACjF,IAAI,aAAa,MAAM;KAErB,MAAM,QADQ,SAAS,SACF,QAAQ,sBAAsB,CAAC,EAAE,iBAAiB;KACvE,MAAM,WAAW,aAAa;KAC9B,IAAI,UAAU,MAAM;MAAE,SAAS,QAAQ,OAAO,EAAE,KAAK,aAAa,CAAC;MAAG,SAAS,QAAQ;KAAM;KAC7F,IAAI,aAAa,MAAM;MAAE,SAAS,QAAQ,UAAU,EAAE,KAAK,aAAa,CAAC;MAAG,SAAS,WAAW;KAAS;IAC3G;IAGA,KAAK;IACL,OAAO,iBAAiB,UAAU,IAAI;IACtC,aAAa;KACX,UAAU;KACV,IAAI,QAAQ,GAAG,qBAAqB,GAAG;KACvC,UAAU,WAAW;KACrB,OAAO,oBAAoB,UAAU,IAAI;IAC3C;GACF,GAAG,CAAC,SAAS,KAAK,CAAC;GAKnB,CAAA,GAAA,MAAA,gBAAA,OAAsB;IACpB,MAAM,OAAO,QAAQ;IACrB,IAAI,SAAS,MAAM;IACnB,MAAM,cAAoB;KACxB,cAAc,KAAK,eAAe,KAAK,eAAe,CAAC;KACvD,WAAW;IACb;IACA,MAAM;IACN,IAAI,OAAO,mBAAmB,aAAa;IAC3C,MAAM,WAAW,IAAI,eAAe,KAAK;IACzC,SAAS,QAAQ,IAAI;IACrB,aAAa,SAAS,WAAW;GACnC,GAAG,CAAC,MAAM,KAAK,CAAC;GAOhB,CAAA,GAAA,MAAA,UAAA,OAAgB;IACd,IAAI,CAAC,WAAW,KAAK,WAAW,GAAG;KACjC,cAAc,IAAI;KAClB;IACF;IACA,IAAI,MAAM;IACV,IAAI,WAAW;IACf,IAAI,eAA+B;IAEnC,MAAM,gBAAsB;KAC1B,MAAM;KACN,MAAM,aAAa,SAAS,cAA2B,4BAA4B;KACnF,MAAM,OAAO,SAAS,cAA2B,kBAAkB;KAEnE,IAAI,eAAe,QAAQ,SAAS,MAAM;MACxC,cAAc,IAAI;MAClB;KACF;KAGA,IAAI,aAAa,QAAQ,iBAAiB,MAAM;MAC9C,SAAS,WAAW;MACpB,SAAS,QAAQ,IAAI;MACrB,eAAe;KACjB;KACA,MAAM,WAAW,WAAW,sBAAsB;KAClD,IAAI,SAAS,UAAU,GAAG;KAE1B,MAAM,2BAAW,IAAI,IAAyB;KAC9C,KAAK,MAAM,OAAO,KAAK,iBAA8B,wBAAwB,GAAG;MAC9E,MAAM,MAAM,IAAI,QAAQ;MACxB,IAAI,QAAQ,KAAA,KAAa,CAAC,SAAS,IAAI,GAAG,GAAG,SAAS,IAAI,KAAK,GAAG;KACpE;KACA,MAAM,OAAuB,CAAC;KAC9B,KAAK,MAAM,OAAO,MAGhB,KAAK,MAAM,aAAa,IAAI,YAAY;MACtC,MAAM,MAAM,SAAS,IAAI,SAAS;MAClC,IAAI,QAAQ,KAAA,GAAW;OACrB,KAAK,KAAK;QAAE,KAAK,IAAI;QAAK,KAAK,IAAI,sBAAsB,CAAC,CAAC,MAAM,SAAS;OAAI,CAAC;OAC/E;MACF;KACF;KAEF,MAAM,OAAO,cAAc,MAAM,kBAAkB,SAAS,MAAM,CAAC;KACnE,eAAe,SAAU,SAAS,OAAO,OAAO,IAAK;IACvD;IACA,MAAM,iBAAuB;KAC3B,IAAI,CAAC,YAAY,QAAQ,GAAG,MAAM,sBAAsB,OAAO;IACjE;IACA,MAAM,WAAW,OAAO,mBAAmB,cAAc,OAAO,IAAI,eAAe,QAAQ;IAE3F,QAAQ;IAKR,SAAS,iBAAiB,UAAU,UAAU,IAAI;IAClD,OAAO,iBAAiB,UAAU,QAAQ;IAC1C,aAAa;KACX,WAAW;KACX,IAAI,QAAQ,GAAG,qBAAqB,GAAG;KACvC,UAAU,WAAW;KACrB,SAAS,oBAAoB,UAAU,UAAU,IAAI;KACrD,OAAO,oBAAoB,UAAU,QAAQ;IAC/C;GACF,GAAG,CAAC,SAAS,IAAI,CAAC;GAGlB,CAAA,GAAA,MAAA,UAAA,aAAsB;IACpB,IAAI,aAAa,YAAY,MAAM,OAAO,aAAa,aAAa,OAAO;IAC3E,IAAI,mBAAmB,YAAY,MAAM,OAAO,aAAa,mBAAmB,OAAO;IACvF,IAAI,eAAe,YAAY,MAAM,OAAO,aAAa,eAAe,OAAO;GAEjF,GAAG,CAAC,CAAC;GAEL,IAAI,CAAC,SAAS,OAAO;GAErB,MAAM,UAAU,QAAuB;IACrC,IAAI,YAAY,KAAA,GAAW;IAC3B,cAAc,IAAI,GAAG;IACrB,MAAM,KAAK,SAAS,IAAI,GAAG;IAC3B,OAAO,iBAAiB,eAAe,MAAO,MAAM,IAAI,MAAM,OAAO,CAAE,GAAG,GAAG;GAC/E;GAEA,MAAM,aAAa,KAAc,WAA8B;IAC7D,MAAM,QAAQ,KAAK,WAAW,MAAM,EAAE,QAAQ,IAAI,GAAG;IACrD,IAAI,QAAQ,GAAG;IACf,iBAAiB;IACjB,MAAM,IAAI,OAAO,sBAAsB;IAIvC,MAAM,aAAa,UAAU,UACzB,EAAE,OAAO,OAAO,aAAa,EAAE,OAAO,GAAG,IACzC,EAAE,MAAM,EAAE,QAAQ,GAAG;IACzB,MAAM,KAAK,KAAK,IAAI,GAAG,QAAA,CAAoB;IAC3C,MAAM,KAAK,KAAK,IAAI,KAAK,SAAS,GAAG,QAAA,CAAoB;IACzD,MAAM,QAA2B,CAAC;IAClC,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,KACxB,MAAM,KAAK;KAAE,KAAK,KAAK;KAAI,UAAU,KAAK,IAAI,IAAI,KAAK;IAAE,CAAC;IAE5D,SAAS;KAAE,KAAK,IAAI;KAAK;KAAO,SAAS,EAAE,MAAM,EAAE,SAAS;KAAG,GAAG;IAAW,CAAC;GAChF;GAEA,MAAM,IAAI,MAAM;GAChB,MAAM,gBAAgB,UAAU,OAAO,KAAK,KAAK,WAAW,MAAM,EAAE,QAAQ,MAAM,GAAG;GAErF,OACE,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;IACE,KAAK;IACL,WAAW,UAAU,UAAU,GAAGC,gCAAO,KAAK,GAAGA,gCAAO,cAAcA,gCAAO;IAC7E,qBAAkB;IAHpB,UAAA;KAKE,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;MAAK,WAAWA,gCAAO;MACpB,UAAA,KAAK,WAAW,IACf,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;OAAK,WAAWA,gCAAO;OAAQ,UAAA,EAAE,aAAa;MAAO,CAAA,IAErD,iBAAA,GAAA,kBAAA,KAAA,CAAA,kBAAA,UAAA,EAAA,UAAA;OACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;QAAM,WAAWA,gCAAO;QAAQ,UAAA,KAAK;OAAa,CAAA;OACjD,aACC,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;QACE,MAAK;QACL,WAAW,GAAGA,gCAAO,OAAO,GAAGA,gCAAO;QACtC,cAAY,EAAE,UAAU;QACxB,OAAO,EAAE,YAAY,YAAY,YAAY,SAAS;QACtD,cAAc;QACd,cAAc;QACd,eAAe,OAAO,EAAE;OACzB,CAAA,IACC;OACJ,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;QACE,KAAK;QACL,WAAWA,gCAAO;QAClB,UAAU;QACV,cAAc;QAEd,UAAA,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;SAAK,WAAWA,gCAAO;SACpB,UAAA,KAAK,KAAK,KAAK,UAAU;UACxB,MAAM,YAAY,UAAU,QAAQ,MAAM,QAAQ,IAAI;UAGtD,MAAM,OAAO,gBAAgB,IAAI,OAAO,UAAU,QAAQ,aAAa;UACvE,MAAM,QAAQ,eAAe,IAAI,MAAM,MAAM,SAAS,OAAO,WAAW,IAAI,IAAI;UAGhF,MAAM,SAAS,UAAU,IAAI,IAAI,GAAG;UACpC,MAAM,MAAM,CAACA,gCAAO,GAAG;UACvB,IAAI,WAAW,IAAI,KAAKA,gCAAO,OAAO;UACtC,IAAI,eAAe,IAAI,KAAK,IAAI,KAAKA,gCAAO,MAAM;UAClD,IAAI,eAAe,IAAI,KAAK,IAAI,KAAKA,gCAAO,OAAO;UACnD,IAAI,WAAW,KAAA,GACb,IAAI,KAAK,OAAO,QAAQ,IAAIA,gCAAO,qBAAqBA,gCAAO,eAAe;UAEhF,OACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;WAEE,WAAW,IAAI,KAAK,GAAG;WACvB,OAAO;YACL,WAAW,SAAS,MAAM;YAC1B,GAAI,WAAW,KAAA,IAAY,EAAE,gBAAgB,GAAG,OAAO,QAAQ,GAAG,IAAI,IAAI,CAAC;WAC7E;WACA,6BAA2B,YAAY,SAAS,KAAA;WAChD,2BAAyB;WACzB,yBAAuB,IAAI;WAC3B,cAAY,IAAI,MAAM,MAAM;WAC5B,eAAe,MAAM,UAAU,KAAK,EAAE,aAAa;WACnD,eAAe,OAAO,GAAG;UAC1B,GAZM,IAAI,GAYV;SAEL,CAAC;QACE,CAAA;OACF,CAAA;OACJ,aACC,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;QACE,MAAK;QACL,WAAW,GAAGA,gCAAO,OAAO,GAAGA,gCAAO;QACtC,cAAY,EAAE,YAAY;QAC1B,OAAO,EAAE,YAAY,cAAc,YAAY,SAAS;QACxD,cAAc;QACd,cAAc;QACd,eAAe,OAAO,CAAC;OACxB,CAAA,IACC;MACJ,EAAA,CAAA;KAED,CAAA;KACJ,SAAS,QAAA,GACNC,UAAAA,aAAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;MACE,WAAWD,gCAAO;MAClB,OAAO,UAAU,UAAU;OAAE,OAAO;OAAI,KAAK;MAAG,IAAI;OAAE,MAAM;OAAI,KAAK;MAAG;MAEvE,UAAA;KACE,CAAA,GACL,SAAS,IACX,IACA;KACH,UAAU,QAAA,GACPC,UAAAA,aAAAA,CAKE,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;MACE,WAAW,UAAU,UAAU,GAAGD,gCAAO,QAAQ,GAAGA,gCAAO,iBAAiBA,gCAAO;MACnF,OAAO;OACL,GAAI,MAAM,SAAS,KAAA,IAAY,EAAE,MAAM,MAAM,KAAK,IAAI,CAAC;OACvD,GAAI,MAAM,UAAU,KAAA,IAAY,EAAE,OAAO,MAAM,MAAM,IAAI,CAAC;OAC1D,KAAK,MAAM;OACX,WAAW;MACb;MACA,cAAc;MACd,cAAc;MAEb,UAAA,MAAM,MAAM,KAAK,SAAS;OACzB,MAAM,UAAU,iBAAiB,KAAK,QAAQ;OAC9C,IAAI,YAAY,MAAM,OAAO;OAC7B,MAAM,aAAa,KAAK,aAAa;OAKrC,MAAM,OAAO,KAAK,IAAI,MAAM,KAAK,KAAK;OACtC,MAAM,MAAM,aACR,GAAGA,gCAAO,KAAK,GAAGA,gCAAO,eAAe,UAAU,UAAU,IAAIA,gCAAO,sBAAsB,OAC7FA,gCAAO;OACX,OACE,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;QAEE,WAAW;QACX,MAAK;QACL,UAAU;QACV,OAAO,EAAE,OAAO,QAAQ,QAAQ;QAChC,eAAe,OAAO,KAAK,GAAG;QANhC,UAAA;SAQG,cAAc,KAAK,IAAI,SAAS,OAC/B,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;UAAK,WAAWA,gCAAO;UAAvB,UAAA,CAAkC,SAAM,KAAK,IAAI,IAAU;SACzD,CAAA,IAAA;SACJ,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;UACE,WAAW,aAAaA,gCAAO,WAAWA,gCAAO;UACjD,OACE,aACI,KAAA,IACC;WAAE,iBAAiB,QAAQ;WAAU,UAAU,QAAQ;UAAS;UAGtE,UAAA,aACG,KAAK,IAAI,MAAM,KAAK,MAAM,cACxB,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;WAAqB,WAAWA,gCAAO;WAAW,UAAA;UAAU,GAAlD,SAAkD,CAC7D,IACD;SACD,CAAA;SACL,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;UAAK,WAAWA,gCAAO;UAAW,UAAA,mBAAmB,KAAK,IAAI,MAAM,KAAK,IAAI,CAAC;SAAO,CAAA;QAClF;OAzBE,GAAA,KAAK,IAAI,GAyBX;MAET,CAAC;KACE,CAAA,GACL,SAAS,IACX,IACA;IACD;;EAET;;;;;;;;;;;EC5rBA,MAAa,gBAAgB,CAAC,QAAQ,OAAO;;EAM7C,MAAa,gBAAiC;;;EAI9C,MAAa,2BAA2B;;EAGxC,MAAa,cAAc;;;;;;;;;;;;ECZ3B,MAAa,oBAAoB;GAAC;GAAG;GAAG;GAAG;EAAE;;EAS7C,MAAa,kBAAkB;;;;;;;;;;;;;;ECK/B,SAAS,gBAAgB,WAA2D;GAClF,MAAM,GAAG,SAAA,GAAQE,MAAAA,SAAAA,CAAS,CAAC;GAC3B,CAAA,GAAA,MAAA,UAAA,OAAgB,gBAAgB,MAAM,MAAM,IAAI,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC;EAClE;EAEA,SAAgB,uBAAuB,OAAiD;GACtF,gBAAgB,MAAM,iBAAiB;GACvC,MAAM,QAAQ,MAAM,MAAM;GAC1B,MAAM,WAAW,MAAM,SAAS;GAChC,MAAM,IAAI,MAAM;GAEhB,OACE,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;IAAK,WAAWC,gCAAO;IAAvB,UAAA;KACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,KAAD;MAAG,WAAWA,gCAAO;MAAgB,UAAA,EAAE,sBAAsB;KAAK,CAAA;KAClE,iBAAA,GAAA,kBAAA,IAAA,CAAC,KAAD;MAAG,WAAWA,gCAAO;MAAe,UAAA,EAAE,qBAAqB;KAAK,CAAA;KAChE,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;MAAK,WAAWA,gCAAO;MAAW,MAAK;MAAa,cAAY,EAAE,sBAAsB;MACrF,UAAA,cAAc,KAAK,WAClB,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;OAEE,MAAK;OACL,MAAK;OACL,gBAAc,UAAU;OACxB,WAAW,UAAU,SAAS,GAAGA,gCAAO,QAAQ,GAAGA,gCAAO,kBAAkBA,gCAAO;OACnF,eAAe,MAAM,SAAS,MAAM;OAEnC,UAAA,EAAE,WAAW,SAAS,wBAAwB,sBAAsB;MAC/D,GARD,MAQC,CACT;KACE,CAAA;KACL,iBAAA,GAAA,kBAAA,IAAA,CAAC,KAAD;MAAG,WAAWA,gCAAO;MAAgB,UAAA,EAAE,yBAAyB;KAAK,CAAA;KACrE,iBAAA,GAAA,kBAAA,IAAA,CAAC,KAAD;MAAG,WAAWA,gCAAO;MAAe,UAAA,EAAE,wBAAwB;KAAK,CAAA;KACnE,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;MAAK,WAAWA,gCAAO;MAAW,MAAK;MAAa,cAAY,EAAE,yBAAyB;MACxF,UAAA,kBAAkB,KAAK,WACtB,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;OAEE,MAAK;OACL,MAAK;OACL,gBAAc,aAAa;OAC3B,WAAW,aAAa,SAAS,GAAGA,gCAAO,QAAQ,GAAGA,gCAAO,kBAAkBA,gCAAO;OACtF,eAAe,MAAM,YAAY,MAAM;OAEtC,UAAA;MACK,GARD,MAQC,CACT;KACE,CAAA;IACF;;EAET;;;;EC7CA,SAAS,kBAAkB,OAA0C;GACnE,OAAO,cAAc,MAAM,WAAW,WAAW,KAAK;EACxD;;EAGA,SAAS,WAAW,OAAmC;GACrD,OAAQ,kBAAyC,SAAS,KAAK;EACjE;;EA0BA,IAAa,gCAAb,MAA2C;GACzC;GACA,4BAA6B,IAAI,IAAgB;GACjD,oBAAwC,CAAC;GACzC,QAA0C;IAAE,OAAO;IAAe,UAAA;IAA6B,YAAY;GAAM;;;;;;;;GASjH,OAAO,QAAuC;IAC5C,IAAI,KAAK,UAAU,KAAA,GAAW;IAC9B,KAAK,QAAQ,OAAO,KAAyB,EAAE,WAAW,yBAAyB,CAAC;IACpF,KAAK,QAAQ,KAAK,OAAO,KAAK,MAAM,YAAY,CAAC;IACjD,KAAK,cAAc,KAAK,MAAM,gBAAgB;KAC5C,IAAI,KAAK,UAAU,KAAA,GAAW;KAC9B,MAAM,OAAO,KAAK,OAAO,KAAK,MAAM,YAAY,CAAC;KACjD,IAAI,KAAK,UAAU,KAAK,MAAM,SAAS,KAAK,aAAa,KAAK,MAAM,YAC/D,KAAK,eAAe,KAAK,MAAM,YAAY;KAChD,KAAK,QAAQ;KACb,KAAK,MAAM,YAAY,KAAK,WAAW,SAAS;IAClD,CAAC;GACH;GAEA,OAAe,UAA+E;IAC5F,MAAM,OAAO,SAAS;IACtB,OAAO;KACL,OAAO,SAAS,WAAW,WAAW,kBAAkB,SAAS,OAAO,KAAK,IACzE,SAAS,MAAM,QACf;KACJ,UAAU,SAAS,WAAW,WAAW,WAAW,SAAS,OAAO,QAAQ,IACxE,SAAS,MAAM,WAAA;KAEnB,YAAY,SAAS,KAAA,MAAc,KAAK,UAAU,KAAA,KAAa,KAAK,aAAa,KAAA;IACnF;GACF;;GAGA,UAAgB;IACd,KAAK,YAAY;IACjB,KAAK,UAAU,MAAM;GACvB;;GAGA,cAAwC;IACtC,OAAO,KAAK;GACd;;GAGA,UAAU,UAAkC;IAC1C,KAAK,UAAU,IAAI,QAAQ;IAC3B,aAAa;KAAE,KAAK,UAAU,OAAO,QAAQ;IAAE;GACjD;;GAGA,SAAS,OAA8B;IACrC,IAAI,KAAK,UAAU,KAAA,GAAW;IAC9B,KAAU,MAAM,IAAI,aAAa,KAAK;GACxC;;GAGA,YAAY,UAA0B;IACpC,IAAI,KAAK,UAAU,KAAA,GAAW;IAC9B,KAAU,MAAM,IAAI,iBAAiB,QAAQ;GAC/C;EACF;;;;;;;EC3HA,MAAa,KAAK;GAChB,eAAe;GACf,YAAY;GACZ,cAAc;GACd,iBAAiB;GACjB,eAAe;GACf,iBAAiB;GACjB,gBAAgB;GAChB,gBAAgB;GAChB,wBAAwB;GACxB,uBAAuB;GACvB,uBAAuB;GACvB,wBAAwB;GACxB,2BAA2B;GAC3B,0BAA0B;EAC5B;EAEA,MAAa,KAAK;GAChB,eAAe;GACf,YAAY;GACZ,cAAc;GACd,iBAAiB;GACjB,eAAe;GACf,iBAAiB;GACjB,gBAAgB;GAChB,gBAAgB;GAChB,wBAAwB;GACxB,uBAAuB;GACvB,uBAAuB;GACvB,wBAAwB;GACxB,2BAA2B;GAC3B,0BAA0B;EAC5B;;;;ECLA,MAAa,iBAAiB,CAAC,QAAQ,UAAU;EASjD,SAAS,SAAS,MAAyC;GACzD,IAAI,OAAO,SAAS,YAAY,SAAS,MAAM,OAAO,KAAA;GACtD,OAAO;EACT;;EAGA,SAAgB,YAAY,SAA0E;GACpG,IAAI,YAAY,KAAA,KAAa,QAAQ,WAAW,GAAG,OAAO;GAC1D,MAAM,QAAQ,QAAQ;GACtB,IAAI,OAAO,OAAO,SAAS,UAAU,OAAO,MAAM;GAClD,OAAO;EACT;;EAGA,SAAgB,iBAAiB,OAA+C;GAC9E,MAAM,MAAsB,CAAC;GAC7B,KAAK,MAAM,QAAQ,OAAO;IACxB,IAAI,CAAC,eAAe,SAAS,KAAK,IAAuC,GAAG;IAC5E,MAAM,UAAU,SAAS,KAAK,IAAI;IAClC,IAAI,KAAK;KACP,KAAK,KAAK;KACV,WAAW,KAAK;KAChB,KAAK,SAAS,OAAO;KACrB,MAAM,SAAS,QAAQ;KAEvB,MAAM,YAAY,SAAS,OAAO;IACpC,CAAC;GACH;GACA,IAAI,MAAM,GAAG,MAAM,EAAE,YAAY,EAAE,SAAS;GAC5C,OAAO;EACT;;EAgBA,SAAgB,kBACd,OACA,YAC2C;GAC3C,IAAI,OAAkD;GACtD,KAAK,MAAM,QAAQ,OAAO;IACxB,IAAI,KAAK,eAAe,UAAU;IAClC,IAAI,KAAK,QAAQ,YAAY;IAC7B,IAAI,SAAS,QAAQ,KAAK,YAAY,KAAK,WAAW,OAAO;KAAE,KAAK,KAAK;KAAK,WAAW,KAAK;IAAU;GAC1G;GACA,OAAO;EACT;;;;;;;;;ECxCA,MAAM,WAAW;GACf,gBAAgB;GAChB,UAAU;GACV,WAAW;GACX,QAAQ;EACV;EAEA,SAAS,aAAa,MAAsD;GAC1E,IAAI,MAAqB;GACzB,KAAK,MAAM,OAAO,MAChB,IAAI,QAAQ,QAAQ,IAAI,YAAY,KAAK,MAAM,IAAI;GAErD,OAAO;EACT;EAEA,SAAS,WAAW,MAA+G;GACjI,MAAM,MAA4C,CAAC;GACnD,KAAK,MAAM,OAAO,MAAM;IACtB,IAAI,IAAI,eAAe,UAAU;IACjC,IAAI,KAAK;KAAE,KAAK,IAAI;KAAK,WAAW,IAAI;IAAU,CAAC;GACrD;GACA,OAAO;EACT;;;;;;EAOA,eAAsB,eAAe,OAAkB,KAAa,UAAuB,CAAC,GAAwB;GAClH,MAAM,MAAM;IAAE,GAAG;IAAU,GAAG;GAAQ;GACtC,MAAM,QAAQ,MAAuB,WAAW,UAAsB;IACpE,MAAM,SAAS,MAAM,QAAQ;IAC7B,OAAO,WAAW;KAAE,IAAI;KAAO;KAAM,UAAU;IAAK,IAAI;KAAE,IAAI;KAAO;IAAK;GAC5E;GAEA,IAAI,CAAC,MAAM,aAAa,GAAG,OAAO,KAAK,eAAe;GAEtD,MAAM,WAAW,MAAM,IAAI,IAAI,IAAI;GACnC,IAAI,QAAQ;GAGZ,OAAO,MAAM;IACX,MAAM,OAAO,MAAM,SAAS;IAC5B,IAAI,SAAS,KAAA,GAAW,OAAO,KAAK,eAAe;IACnD,MAAM,OAAO,WAAW,KAAK,IAAI;IACjC,IAAI,KAAK,MAAM,MAAM,EAAE,QAAQ,GAAG,GAAG;IACrC,IAAI,KAAK,cAAc,QAAQ;KAC7B,IAAI,KAAK,cAAc,WAAW,MAAM,IAAI,IAAI,UAC9C,OAAO,KAAK,KAAK,cAAc,UAAU,kBAAkB,SAAS;KAEtE,MAAM,MAAM,MAAM,IAAI,MAAM;KAC5B;IACF;IACA,IAAI,KAAK,YAAY,MAAM,OAAO,KAAK,WAAW;IAClD,IAAI,SAAS,IAAI,YAAY,MAAM,IAAI,IAAI,UAAU,OAAO,KAAK,SAAS;IAC1E,IAAI,KAAK,cAAc;KACrB,MAAM,MAAM,MAAM,IAAI,MAAM;KAC5B;IACF;IACA,MAAM,SAAS,aAAa,IAAI;IAChC,MAAM,MAAM,UAAU;IACtB,SAAS;IACT,MAAM,YAAY,MAAM,SAAS;IACjC,MAAM,QAAQ,aAAa,cAAc,KAAA,IAAY,CAAC,IAAI,UAAU,IAAI;IACxE,IAAI,UAAU,QAAS,WAAW,QAAQ,SAAS,QAAS,OAAO,KAAK,WAAW;GACrF;GAGA,MAAM,YAAY,OAAO,WAAgD;IACvE,KAAK,IAAI,SAAS,GAAG,UAAU,IAAI,WAAW,UAAU,IAAI,QAAQ;KAClE,IAAI,CAAC,MAAM,aAAa,GAAG,OAAO;KAClC,MAAM,MAAM,MAAM,QAAQ,MAAM;KAChC,IAAI,QAAQ,MAAM,OAAO;KACzB,MAAM,MAAM,MAAM,IAAI,MAAM;IAC9B;IACA,OAAO;GACT;GAEA,MAAM,MAAM,MAAM,UAAU,GAAG;GAC/B,IAAI,QAAQ,MAAM;IAChB,MAAM,eAAe,GAAG;IACxB,OAAO,EAAE,IAAI,KAAK;GACpB;GAEA,MAAM,OAAO,MAAM,SAAS;GAC5B,MAAM,WAAW,kBAAkB,SAAS,KAAA,IAAY,CAAC,IAAI,KAAK,MAAM,GAAG;GAC3E,IAAI,aAAa,MAAM;IACrB,MAAM,QAAQ,MAAM,UAAU,SAAS,GAAG;IAC1C,IAAI,UAAU,MAAM;KAClB,MAAM,eAAe,KAAK;KAC1B,OAAO,KAAK,iBAAiB,IAAI;IACnC;GACF;GACA,OAAO,KAAK,iBAAiB,KAAK;EACpC;;;;ECnHA,MAAM,KAAK;;EAUX,MAAa,SAAS;GAAC;GAAS;GAAU;EAAU;EAQpD,SAAS,aAAsB;GAC7B,IAAI,WAAW,4BAA4B,MAAM,OAAO;GACxD,WAAW,0BAA0B;GACrC,OAAO;EACT;EAEA,SAAS,eAAqB;GAC5B,WAAW,0BAA0B,KAAA;EACvC;;EAGA,SAAS,aAAa,KAAoB,WAAiC;GACzE,OAAO;IACL,gBAAgB;KAEd,MAAM,OADU,IAAI,SAAS,QAAQ,SAClB,CAAC,EAAE,QAAQ,YAAY;KAC1C,IAAI,SAAS,KAAA,GAAW,OAAO,KAAA;KAC/B,OAAO;MACL,WAAW,KAAK;MAChB,SAAS,KAAK;MACd,cAAc,KAAK;MACnB,MAAM,KAAK,KAAK,MAAM,OAAO;KAC/B;IACF;IACA,WAAW,YAAY;KACrB,MAAM,UAAU,IAAI,SAAS,QAAQ,SAAS;KAC9C,IAAI,YAAY,KAAA,GAAW,MAAM,IAAI,MAAM,qBAAqB;KAChE,MAAM,QAAQ,QAAQ,UAAU;IAClC;IACA,oBAAoB,SAAS,cAAc,kBAAkB,MAAM;IACnE,UAAU,QAAgB;KACxB,KAAK,MAAM,aAAa,MAAM,KAAK,SAAS,iBAA8B,wBAAwB,CAAC,GACjG,IAAI,UAAU,QAAQ,kBAAkB,KAAK,OAAO;KAEtD,OAAO;IACT;IACA,iBAAiB,QAAQ,IAAI,eAAe,EAAE,OAAO,QAAQ,CAAC;IAC9D,WAAW,KAAK,IAAI;IACpB,QAAQ,OAAO,IAAI,SAAS,YAAY,OAAO,WAAW,SAAS,EAAE,CAAC;GACxE;EACF;;;;;;EAOA,SAAS,qBAAqB,KAAoB,WAAkD;GAClG,MAAM,OAAO,IAAI,SAAS,QAAQ,SAAS,CAAC,EAAE,QAAQ,YAAY,OAAO,eAAe;GACxF,IAAI,SAAS,KAAA,GAAW,OAAO,KAAA;GAC/B,OAAO;IACL,mBAAmB,KAAK,YAAY;IACpC,YAAY,aAAa,KAAK,UAAU,QAAQ;GAClD;EACF;EAEA,SAAS,aAAa,KAAoB,UAA8D;GACtG,OAAO;IACL,gBAAgB,cAAc;KAC5B,MAAM,OAAO,IAAI,SAAS,QAAQ,SAAS,CAAC,EAAE,QAAQ,YAAY;KAClE,IAAI,SAAS,KAAA,GAAW,OAAO,CAAC;KAChC,OAAO,iBAAiB,KAAK,KAAK,MAAM,OAAO,CAAC;IAClD;IACA,gBAAgB,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE;IACrD,mBAAmB,WAAW,OAAO;KACnC,MAAM,UAAU,IAAI,SAAS,QAAQ,SAAS;KAC9C,IAAI,YAAY,KAAA,GAAW,aAAa,CAAC;KACzC,OAAO,QAAQ,QAAQ,UAAU,EAAE;IACrC;IACA,qBAAqB,cAAc,qBAAqB,KAAK,SAAS;IACtE,OAAO,WAAW,QAAQ;KACxB,MAAM,QAAQ,aAAa,KAAK,SAAS;KACzC,MAAM,UAAU,SAA0B;MAGxC,OAAO,cAAc,IAAI,YAAY,4BAA4B,EAAE,QAAQ,KAAK,CAAC,CAAC;KACpF;KACA,eAAoB,OAAO,GAAG;IAChC;IACA,aAAa,SAAS,YAAY,CAAC,CAAC;IACpC,oBAAoB,OAAO,SAAS,UAAU,EAAE;IAChD,WAAW,UAAU,SAAS,SAAS,KAAK;IAC5C,gBAAgB,SAAS,YAAY,CAAC,CAAC;IACvC,cAAc,aAAa,SAAS,YAAY,QAAQ;GAC1D;EACF;;;;;EAMA,SAAgB,MAAM,KAA0B;GAC9C,IAAI,CAAC,WAAW,GAAG;GACnB,IAAI,aAAa,cAAc,2BAA2B;GAE1D,IAAI,aAAa,IAAI,OAAO,SAAS,IAAI;IAAE;IAAI;GAAG,CAAC,GAAG,4BAA4B;GAClF,MAAM,IAAI,IAAI,OAAO,KAAK,EAAE;GAE5B,MAAM,WAAW,IAAI,8BAA8B;GACnD,MAAM,WAAW,aAAa,KAAK,QAAQ;GAE3C,IAAI,MAAM,OAAO,uBAAuB,IAAI,MAAM,SAAS;IACzD,MAAM;IACN,IAAI;IACJ,OAAO;IACP,QAAQ;IACR,cAAc;GAChB,GAAG,gBAAgB,CAAC;GAKpB,IAAI,OAAO,CAAC,eAAe,IAAI,aAAa;IAC1C,SAAS,OAAO,SAAS,IAAI,eAAe,CAAwB;IACpE,IAAI,MAAM,OAAO,8BAA8B,IAAI,MAAM,SAAS;KAChE,MAAM;KACN,IAAI;KACJ,OAAO;KACP,aAAa,EAAE,cAAc;KAC7B,QAAQ;KACR,cAAc;IAChB,GAAG,sBAAsB,CAAC;GAC5B,CAAC;EACH"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Scroll-spy rule for the question-nav strip: which dot marks the reader's
3
+ * current position in the chat. Pure over pre-measured row tops (scrollport
4
+ * coordinates), so the decision is unit-testable without a DOM — the browser
5
+ * half only measures and feeds rows in dot order.
6
+ */
7
+ /** One rendered dot row's position relative to the scrollport's top edge. */
8
+ export interface ActiveDotRow {
9
+ /** Dot key (the turn's jump anchor). */
10
+ key: string;
11
+ /** Row top minus scrollport top, in px. */
12
+ top: number;
13
+ }
14
+ /**
15
+ * Pick the dot that marks the reader's position: the LAST row (in dot order)
16
+ * whose top sits at or above the reading line — the turn the reader has
17
+ * reached. When the line sits above every rendered row (reading the intro
18
+ * before the first question), the FIRST row marks the upcoming turn instead,
19
+ * so the marker is always present while questions are on screen. Returns null
20
+ * when no dot row is rendered (trajectory view, or a window that has not
21
+ * mounted any question yet).
22
+ *
23
+ * Rows are expected in dot order; tops are not re-sorted — the scan keeps the
24
+ * last qualifying entry, so one stale out-of-order measurement never moves
25
+ * the marker backwards past a later row.
26
+ */
27
+ export declare function pickActiveDot(rows: readonly ActiveDotRow[], readingLine: number): string | null;
28
+ /**
29
+ * Reading-line offset from the scrollport top: 35% of the visible height,
30
+ * clamped to at least 48px so short viewports keep a usable top margin. The
31
+ * marker tracks the turn at this line rather than the exact top edge, which
32
+ * reads as "the question I am looking at" instead of "the question that just
33
+ * left the screen".
34
+ */
35
+ export declare function readingLineOffset(scrollportHeight: number): number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@luziyang2026/dsh-question-nav",
3
3
  "description": "In-session question navigator for the DSH web GUI: a vertical minimap of round dots overlaid on the edge of the conversation column (left or right, configurable in settings), one dot per user question — hovering focuses a magnified window and a vertical cascade of crisp question cards showing the full text and sent time of each (any card is clickable), click jumps to that message.",
4
- "version": "0.7.5",
4
+ "version": "0.7.7",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
@@ -23,6 +23,11 @@
23
23
  * appears only while its direction has more to reveal) — no hover auto-scroll
24
24
  * of any kind.
25
25
  *
26
+ * A scroll-spy marks the reader's position: the dot of the turn at the
27
+ * reading line (35% down the scrollport) carries a ring marker that follows
28
+ * the conversation as it scrolls, streams, or pages its window. The marker
29
+ * never scrolls the band — it is purely a position indicator.
30
+ *
26
31
  * Data source: the host-folded `questionIndex` session projection (whole
27
32
  * history, persisted host-side, pushed live through session/projection
28
33
  * frames) read through the injected `questionProjection` face, plus the live
@@ -47,6 +52,7 @@ import type { AlignPreference } from '../core/align.ts'
47
52
  import type { PageSize } from '../core/page-size.ts'
48
53
  import type { JumpFailureCode } from '../core/jump.ts'
49
54
  import { DOT_GAP, DOT_SIZE, FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, pageStep } from '../core/focus.ts'
55
+ import { pickActiveDot, readingLineOffset, type ActiveDotRow } from '../core/active-dot.ts'
50
56
  import { formatQuestionTime } from '../core/time.ts'
51
57
  import type { QuestionNavKey } from './locales.ts'
52
58
  import styles from './question-nav.module.css'
@@ -153,6 +159,8 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
153
159
  const [jumpingKey, setJumpingKey] = useState<string | null>(null)
154
160
  const [hint, setHint] = useState<string | null>(null)
155
161
  const [focus, setFocus] = useState<FocusState | null>(null)
162
+ // Scroll-spy: the dot marking the reader's current turn (null outside chat).
163
+ const [currentKey, setCurrentKey] = useState<string | null>(null)
156
164
  const [align, setAlign] = useState<AlignPreference>(() => props.align())
157
165
  // Dots revealed per ▲/▼ click (settings scope; drives pageBy's step).
158
166
  const [pageSize, setPageSize] = useState<PageSize>(() => props.pageSize())
@@ -351,7 +359,14 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
351
359
  // Anchor to the configured edge: keep the other edge's inline style
352
360
  // cleared so the CSS class (left: auto on .railRight) owns it.
353
361
  if (align === 'right') {
354
- const right = `${frameRect.right - convRect.right}px`
362
+ // Give back the conversation scrollbar's layout width so the 44px
363
+ // rail never covers the bar: the WebKit path consumes 8px at the
364
+ // column's right edge, which the rail would otherwise render on top
365
+ // of, making the bar unclickable. ui-theme mirrors that width as
366
+ // --dsh-scrollbar-width (the overlay composer seat offsets `right`
367
+ // by the same variable); 8px fallback covers engines where the
368
+ // variable is absent.
369
+ const right = `calc(${frameRect.right - convRect.right}px + var(--dsh-scrollbar-width, 8px))`
355
370
  if (panel.style.top === top && panel.style.height === height
356
371
  && panel.style.right === right && panel.style.left === '') {
357
372
  return false
@@ -423,6 +438,80 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
423
438
  return () => observer.disconnect()
424
439
  }, [dots, align])
425
440
 
441
+ // Scroll-spy: mark the dot whose turn sits at the reader's position and
442
+ // follow the conversation as it scrolls. The reading line is 35% down the
443
+ // scrollport (see core/active-dot.ts), so the marker reads as "the question
444
+ // I am looking at". Only rendered rows participate: a paged-out window
445
+ // unmounts older questions, and the marker tracks what is on screen.
446
+ useEffect(() => {
447
+ if (!visible || dots.length === 0) {
448
+ setCurrentKey(null)
449
+ return
450
+ }
451
+ let raf = 0
452
+ let disposed = false
453
+ let observedFlow: Element | null = null
454
+
455
+ const compute = (): void => {
456
+ raf = 0
457
+ const scrollport = document.querySelector<HTMLElement>('[data-conversation-scroll]')
458
+ const flow = document.querySelector<HTMLElement>('[data-chat-flow]')
459
+ // Trajectory view / chat not mounted: no marker.
460
+ if (scrollport === null || flow === null) {
461
+ setCurrentKey(null)
462
+ return
463
+ }
464
+ // Content growth (streaming, paging) shifts row tops without a scroll
465
+ // event; re-observe the flow element across remounts.
466
+ if (observer !== null && observedFlow !== flow) {
467
+ observer.disconnect()
468
+ observer.observe(flow)
469
+ observedFlow = flow
470
+ }
471
+ const portRect = scrollport.getBoundingClientRect()
472
+ if (portRect.height <= 0) return
473
+ // Key lookup without interpolating a selector (chat keys contain ':').
474
+ const rendered = new Map<string, HTMLElement>()
475
+ for (const row of flow.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')) {
476
+ const key = row.dataset.chatAnchorKey
477
+ if (key !== undefined && !rendered.has(key)) rendered.set(key, row)
478
+ }
479
+ const rows: ActiveDotRow[] = []
480
+ for (const dot of dots) {
481
+ // The turn's first rendered question row anchors the dot (memberKeys
482
+ // are in seq order, so the first hit is the turn's topmost row).
483
+ for (const memberKey of dot.memberKeys) {
484
+ const row = rendered.get(memberKey)
485
+ if (row !== undefined) {
486
+ rows.push({ key: dot.key, top: row.getBoundingClientRect().top - portRect.top })
487
+ break
488
+ }
489
+ }
490
+ }
491
+ const next = pickActiveDot(rows, readingLineOffset(portRect.height))
492
+ setCurrentKey((prev) => (prev === next ? prev : next))
493
+ }
494
+ const schedule = (): void => {
495
+ if (!disposed && raf === 0) raf = requestAnimationFrame(compute)
496
+ }
497
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule)
498
+
499
+ compute()
500
+ // Scroll events do not bubble; capturing at the document reaches the
501
+ // conversation scrollport today and after any remount without tracking
502
+ // the node. rAF-throttled, so hot scroll streams coalesce into one
503
+ // measurement per frame.
504
+ document.addEventListener('scroll', schedule, true)
505
+ window.addEventListener('resize', schedule)
506
+ return () => {
507
+ disposed = true
508
+ if (raf !== 0) cancelAnimationFrame(raf)
509
+ observer?.disconnect()
510
+ document.removeEventListener('scroll', schedule, true)
511
+ window.removeEventListener('resize', schedule)
512
+ }
513
+ }, [visible, dots])
514
+
426
515
  // Clear any pending timers on unmount.
427
516
  useEffect(() => () => {
428
517
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
@@ -505,6 +594,7 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
505
594
  const cls = [styles.dot]
506
595
  if (isFocused) cls.push(styles.focused)
507
596
  if (jumpingKey === dot.key) cls.push(styles.active)
597
+ if (currentKey === dot.key) cls.push(styles.current)
508
598
  if (reveal !== undefined) {
509
599
  cls.push(reveal.dir === 1 ? styles.dotEnterFromBottom : styles.dotEnterFromTop)
510
600
  }
@@ -88,6 +88,16 @@
88
88
  .dot.active {
89
89
  background: var(--dsw-alias-brand-primary);
90
90
  }
91
+ /* Scroll-spy marker: the turn at the reader's position. Brand fill plus an
92
+ offset ring so it stays recognizable next to plain dots and composes with
93
+ the hover/focus/jump fill states (those recolor the interior; the ring
94
+ persists). Outline never affects layout, and the gap stays transparent so
95
+ no backdrop color has to be punched out. */
96
+ .dot.current {
97
+ background: var(--dsw-alias-brand-primary);
98
+ outline: 1.5px solid var(--dsw-alias-brand-primary);
99
+ outline-offset: 2.5px;
100
+ }
91
101
 
92
102
  /* Click-paging feedback: dots revealed by a ▲/▼ click pop in with a staggered
93
103
  fade + rise/drop (per-dot animation-delay is set inline, stagger order *
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Scroll-spy rule for the question-nav strip: which dot marks the reader's
3
+ * current position in the chat. Pure over pre-measured row tops (scrollport
4
+ * coordinates), so the decision is unit-testable without a DOM — the browser
5
+ * half only measures and feeds rows in dot order.
6
+ */
7
+
8
+ /** One rendered dot row's position relative to the scrollport's top edge. */
9
+ export interface ActiveDotRow {
10
+ /** Dot key (the turn's jump anchor). */
11
+ key: string
12
+ /** Row top minus scrollport top, in px. */
13
+ top: number
14
+ }
15
+
16
+ /**
17
+ * Pick the dot that marks the reader's position: the LAST row (in dot order)
18
+ * whose top sits at or above the reading line — the turn the reader has
19
+ * reached. When the line sits above every rendered row (reading the intro
20
+ * before the first question), the FIRST row marks the upcoming turn instead,
21
+ * so the marker is always present while questions are on screen. Returns null
22
+ * when no dot row is rendered (trajectory view, or a window that has not
23
+ * mounted any question yet).
24
+ *
25
+ * Rows are expected in dot order; tops are not re-sorted — the scan keeps the
26
+ * last qualifying entry, so one stale out-of-order measurement never moves
27
+ * the marker backwards past a later row.
28
+ */
29
+ export function pickActiveDot(rows: readonly ActiveDotRow[], readingLine: number): string | null {
30
+ if (rows.length === 0) return null
31
+ let active: string | null = null
32
+ for (const row of rows) {
33
+ if (row.top <= readingLine) active = row.key
34
+ }
35
+ return active ?? rows[0].key
36
+ }
37
+
38
+ /**
39
+ * Reading-line offset from the scrollport top: 35% of the visible height,
40
+ * clamped to at least 48px so short viewports keep a usable top margin. The
41
+ * marker tracks the turn at this line rather than the exact top edge, which
42
+ * reads as "the question I am looking at" instead of "the question that just
43
+ * left the screen".
44
+ */
45
+ export function readingLineOffset(scrollportHeight: number): number {
46
+ return Math.max(48, scrollportHeight * 0.35)
47
+ }