dsh-mega-chat-nav 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/LICENSE +201 -0
  2. package/README.en.md +149 -0
  3. package/README.md +149 -0
  4. package/cordis.patch.yml +4 -0
  5. package/lib/client/NavOverlay.js +35 -0
  6. package/lib/client/NavRail.js +12 -0
  7. package/lib/client/QuestionNavSettingsTab.js +26 -0
  8. package/lib/client/QuestionNavStrip.js +555 -0
  9. package/lib/client/components/ConfigPanel.js +14 -0
  10. package/lib/client/components/DotRail.js +62 -0
  11. package/lib/client/components/NavOverlay.js +121 -0
  12. package/lib/client/components/NavRail.js +97 -0
  13. package/lib/client/components/RailDispatch.js +22 -0
  14. package/lib/client/components/RailHost.js +79 -0
  15. package/lib/client/components/SearchBox.js +66 -0
  16. package/lib/client/components/SettingsPage.js +46 -0
  17. package/lib/client/components/StandaloneSettings.js +16 -0
  18. package/lib/client/components/codex/CodexRail.js +139 -0
  19. package/lib/client/components/deepseek/DeepseekRail.js +264 -0
  20. package/lib/client/components/minimal/MinimalRail.js +135 -0
  21. package/lib/client/components/minimal/cards.js +32 -0
  22. package/lib/client/components/minimal/dot.js +11 -0
  23. package/lib/client/components/minimal/hint.js +30 -0
  24. package/lib/client/components/minimal/paging.js +5 -0
  25. package/lib/client/components/minimal/quick-settings.js +28 -0
  26. package/lib/client/components/minimal/search.js +67 -0
  27. package/lib/client/components/minimal/useRail.js +716 -0
  28. package/lib/client/components/rail/cards.js +32 -0
  29. package/lib/client/components/rail/hint.js +30 -0
  30. package/lib/client/components/rail/paging.js +5 -0
  31. package/lib/client/components/rail/quick-settings.js +28 -0
  32. package/lib/client/components/rail/search.js +76 -0
  33. package/lib/client/components/rail/useRail.js +845 -0
  34. package/lib/client/components/settings/SettingsPage.js +46 -0
  35. package/lib/client/components/settings/SettingsTab.js +17 -0
  36. package/lib/client/components/settings/StandaloneSettings.js +16 -0
  37. package/lib/client/components/shared/Cascade.js +26 -0
  38. package/lib/client/components/shared/ConfigPanel.js +27 -0
  39. package/lib/client/components/shared/Dot.js +8 -0
  40. package/lib/client/components/shared/Hint.js +5 -0
  41. package/lib/client/components/shared/PagingButton.js +5 -0
  42. package/lib/client/components/shared/RailShell.js +78 -0
  43. package/lib/client/components/shared/SearchBox.js +63 -0
  44. package/lib/client/components/shared/hooks.js +427 -0
  45. package/lib/client/components/shared/types.js +1 -0
  46. package/lib/client/config.js +1 -0
  47. package/lib/client/engines/jump.js +34 -0
  48. package/lib/client/engines/useAtBottom.js +25 -0
  49. package/lib/client/engines/useJump.js +40 -0
  50. package/lib/client/engines/useScrollSpy.js +88 -0
  51. package/lib/client/hooks.js +11 -0
  52. package/lib/client/index.js +312 -0
  53. package/lib/client/jump.js +255 -0
  54. package/lib/client/locales.js +142 -0
  55. package/lib/client/pure.js +61 -0
  56. package/lib/client/settings.js +369 -0
  57. package/lib/client/virtual/VirtualList.js +21 -0
  58. package/lib/client.js +3213 -0
  59. package/lib/compat.js +145 -0
  60. package/lib/config.js +37 -0
  61. package/lib/core/active-dot.js +17 -0
  62. package/lib/core/align.js +9 -0
  63. package/lib/core/config.js +37 -0
  64. package/lib/core/focus.js +38 -0
  65. package/lib/core/jump.js +98 -0
  66. package/lib/core/logic/active-dot.js +16 -0
  67. package/lib/core/logic/focus.js +34 -0
  68. package/lib/core/logic/jump.js +188 -0
  69. package/lib/core/logic/nodes.js +59 -0
  70. package/lib/core/model/nav-item.js +1 -0
  71. package/lib/core/model/turns.js +73 -0
  72. package/lib/core/nav-item.js +1 -0
  73. package/lib/core/nodes.js +57 -0
  74. package/lib/core/page-size.js +7 -0
  75. package/lib/core/question-entry.js +1 -0
  76. package/lib/core/time.js +23 -0
  77. package/lib/core/turn-dots.js +85 -0
  78. package/lib/core/utils/measure.js +24 -0
  79. package/lib/core/utils/time.js +26 -0
  80. package/lib/index.js +64 -0
  81. package/lib/nav-item.js +1 -0
  82. package/lib/nodes.js +59 -0
  83. package/lib/projection.js +126 -0
  84. package/lib/schema.js +26 -0
  85. package/lib/search.js +285 -0
  86. package/lib/settings.js +89 -0
  87. package/lib/types/client/NavOverlay.d.ts +18 -0
  88. package/lib/types/client/NavRail.d.ts +7 -0
  89. package/lib/types/client/QuestionNavSettingsTab.d.ts +16 -0
  90. package/lib/types/client/QuestionNavStrip.d.ts +48 -0
  91. package/lib/types/client/components/ConfigPanel.d.ts +14 -0
  92. package/lib/types/client/components/DotRail.d.ts +15 -0
  93. package/lib/types/client/components/NavOverlay.d.ts +30 -0
  94. package/lib/types/client/components/NavRail.d.ts +16 -0
  95. package/lib/types/client/components/RailDispatch.d.ts +8 -0
  96. package/lib/types/client/components/RailHost.d.ts +27 -0
  97. package/lib/types/client/components/SearchBox.d.ts +9 -0
  98. package/lib/types/client/components/SettingsPage.d.ts +9 -0
  99. package/lib/types/client/components/StandaloneSettings.d.ts +13 -0
  100. package/lib/types/client/components/codex/CodexRail.d.ts +3 -0
  101. package/lib/types/client/components/deepseek/DeepseekRail.d.ts +3 -0
  102. package/lib/types/client/components/minimal/MinimalRail.d.ts +8 -0
  103. package/lib/types/client/components/minimal/cards.d.ts +19 -0
  104. package/lib/types/client/components/minimal/dot.d.ts +14 -0
  105. package/lib/types/client/components/minimal/hint.d.ts +11 -0
  106. package/lib/types/client/components/minimal/paging.d.ts +10 -0
  107. package/lib/types/client/components/minimal/quick-settings.d.ts +8 -0
  108. package/lib/types/client/components/minimal/search.d.ts +27 -0
  109. package/lib/types/client/components/minimal/useRail.d.ts +90 -0
  110. package/lib/types/client/components/rail/cards.d.ts +19 -0
  111. package/lib/types/client/components/rail/hint.d.ts +11 -0
  112. package/lib/types/client/components/rail/paging.d.ts +10 -0
  113. package/lib/types/client/components/rail/quick-settings.d.ts +8 -0
  114. package/lib/types/client/components/rail/search.d.ts +34 -0
  115. package/lib/types/client/components/rail/useRail.d.ts +132 -0
  116. package/lib/types/client/components/settings/SettingsPage.d.ts +9 -0
  117. package/lib/types/client/components/settings/SettingsTab.d.ts +6 -0
  118. package/lib/types/client/components/settings/StandaloneSettings.d.ts +13 -0
  119. package/lib/types/client/components/shared/Cascade.d.ts +14 -0
  120. package/lib/types/client/components/shared/ConfigPanel.d.ts +8 -0
  121. package/lib/types/client/components/shared/Dot.d.ts +17 -0
  122. package/lib/types/client/components/shared/Hint.d.ts +6 -0
  123. package/lib/types/client/components/shared/PagingButton.d.ts +10 -0
  124. package/lib/types/client/components/shared/RailShell.d.ts +43 -0
  125. package/lib/types/client/components/shared/SearchBox.d.ts +18 -0
  126. package/lib/types/client/components/shared/hooks.d.ts +64 -0
  127. package/lib/types/client/components/shared/types.d.ts +101 -0
  128. package/lib/types/client/config.d.ts +9 -0
  129. package/lib/types/client/engines/jump.d.ts +17 -0
  130. package/lib/types/client/engines/useAtBottom.d.ts +1 -0
  131. package/lib/types/client/engines/useJump.d.ts +16 -0
  132. package/lib/types/client/engines/useScrollSpy.d.ts +12 -0
  133. package/lib/types/client/hooks.d.ts +7 -0
  134. package/lib/types/client/index.d.ts +31 -0
  135. package/lib/types/client/jump.d.ts +47 -0
  136. package/lib/types/client/locales.d.ts +142 -0
  137. package/lib/types/client/pure.d.ts +21 -0
  138. package/lib/types/client/settings.d.ts +173 -0
  139. package/lib/types/client/virtual/VirtualList.d.ts +15 -0
  140. package/lib/types/compat.d.ts +44 -0
  141. package/lib/types/config.d.ts +44 -0
  142. package/lib/types/core/active-dot.d.ts +15 -0
  143. package/lib/types/core/align.d.ts +10 -0
  144. package/lib/types/core/config.d.ts +44 -0
  145. package/lib/types/core/focus.d.ts +24 -0
  146. package/lib/types/core/jump.d.ts +39 -0
  147. package/lib/types/core/logic/active-dot.d.ts +15 -0
  148. package/lib/types/core/logic/focus.d.ts +22 -0
  149. package/lib/types/core/logic/jump.d.ts +39 -0
  150. package/lib/types/core/logic/nodes.d.ts +48 -0
  151. package/lib/types/core/model/nav-item.d.ts +12 -0
  152. package/lib/types/core/model/turns.d.ts +30 -0
  153. package/lib/types/core/nav-item.d.ts +12 -0
  154. package/lib/types/core/nodes.d.ts +43 -0
  155. package/lib/types/core/page-size.d.ts +8 -0
  156. package/lib/types/core/question-entry.d.ts +12 -0
  157. package/lib/types/core/time.d.ts +4 -0
  158. package/lib/types/core/turn-dots.d.ts +29 -0
  159. package/lib/types/core/utils/measure.d.ts +12 -0
  160. package/lib/types/core/utils/time.d.ts +4 -0
  161. package/lib/types/index.d.ts +4 -0
  162. package/lib/types/nav-item.d.ts +12 -0
  163. package/lib/types/nodes.d.ts +48 -0
  164. package/lib/types/projection.d.ts +71 -0
  165. package/lib/types/schema.d.ts +37 -0
  166. package/lib/types/search.d.ts +42 -0
  167. package/lib/types/settings.d.ts +99 -0
  168. package/package.json +71 -0
  169. package/src/client/components/RailDispatch.tsx +27 -0
  170. package/src/client/components/codex/CodexRail.tsx +348 -0
  171. package/src/client/components/deepseek/DeepseekRail.tsx +522 -0
  172. package/src/client/components/minimal/MinimalRail.tsx +324 -0
  173. package/src/client/components/minimal/dot.tsx +32 -0
  174. package/src/client/components/rail/cards.tsx +102 -0
  175. package/src/client/components/rail/hint.tsx +50 -0
  176. package/src/client/components/rail/paging.tsx +26 -0
  177. package/src/client/components/rail/quick-settings.tsx +130 -0
  178. package/src/client/components/rail/search.tsx +147 -0
  179. package/src/client/components/rail/useRail.ts +906 -0
  180. package/src/client/components/settings/SettingsPage.tsx +198 -0
  181. package/src/client/components/settings/StandaloneSettings.tsx +28 -0
  182. package/src/client/components/shared/types.ts +99 -0
  183. package/src/client/index.ts +388 -0
  184. package/src/client/jump.ts +293 -0
  185. package/src/client/locales.ts +143 -0
  186. package/src/client/settings.ts +482 -0
  187. package/src/client/styles.css +1580 -0
  188. package/src/compat.ts +161 -0
  189. package/src/index.ts +66 -0
  190. package/src/projection.ts +174 -0
  191. package/src/search.ts +291 -0
  192. package/src/settings.ts +169 -0
@@ -0,0 +1,555 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Question-nav minimap. Renders a vertical column of small round dots overlaid
4
+ * on the LEFT edge of the conversation column (via the frame-wide
5
+ * `shell.overlay` floating layer): one dot per turn that claimed at least one
6
+ * user question — strictly aligned with the Trajectory view's turn numbering
7
+ * (turns without a question produce no dot). The dot column is vertically
8
+ * centered and clamped to at most 60% of the conversation height, scrolling
9
+ * within that band when the session has more dots than fit.
10
+ *
11
+ * Hovering a dot "selects" it: the selected dot plus its two immediate
12
+ * neighbors on each side magnify (progressively smaller with distance), and a
13
+ * vertical cascade of question cards opens — the selected (center) card is the
14
+ * focus (brand accent, elevated, full question text), the four neighbors are
15
+ * narrower context cards clamped to fewer lines. Every card is clickable and
16
+ * jumps to its question, exactly like clicking the dot. Hovering never scrolls
17
+ * the band — the dot column stays put while you browse.
18
+ *
19
+ * Overflow is paged, not auto-scrolled: two small triangle buttons in the dot
20
+ * style sit above and below the dot queue (▲ / ▼), each click revealing one
21
+ * page of hidden dots (the page size is configurable in the plugin settings,
22
+ * default 5) with a staggered pop-in animation as click feedback. The native
23
+ * scrollbar stays hidden; the triangles themselves are the overflow cue (each
24
+ * appears only while its direction has more to reveal) — no hover auto-scroll
25
+ * of any kind.
26
+ *
27
+ * A scroll-spy marks the reader's position: the dot of the turn at the
28
+ * reading line (35% down the scrollport) carries a ring marker that follows
29
+ * the conversation as it scrolls, streams, or pages its window. The marker
30
+ * never scrolls the band — it is purely a position indicator.
31
+ *
32
+ * Data source: the host-folded `msgNavMessages` session projection (whole
33
+ * history, persisted host-side, pushed live through session/projection
34
+ * frames) read through the injected `questionProjection` face, plus the live
35
+ * chat window's questions merged on top for the brief window before a
36
+ * just-sent question lands in the projection. No render-window expansion, no
37
+ * client-side history paging.
38
+ *
39
+ * Data arrives through the props shares: the framework `useSessions` hook
40
+ * (current session), the registrant inject face (read/subscribe/project/
41
+ * jump), and the bound locale translator.
42
+ */
43
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
44
+ import { createPortal } from 'react-dom';
45
+ import { groupQuestionsByTurn, mergeLiveQuestions } from "../core/turn-dots.js";
46
+ import { DOT_GAP, DOT_SIZE, FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, pageStep } from "../core/focus.js";
47
+ import { pickActiveDot, readingLineOffset } from "../core/active-dot.js";
48
+ import { formatQuestionTime } from "../core/time.js";
49
+ const FAILURE_HINTS = {
50
+ VIEW_INACTIVE: 'jump.inactive',
51
+ TARGET_HIDDEN: 'jump.hidden',
52
+ NOT_FOUND: 'jump.notfound',
53
+ TIMEOUT: 'jump.timeout',
54
+ };
55
+ /** Read the projection face value as a question-entry list (structural guard). */
56
+ function projectionEntries(face) {
57
+ const value = face?.getSnapshot();
58
+ if (!Array.isArray(value))
59
+ return [];
60
+ return value.filter((item) => typeof item === 'object' && item !== null
61
+ && typeof item.id === 'string'
62
+ && typeof item.seq === 'number'
63
+ && typeof item.turn === 'number');
64
+ }
65
+ function findConvRoot() {
66
+ return document.querySelector('[data-slot="conversation"] > div[data-phase]');
67
+ }
68
+ /** Structural equality of two dot lists (member keys fully capture a dot's
69
+ * folded questions, so identical key sequences mean identical content).
70
+ * Lets the strip skip a re-render when a refresh produced no change. */
71
+ function sameDots(a, b) {
72
+ if (a.length !== b.length)
73
+ return false;
74
+ for (let i = 0; i < a.length; i++) {
75
+ const da = a[i];
76
+ const db = b[i];
77
+ if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length)
78
+ return false;
79
+ for (let j = 0; j < da.memberKeys.length; j++) {
80
+ if (da.memberKeys[j] !== db.memberKeys[j])
81
+ return false;
82
+ }
83
+ }
84
+ return true;
85
+ }
86
+ export function QuestionNavStrip(props) {
87
+ const current = props.useSessions?.((s) => s.current);
88
+ const summary = props.useSessions?.((s) => (current === undefined ? undefined : s.byId?.[String(current)]));
89
+ const visible = current !== undefined && summary !== undefined && summary.blank !== true;
90
+ const [dots, setDots] = useState([]);
91
+ const [jumpingKey, setJumpingKey] = useState(null);
92
+ const [hint, setHint] = useState(null);
93
+ const [focus, setFocus] = useState(null);
94
+ // Scroll-spy: the dot marking the reader's current turn (null outside chat).
95
+ const [currentKey, setCurrentKey] = useState(null);
96
+ const [align, setAlign] = useState(() => props.align());
97
+ // Dots revealed per ▲/▼ click (settings scope; drives pageBy's step).
98
+ const [pageSize, setPageSize] = useState(() => props.pageSize());
99
+ const panelRef = useRef(null);
100
+ const listRef = useRef(null);
101
+ const hintTimerRef = useRef(null);
102
+ // Pending focus-clear timer: lets the mouse move from the rail onto a
103
+ // cascade card without dropping the cascade mid-flight.
104
+ const clearFocusTimerRef = useRef(null);
105
+ // Last rendered dot list, for the change-detection bail-out below.
106
+ const lastDotsRef = useRef([]);
107
+ // Whether the dot band overflows its 60% clamp (drives the ▲/▼ paging
108
+ // buttons).
109
+ const [scrollable, setScrollable] = useState(false);
110
+ // Scroll offset of the band + its max offset: drives the ▲/▼ visibility
111
+ // (each direction hides once there is nothing more to reveal).
112
+ const [scrollPos, setScrollPos] = useState({ top: 0, max: 0 });
113
+ // Dots revealed by the latest ▲/▼ click: key → stagger order + travel
114
+ // direction. Drives a short staggered pop-in animation so the click has a
115
+ // visible effect; cleared by a timer once the animation has played out.
116
+ const [revealed, setRevealed] = useState(null);
117
+ const revealTimerRef = useRef(null);
118
+ const syncScroll = () => {
119
+ const list = listRef.current;
120
+ if (list === null)
121
+ return;
122
+ const max = Math.max(0, list.scrollHeight - list.clientHeight);
123
+ setScrollPos({ top: list.scrollTop, max });
124
+ };
125
+ // Page the band by one DOT_PAGE_ROWS click in the given direction, then mark
126
+ // the dots the page just brought into view so they play a staggered pop-in
127
+ // animation in the direction of travel (▼ → rise from below, ▲ → drop from
128
+ // above) — the visual confirmation that the click revealed new dots.
129
+ const pageBy = (dir) => {
130
+ const list = listRef.current;
131
+ if (list === null)
132
+ return;
133
+ const max = Math.max(0, list.scrollHeight - list.clientHeight);
134
+ const target = clampScrollTop(list.scrollTop + dir * pageStep(DOT_SIZE, DOT_GAP, pageSize), max);
135
+ if (target === list.scrollTop)
136
+ return;
137
+ // Snapshot which dots are geometrically inside the band before the jump
138
+ // (the container rect does not move when its content scrolls).
139
+ const band = list.getBoundingClientRect();
140
+ const els = Array.from(list.querySelectorAll('[data-mega-chat-nav-key]'));
141
+ const wasVisible = els.map((el) => {
142
+ const r = el.getBoundingClientRect();
143
+ return r.bottom > band.top && r.top < band.bottom;
144
+ });
145
+ list.scrollTop = target;
146
+ syncScroll();
147
+ const next = new Map();
148
+ const fresh = [];
149
+ els.forEach((el, i) => {
150
+ if (wasVisible[i])
151
+ return;
152
+ const r = el.getBoundingClientRect();
153
+ const key = el.dataset.questionNavKey;
154
+ if (key !== undefined && r.bottom > band.top && r.top < band.bottom) {
155
+ fresh.push(key);
156
+ }
157
+ });
158
+ // Stagger radiates from the clicked triangle: paging down (▼) starts at
159
+ // the bottom of the fresh batch, paging up (▲) at the top.
160
+ if (dir === 1)
161
+ fresh.reverse();
162
+ fresh.forEach((key, order) => next.set(key, { dir, order }));
163
+ if (next.size === 0)
164
+ return;
165
+ setRevealed(next);
166
+ if (revealTimerRef.current !== null)
167
+ window.clearTimeout(revealTimerRef.current);
168
+ // Animation (320ms) + per-dot stagger (35ms each) + slack.
169
+ revealTimerRef.current = window.setTimeout(() => setRevealed(null), 320 + next.size * 35 + 120);
170
+ };
171
+ const canPageUp = scrollable && scrollPos.top > 0;
172
+ const canPageDown = scrollable && scrollPos.top < scrollPos.max;
173
+ // Focus persists briefly after leaving the rail, so the mouse can reach the
174
+ // clickable cascade cards; entering a card cancels the clear, leaving
175
+ // everything re-arms it.
176
+ const cancelClearFocus = () => {
177
+ if (clearFocusTimerRef.current !== null) {
178
+ window.clearTimeout(clearFocusTimerRef.current);
179
+ clearFocusTimerRef.current = null;
180
+ }
181
+ };
182
+ const scheduleClearFocus = () => {
183
+ cancelClearFocus();
184
+ clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240);
185
+ };
186
+ // Follow the plugin settings from the settings scope (an align change
187
+ // re-anchors the rail through the layout effect below; a page-size change
188
+ // steps the ▲/▼ paging).
189
+ useEffect(() => props.subscribeSettings(() => {
190
+ setAlign(props.align());
191
+ setPageSize(props.pageSize());
192
+ }), [props]);
193
+ const showHint = (message) => {
194
+ setHint(message);
195
+ if (hintTimerRef.current !== null)
196
+ window.clearTimeout(hintTimerRef.current);
197
+ hintTimerRef.current = window.setTimeout(() => setHint(null), 1800);
198
+ };
199
+ // Recompute the dot list from the projection + live window. Subscribed to
200
+ // the projection push frames and the session's content only — session
201
+ // switches are covered by `current` below (the effect re-runs on change),
202
+ // so the session-list feed is not subscribed: it would re-run the full
203
+ // recompute for unrelated list churn.
204
+ useEffect(() => {
205
+ if (!visible || current === undefined) {
206
+ setDots([]);
207
+ lastDotsRef.current = [];
208
+ return;
209
+ }
210
+ const sessionId = current;
211
+ const face = props.questionProjection(sessionId);
212
+ const refresh = () => {
213
+ const grouped = groupQuestionsByTurn(projectionEntries(face));
214
+ const next = mergeLiveQuestions(grouped, props.readQuestions(sessionId));
215
+ // Identical content (a streaming update that added no question): re-use
216
+ // the previous array reference so React bails out of re-rendering the
217
+ // strip — the common case during assistant streaming.
218
+ if (sameDots(next, lastDotsRef.current)) {
219
+ setDots(lastDotsRef.current);
220
+ return;
221
+ }
222
+ lastDotsRef.current = next;
223
+ setDots(next);
224
+ };
225
+ refresh();
226
+ const unsubProjection = face?.subscribe(refresh) ?? (() => { });
227
+ const unsubContent = props.subscribeContent(sessionId, refresh);
228
+ return () => {
229
+ unsubProjection();
230
+ unsubContent();
231
+ };
232
+ // eslint-disable-next-line react-hooks/exhaustive-deps
233
+ }, [visible, current, props]);
234
+ // Listen for jump-failure events and surface the hint.
235
+ useEffect(() => {
236
+ const onJumpFailed = (event) => {
237
+ const code = event.detail;
238
+ showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'));
239
+ };
240
+ window.addEventListener('mega-chat-nav:jump-failed', onJumpFailed);
241
+ return () => window.removeEventListener('mega-chat-nav:jump-failed', onJumpFailed);
242
+ }, [props]);
243
+ // Anchor the minimap to the conversation column: position it at the left
244
+ // edge of the conversation root and reserve a thin rail with padding-left.
245
+ //
246
+ // A layout-correction loop keeps the rail pinned to the conversation even
247
+ // when an outside panel (e.g. a browser-extension sidebar like Doubao) moves
248
+ // or resizes the frame without resizing the browser window: `window resize`
249
+ // never fires for in-page panels, and a ResizeObserver on the conversation
250
+ // root alone misses remounts and the tail of the frame's grid-column
251
+ // transition, which used to leave the rail drifting into the session list.
252
+ // The loop runs while the layout is still moving, then parks itself after a
253
+ // few stable frames; observers re-wake it on the next change.
254
+ useLayoutEffect(() => {
255
+ if (!visible)
256
+ return;
257
+ let raf = 0;
258
+ let idle = 0;
259
+ let stopped = false;
260
+ let observer = null;
261
+ const observed = { frame: null, convRoot: null };
262
+ const applyLayout = () => {
263
+ const panel = panelRef.current;
264
+ if (panel === null)
265
+ return false;
266
+ const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null;
267
+ const convRoot = findConvRoot();
268
+ // Keep looping while the anchors are not both present (conversation not
269
+ // mounted yet / mid-reflow), so a late mount still aligns.
270
+ if (frame === null || convRoot === null)
271
+ return true;
272
+ // Keep observing the live nodes: the conversation root may remount
273
+ // (e.g. after a panel-triggered reflow), which silently detaches an
274
+ // earlier ResizeObserver target.
275
+ if (observer !== null) {
276
+ if (observed.frame !== frame) {
277
+ observer.observe(frame, { box: 'border-box' });
278
+ observed.frame = frame;
279
+ }
280
+ if (observed.convRoot !== convRoot) {
281
+ observer.observe(convRoot, { box: 'border-box' });
282
+ observed.convRoot = convRoot;
283
+ }
284
+ }
285
+ const frameRect = frame.getBoundingClientRect();
286
+ const convRect = convRoot.getBoundingClientRect();
287
+ // Never snap onto a transient box: keep correcting until the
288
+ // conversation has a real footprint again.
289
+ if (convRect.height <= 0 || convRect.width <= 0)
290
+ return true;
291
+ const top = `${convRect.top - frameRect.top}px`;
292
+ const height = `${convRect.height}px`;
293
+ // Anchor to the configured edge: keep the other edge's inline style
294
+ // cleared so the CSS class (left: auto on .railRight) owns it.
295
+ if (align === 'right') {
296
+ // Give back the conversation scrollbar's layout width so the 44px
297
+ // rail never covers the bar: the WebKit path consumes 8px at the
298
+ // column's right edge, which the rail would otherwise render on top
299
+ // of, making the bar unclickable. ui-theme mirrors that width as
300
+ // --dsh-scrollbar-width (the overlay composer seat offsets `right`
301
+ // by the same variable); 8px fallback covers engines where the
302
+ // variable is absent.
303
+ const right = `calc(${frameRect.right - convRect.right}px + var(--dsh-scrollbar-width, 8px))`;
304
+ if (panel.style.top === top && panel.style.height === height
305
+ && panel.style.right === right && panel.style.left === '') {
306
+ return false;
307
+ }
308
+ panel.style.top = top;
309
+ panel.style.height = height;
310
+ panel.style.right = right;
311
+ panel.style.left = '';
312
+ return true;
313
+ }
314
+ const left = `${convRect.left - frameRect.left}px`;
315
+ if (panel.style.top === top && panel.style.height === height
316
+ && panel.style.left === left && panel.style.right === '') {
317
+ return false;
318
+ }
319
+ panel.style.top = top;
320
+ panel.style.height = height;
321
+ panel.style.left = left;
322
+ panel.style.right = '';
323
+ return true;
324
+ };
325
+ const loop = () => {
326
+ if (stopped)
327
+ return;
328
+ raf = 0;
329
+ idle = applyLayout() ? 0 : idle + 1;
330
+ if (idle < 3)
331
+ raf = requestAnimationFrame(loop);
332
+ };
333
+ const wake = () => {
334
+ if (stopped)
335
+ return;
336
+ idle = 0;
337
+ if (raf === 0)
338
+ raf = requestAnimationFrame(loop);
339
+ };
340
+ observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake);
341
+ if (observer !== null) {
342
+ const panel = panelRef.current;
343
+ const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null;
344
+ const convRoot = findConvRoot();
345
+ if (frame !== null) {
346
+ observer.observe(frame, { box: 'border-box' });
347
+ observed.frame = frame;
348
+ }
349
+ if (convRoot !== null) {
350
+ observer.observe(convRoot, { box: 'border-box' });
351
+ observed.convRoot = convRoot;
352
+ }
353
+ }
354
+ // Initial alignment; the loop keeps correcting through layout transitions.
355
+ wake();
356
+ window.addEventListener('resize', wake);
357
+ return () => {
358
+ stopped = true;
359
+ if (raf !== 0)
360
+ cancelAnimationFrame(raf);
361
+ observer?.disconnect();
362
+ window.removeEventListener('resize', wake);
363
+ };
364
+ }, [visible, align]);
365
+ // Detect band overflow: drives the ▲/▼ paging buttons. Re-checked when the
366
+ // dots change and whenever the band itself resizes (the layout loop above
367
+ // clamps it to the conversation height).
368
+ useLayoutEffect(() => {
369
+ const list = listRef.current;
370
+ if (list === null)
371
+ return;
372
+ const check = () => {
373
+ setScrollable(list.scrollHeight > list.clientHeight + 1);
374
+ syncScroll();
375
+ };
376
+ check();
377
+ if (typeof ResizeObserver === 'undefined')
378
+ return;
379
+ const observer = new ResizeObserver(check);
380
+ observer.observe(list);
381
+ return () => observer.disconnect();
382
+ }, [dots, align]);
383
+ // Scroll-spy: mark the dot whose turn sits at the reader's position and
384
+ // follow the conversation as it scrolls. The reading line is 35% down the
385
+ // scrollport (see core/active-dot.ts), so the marker reads as "the question
386
+ // I am looking at". Only rendered rows participate: a paged-out window
387
+ // unmounts older questions, and the marker tracks what is on screen.
388
+ useEffect(() => {
389
+ if (!visible || dots.length === 0) {
390
+ setCurrentKey(null);
391
+ return;
392
+ }
393
+ let raf = 0;
394
+ let disposed = false;
395
+ let observedFlow = null;
396
+ const compute = () => {
397
+ raf = 0;
398
+ const scrollport = document.querySelector('[data-conversation-scroll]');
399
+ const flow = document.querySelector('[data-chat-flow]');
400
+ // Trajectory view / chat not mounted: no marker.
401
+ if (scrollport === null || flow === null) {
402
+ setCurrentKey(null);
403
+ return;
404
+ }
405
+ // Content growth (streaming, paging) shifts row tops without a scroll
406
+ // event; re-observe the flow element across remounts.
407
+ if (observer !== null && observedFlow !== flow) {
408
+ observer.disconnect();
409
+ observer.observe(flow);
410
+ observedFlow = flow;
411
+ }
412
+ const portRect = scrollport.getBoundingClientRect();
413
+ if (portRect.height <= 0)
414
+ return;
415
+ // Key lookup without interpolating a selector (chat keys contain ':').
416
+ const rendered = new Map();
417
+ for (const row of flow.querySelectorAll('[data-chat-anchor-key]')) {
418
+ const key = row.dataset.chatAnchorKey;
419
+ if (key !== undefined && !rendered.has(key))
420
+ rendered.set(key, row);
421
+ }
422
+ const rows = [];
423
+ for (const dot of dots) {
424
+ // The turn's first rendered question row anchors the dot (memberKeys
425
+ // are in seq order, so the first hit is the turn's topmost row).
426
+ for (const memberKey of dot.memberKeys) {
427
+ const row = rendered.get(memberKey);
428
+ if (row !== undefined) {
429
+ rows.push({ key: dot.key, top: row.getBoundingClientRect().top - portRect.top });
430
+ break;
431
+ }
432
+ }
433
+ }
434
+ const next = pickActiveDot(rows, readingLineOffset(portRect.height));
435
+ setCurrentKey((prev) => (prev === next ? prev : next));
436
+ };
437
+ const schedule = () => {
438
+ if (!disposed && raf === 0)
439
+ raf = requestAnimationFrame(compute);
440
+ };
441
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
442
+ compute();
443
+ // Scroll events do not bubble; capturing at the document reaches the
444
+ // conversation scrollport today and after any remount without tracking
445
+ // the node. rAF-throttled, so hot scroll streams coalesce into one
446
+ // measurement per frame.
447
+ document.addEventListener('scroll', schedule, true);
448
+ window.addEventListener('resize', schedule);
449
+ return () => {
450
+ disposed = true;
451
+ if (raf !== 0)
452
+ cancelAnimationFrame(raf);
453
+ observer?.disconnect();
454
+ document.removeEventListener('scroll', schedule, true);
455
+ window.removeEventListener('resize', schedule);
456
+ };
457
+ }, [visible, dots]);
458
+ // Clear any pending timers on unmount.
459
+ useEffect(() => () => {
460
+ if (hintTimerRef.current !== null)
461
+ window.clearTimeout(hintTimerRef.current);
462
+ if (clearFocusTimerRef.current !== null)
463
+ window.clearTimeout(clearFocusTimerRef.current);
464
+ if (revealTimerRef.current !== null)
465
+ window.clearTimeout(revealTimerRef.current);
466
+ // eslint-disable-next-line react-hooks/exhaustive-deps
467
+ }, []);
468
+ if (!visible)
469
+ return null;
470
+ const onJump = (dot) => {
471
+ if (current === undefined)
472
+ return;
473
+ setJumpingKey(dot.key);
474
+ props.jump(current, dot.key);
475
+ window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600);
476
+ };
477
+ const openFocus = (dot, target) => {
478
+ const index = dots.findIndex((d) => d.key === dot.key);
479
+ if (index < 0)
480
+ return;
481
+ cancelClearFocus();
482
+ const r = target.getBoundingClientRect();
483
+ // The cascade opens away from the rail: to the right of the dots on a
484
+ // left-anchored rail, to the left on a right-anchored one. The column is
485
+ // vertically centered on the selected dot (top: centerY + translateY(-50%)).
486
+ const horizontal = align === 'right'
487
+ ? { right: window.innerWidth - r.left + 10 }
488
+ : { left: r.right + 10 };
489
+ const lo = Math.max(0, index - FOCUS_RADIUS);
490
+ const hi = Math.min(dots.length - 1, index + FOCUS_RADIUS);
491
+ const items = [];
492
+ for (let i = lo; i <= hi; i++) {
493
+ items.push({ dot: dots[i], distance: Math.abs(i - index) });
494
+ }
495
+ setFocus({ key: dot.key, items, centerY: r.top + r.height / 2, ...horizontal });
496
+ };
497
+ const t = props.t;
498
+ const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key);
499
+ return (_jsxs("div", { ref: panelRef, className: align === 'right' ? `${'mgs-rail'} ${'mgs-railRight'}` : 'mgs-rail', "data-mega-chat-nav": "rail", children: [_jsx("div", { className: 'mgs-queue', children: dots.length === 0 ? (_jsx("div", { className: 'mgs-empty', children: t('strip.empty') })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: 'mgs-count', children: dots.length }), scrollable ? (_jsx("button", { type: "button", className: `${'mgs-navBtn'} ${'mgs-navUp'}`, "aria-label": t('strip.up'), style: { visibility: canPageUp ? 'visible' : 'hidden' }, onMouseEnter: cancelClearFocus, onMouseLeave: scheduleClearFocus, onClick: () => pageBy(-1) })) : null, _jsx("div", { ref: listRef, className: 'mgs-list', onScroll: syncScroll, onMouseLeave: scheduleClearFocus, children: _jsx("div", { className: 'mgs-dots', children: dots.map((dot, index) => {
500
+ const isFocused = focus !== null && focus.key === dot.key;
501
+ // Progressive magnification: selected largest, ±1 smaller, ±2
502
+ // smaller still, the rest base scale.
503
+ const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex);
504
+ const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1;
505
+ // Click-paging feedback: dots just paged into view play a
506
+ // staggered pop-in, traveling in the paging direction.
507
+ const reveal = revealed?.get(dot.key);
508
+ const cls = ['mgs-dot'];
509
+ if (isFocused)
510
+ cls.push('mgs-focused');
511
+ if (jumpingKey === dot.key)
512
+ cls.push('mgs-active');
513
+ if (currentKey === dot.key)
514
+ cls.push('mgs-current');
515
+ if (reveal !== undefined) {
516
+ cls.push(reveal.dir === 1 ? 'mgs-dotEnterFromBottom' : 'mgs-dotEnterFromTop');
517
+ }
518
+ return (_jsx("button", { className: cls.join(' '), style: {
519
+ transform: `scale(${scale})`,
520
+ ...(reveal !== undefined ? { animationDelay: `${reveal.order * 35}ms` } : {}),
521
+ }, "data-mega-chat-nav-focused": isFocused ? 'true' : undefined, "data-mega-chat-nav-index": index, "data-mega-chat-nav-key": dot.key, "aria-label": dot.texts[0] ?? '', onMouseEnter: (e) => openFocus(dot, e.currentTarget), onClick: () => onJump(dot) }, dot.key));
522
+ }) }) }), scrollable ? (_jsx("button", { type: "button", className: `${'mgs-navBtn'} ${'mgs-navDown'}`, "aria-label": t('strip.down'), style: { visibility: canPageDown ? 'visible' : 'hidden' }, onMouseEnter: cancelClearFocus, onMouseLeave: scheduleClearFocus, onClick: () => pageBy(1) })) : null] })) }), hint !== null
523
+ ? createPortal(_jsx("div", { className: 'mgs-hint', style: align === 'right' ? { right: 60, top: 20 } : { left: 60, top: 20 }, children: hint }), document.body)
524
+ : null, focus !== null
525
+ ? createPortal(
526
+ /* The vertical cascade: one crisp card per window dot, stacked
527
+ non-overlapping, vertically centered on the selected dot.
528
+ Hovering the cascade keeps it alive (the rail only re-arms its
529
+ clear once the mouse leaves both). */
530
+ _jsx("div", { className: align === 'right' ? `${'mgs-cascade'} ${'mgs-cascadeRight'}` : 'mgs-cascade', style: {
531
+ ...(focus.left !== undefined ? { left: focus.left } : {}),
532
+ ...(focus.right !== undefined ? { right: focus.right } : {}),
533
+ top: focus.centerY,
534
+ transform: 'translateY(-50%)',
535
+ }, onMouseEnter: cancelClearFocus, onMouseLeave: scheduleClearFocus, children: focus.items.map((item) => {
536
+ const metrics = focusCardMetrics(item.distance);
537
+ if (metrics === null)
538
+ return null;
539
+ const isSelected = item.distance === 0;
540
+ // Cascade cards join their dot's texts into one clamped line;
541
+ // the selected card keeps every line. Every card shows the
542
+ // question's sent time, and clicking any card jumps to its
543
+ // question exactly like clicking the dot.
544
+ const text = item.dot.texts.join(' · ');
545
+ const cls = isSelected
546
+ ? `${'mgs-card'} ${'mgs-cardSelected'}${align === 'right' ? ` ${'mgs-cardSelectedRight'}` : ''}`
547
+ : 'mgs-card';
548
+ return (_jsxs("div", { className: cls, role: "button", tabIndex: -1, style: { width: metrics.widthPx }, onClick: () => onJump(item.dot), children: [isSelected && item.dot.turn !== null ? (_jsxs("div", { className: 'mgs-cardTitle', children: ["Turn ", item.dot.turn] })) : null, _jsx("div", { className: isSelected ? 'mgs-cardBody' : 'mgs-cardClamp', style: isSelected
549
+ ? undefined
550
+ : { WebkitLineClamp: metrics.maxLines, fontSize: metrics.fontSize }, children: isSelected
551
+ ? item.dot.texts.map((line, lineIndex) => (_jsx("div", { className: 'mgs-cardLine', children: line }, lineIndex)))
552
+ : text }), _jsx("div", { className: 'mgs-cardTime', children: formatQuestionTime(item.dot.time, Date.now()) })] }, item.dot.key));
553
+ }) }), document.body)
554
+ : null] }));
555
+ }
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { readConfig, setGeneral } from "../config.js";
3
+ const STYLES = ['minimal', 'deepseek', 'codex'];
4
+ export function ConfigPanel(props) {
5
+ const cfg = readConfig(props.scope);
6
+ if (!cfg || !props.scope)
7
+ return null;
8
+ const g = cfg.general;
9
+ const set = (patch) => void setGeneral(props.scope, patch);
10
+ return (_jsxs("div", { className: "mgs-panel mgs-config", role: "dialog", "aria-label": props.t('settings.entry'), children: [_jsxs("div", { className: "mgs-panel__head", children: [_jsx("span", { className: "mgs-panel__title", children: props.t('settings.entry') }), _jsx("button", { type: "button", className: "mgs-rail__toggle", onClick: props.onClose, "aria-label": "close", children: "\u2715" })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.style') }), _jsx("div", { className: "mgs-seg", children: STYLES.map((s) => (_jsx("button", { type: "button", className: 'mgs-seg__item' + (g.style === s ? ' mgs-seg__item--active' : ''), onClick: () => set({ style: s }), children: props.t('config.style.' + s) }, s))) })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.align') }), _jsx("div", { className: "mgs-seg", children: ['left', 'right'].map((a) => (_jsx("button", { type: "button", className: 'mgs-seg__item' + (g.align === a ? ' mgs-seg__item--active' : ''), onClick: () => set({ align: a }), children: props.t('config.align.' + a) }, a))) })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.panelWidth') }), _jsx("input", { className: "mgs-input mgs-input--num", type: "number", min: 200, max: 400, value: g.panelWidth, onChange: (e) => set({ panelWidth: Math.min(400, Math.max(200, Number(e.target.value) || 280)) }) })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.collapsed') }), _jsx(Switch, { checked: g.collapsed, onChange: (v) => set({ collapsed: v }) })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.showSearch') }), _jsx(Switch, { checked: g.showSearch, onChange: (v) => set({ showSearch: v }) })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.themeMode') }), _jsx("div", { className: "mgs-seg", children: ['follow', 'custom'].map((m) => (_jsx("button", { type: "button", className: 'mgs-seg__item' + (g.themeMode === m ? ' mgs-seg__item--active' : ''), onClick: () => set({ themeMode: m }), children: props.t('config.themeMode.' + m) }, m))) })] })] }));
11
+ }
12
+ export function Switch({ checked, onChange }) {
13
+ return (_jsxs("label", { className: "mgs-switch", children: [_jsx("input", { type: "checkbox", checked: checked, onChange: (e) => onChange(e.target.checked) }), _jsx("span", { className: "mgs-switch__track", children: _jsx("span", { className: "mgs-switch__thumb" }) })] }));
14
+ }
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // 圆点轨(纯渲染):question-nav 形态 —— 8px 圆点 / 波纹放大 / 悬停预览卡
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { VirtualList } from "../virtual/VirtualList.js";
5
+ import { formatTime, truncate } from "../pure.js";
6
+ const DOT_STEP = 14;
7
+ const RIPPLE = [2.0, 1.55, 1.25];
8
+ function rippleScale(dist) {
9
+ if (dist < RIPPLE.length)
10
+ return RIPPLE[dist];
11
+ return 1;
12
+ }
13
+ function userItems(items) {
14
+ return items.filter((it) => it.role === 'user' || it.role === 'steering');
15
+ }
16
+ export function DotRail(props) {
17
+ const { items, align, cfg, t, activeKey, failed, onJump, pos } = props;
18
+ const entries = useMemo(() => userItems(items), [items]);
19
+ const [scrollTop, setScrollTop] = useState(0);
20
+ const [viewportH, setViewportH] = useState(360);
21
+ const [hoverKey, setHoverKey] = useState(null);
22
+ const [hoverIdx, setHoverIdx] = useState(-1);
23
+ const trackRef = useRef(null);
24
+ const keys = entries.map((it) => it.key);
25
+ const hovered = hoverIdx >= 0 ? entries[hoverIdx] : undefined;
26
+ const pinned = useMemo(() => {
27
+ const s = new Set();
28
+ if (activeKey)
29
+ s.add(activeKey);
30
+ if (hoverKey)
31
+ s.add(hoverKey);
32
+ return s.size > 0 ? s : undefined;
33
+ }, [activeKey, hoverKey]);
34
+ // 视口高度跟随轨道容器
35
+ useEffect(() => {
36
+ const el = trackRef.current;
37
+ if (!el)
38
+ return;
39
+ const ro = new ResizeObserver(() => {
40
+ const h = el.clientHeight;
41
+ setViewportH((prev) => (Math.abs(prev - h) > 1 ? h : prev));
42
+ });
43
+ ro.observe(el);
44
+ setViewportH(el.clientHeight || 360);
45
+ return () => ro.disconnect();
46
+ }, []);
47
+ const style = {
48
+ position: 'fixed',
49
+ top: pos?.top ?? '50%',
50
+ height: pos?.height ?? undefined,
51
+ left: pos?.left,
52
+ right: pos?.right,
53
+ };
54
+ if (!pos)
55
+ style.transform = 'translateY(-50%)';
56
+ return (_jsxs("div", { className: 'mgs-dots mgs-dots--' + align, style: style, role: "navigation", "aria-label": t('a11y.list'), children: [_jsx("div", { className: "mgs-dots__track", ref: trackRef, children: entries.length === 0 ? (_jsx("div", { className: "mgs-dots__empty", children: t('empty') })) : (_jsx(VirtualList, { items: entries, itemHeight: DOT_STEP, viewportHeight: viewportH, scrollTop: scrollTop, onScrollTopChange: setScrollTop, keyOf: (it) => it.key, pinned: pinned, renderItem: (it, i) => {
57
+ const dist = hoverIdx >= 0 ? Math.abs(i - hoverIdx) : 99;
58
+ return (_jsx("button", { type: "button", className: 'mgs-dot' +
59
+ (it.key === activeKey ? ' mgs-dot--active' : '') +
60
+ (it.key === hoverKey ? ' mgs-dot--hover' : ''), style: { transform: 'scale(' + rippleScale(dist) + ')' }, "aria-label": truncate(it.text, 40), "aria-current": it.key === activeKey ? 'step' : undefined, onClick: () => onJump(it.key), onMouseEnter: () => { setHoverKey(it.key); setHoverIdx(i); }, onMouseLeave: () => { setHoverKey((k) => (k === it.key ? null : k)); setHoverIdx(-1); } }));
61
+ } })) }), hovered && (_jsxs("div", { className: 'mgs-dots__tip mgs-dots__tip--' + align, children: [_jsx("div", { className: "mgs-dots__tip-text", children: truncate(hovered.text, 80) || t('empty') }), _jsx("div", { className: "mgs-dots__tip-time", children: formatTime(hovered.time) })] })), failed && _jsx("div", { className: "mgs-dots__hint", children: t('jump.failed') })] }));
62
+ }