openclacky 1.5.13 → 1.5.14

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +38 -97
  6. data/lib/clacky/agent.rb +281 -276
  7. data/lib/clacky/agent_config.rb +1 -1
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +6 -0
  10. data/lib/clacky/brand_config.rb +13 -2
  11. data/lib/clacky/cli.rb +27 -0
  12. data/lib/clacky/client.rb +2 -2
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  14. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  16. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  17. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  18. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  19. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  20. data/lib/clacky/extension/api_extension.rb +1 -1
  21. data/lib/clacky/extension/verifier.rb +1 -1
  22. data/lib/clacky/media/openai_compat.rb +70 -30
  23. data/lib/clacky/message_format/bedrock.rb +6 -1
  24. data/lib/clacky/prompts/base.md +1 -1
  25. data/lib/clacky/providers.rb +38 -3
  26. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  27. data/lib/clacky/search_config.rb +3 -3
  28. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  29. data/lib/clacky/server/git_panel.rb +10 -2
  30. data/lib/clacky/server/http_server.rb +161 -14
  31. data/lib/clacky/server/preview.rb +351 -0
  32. data/lib/clacky/server/web_ui_controller.rb +1 -1
  33. data/lib/clacky/skill.rb +48 -0
  34. data/lib/clacky/tools/browser.rb +176 -19
  35. data/lib/clacky/tools/web_search.rb +79 -8
  36. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  37. data/lib/clacky/ui2/components/input_area.rb +22 -2
  38. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  39. data/lib/clacky/ui2/ui_controller.rb +44 -7
  40. data/lib/clacky/utils/file_processor.rb +19 -7
  41. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  42. data/lib/clacky/utils/model_pricing.rb +114 -66
  43. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  44. data/lib/clacky/version.rb +1 -1
  45. data/lib/clacky/web/app.css +596 -130
  46. data/lib/clacky/web/components/chat-navigator.js +489 -202
  47. data/lib/clacky/web/components/code-editor.js +191 -5
  48. data/lib/clacky/web/components/model-picker.js +2 -1
  49. data/lib/clacky/web/components/quote-select.js +235 -0
  50. data/lib/clacky/web/core/aside.js +157 -8
  51. data/lib/clacky/web/core/ext.js +16 -0
  52. data/lib/clacky/web/features/billing/view.js +75 -21
  53. data/lib/clacky/web/features/skills/store.js +18 -1
  54. data/lib/clacky/web/features/skills/view.js +128 -7
  55. data/lib/clacky/web/features/workspace/store.js +80 -7
  56. data/lib/clacky/web/features/workspace/view.js +392 -40
  57. data/lib/clacky/web/i18n.js +90 -5
  58. data/lib/clacky/web/index.html +15 -6
  59. data/lib/clacky/web/sessions.js +193 -38
  60. data/lib/clacky/web/utils.js +34 -0
  61. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  62. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  63. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  64. data/lib/clacky/web/ws-dispatcher.js +20 -0
  65. data/lib/clacky.rb +2 -0
  66. metadata +11 -1
@@ -1,260 +1,547 @@
1
- // ── Chat Navigator - vertical minimap on the right edge of the chat ────────
2
- //
3
- // Shows one bar per user message. Hovering a bar opens a scrollable popup
4
- // listing every user message preview; clicking a bar or popup item scrolls
5
- // the chat to that message.
6
- //
7
- // The navigator element is absolutely positioned inside #chat-main and its
8
- // vertical bounds are synced to #messages via ResizeObserver so it never
9
- // overlaps the composer / info bar below.
10
- //
11
- // Depends on: global $ helper (app.js), I18n.
12
- // ───────────────────────────────────────────────────────────────────────────
1
+ // ── Chat Navigator — lightweight round index, independent of loaded DOM ──
13
2
  "use strict";
14
3
 
15
4
  (() => {
16
- const $ = (id) => document.getElementById(id);
17
-
18
- const NAV_SELECTOR = ".msg-user-wrap";
19
-
20
- let nav, track, popup, popupList;
21
- let messages, chatMain;
22
- let items = []; // [{ el, bar, popupItem, type, preview }]
5
+ const STEP = 32;
6
+ const PADDING = 24;
7
+ const MAX_VISIBLE_ROWS = 13;
8
+ const MOBILE_MEDIA = "(max-width: 768px)";
9
+ const PREVIEW_DELAY = 120;
10
+ // Navigation IDs include source versions and are URL encoded in a GET query.
11
+ // Keep each batch below WEBrick's request-target limit, then drain visible
12
+ // rows serially through _renderTicks() after every response.
13
+ const PREVIEW_BATCH_SIZE = 8;
14
+ const PREVIEW_CACHE_SIZE = 80;
15
+ const BOTTOM_TOLERANCE = 1;
16
+ let nav, track, canvas, popup, answerPreview, answerLabel, messages, chatMain;
17
+ let viewportQuery;
18
+ let sessionId = null;
19
+ let items = [];
20
+ let loaded = [];
23
21
  let activeIdx = -1;
24
- let mo, ro;
25
- let rebuildTimer = null;
26
- let scrollTimer = null;
27
- let hideTimer = null;
28
22
  let hoveredIdx = -1;
23
+ let expanded = false;
24
+ let hoveringMessage = false;
25
+ let loading = false;
26
+ let failed = false;
27
+ let jumping = false;
28
+ let controller;
29
+ let generation = 0;
30
+ let refreshTimer, syncTimer, hideTimer, scrollFrame;
31
+ let pointerY = null;
32
+ let keepOpenUntilPointerMove = false;
33
+ let previewTimer, previewRequest, previewQueueKey;
34
+ const previewFailures = new Set();
35
+ const previews = new Map();
36
+ const renderedTicks = new Map();
37
+ const previewText = new WeakMap();
38
+
39
+ function _contentSpan() {
40
+ return Math.max(0, (items.length - 1) * STEP);
41
+ }
29
42
 
30
- // ── Preview text extraction ────────────────────────────────────────────
31
-
32
- function _previewFor(el) {
33
- const bubble = el.querySelector(".msg-user");
34
- if (!bubble) return "";
35
- const clone = bubble.cloneNode(true);
36
- clone
37
- .querySelectorAll("img, .msg-pdf-badge, .msg-user-action-bar, .msg-time")
38
- .forEach((e) => e.remove());
39
- const text = clone.textContent.trim();
40
- return text || (I18n.t("chat.nav.empty") || "(empty)");
43
+ function _baseOffset() {
44
+ return Math.max(PADDING, (track.clientHeight - _contentSpan()) / 2);
41
45
  }
42
46
 
43
- // ── Position sync ───────────────────────────────────────────────────────
47
+ function _tickY(index) {
48
+ return _baseOffset() + index * STEP;
49
+ }
44
50
 
45
- function _syncBounds() {
46
- if (!nav || !messages || !chatMain) return;
47
- const msgRect = messages.getBoundingClientRect();
48
- const mainRect = chatMain.getBoundingClientRect();
49
- nav.style.top = msgRect.top - mainRect.top + "px";
50
- nav.style.height = msgRect.height + "px";
51
+ function _nearest(y) {
52
+ if (!items.length) return -1;
53
+ const index = Math.round((y + track.scrollTop - _baseOffset()) / STEP);
54
+ return Math.max(0, Math.min(items.length - 1, index));
51
55
  }
52
56
 
53
- // ── Rebuild bars + popup list ───────────────────────────────────────────
57
+ function _visibleBounds() {
58
+ const first = Math.max(0, Math.floor((track.scrollTop - _baseOffset()) / STEP) - 2);
59
+ const last = Math.min(items.length, first + Math.ceil(track.clientHeight / STEP) + 5);
60
+ return { first, last };
61
+ }
54
62
 
55
- function _scheduleRebuild() {
56
- clearTimeout(rebuildTimer);
57
- rebuildTimer = setTimeout(_rebuild, 120);
63
+ function _intersectsViewport(index) {
64
+ const y = _tickY(index) - track.scrollTop;
65
+ return y + STEP / 2 > 0 && y - STEP / 2 < track.clientHeight;
58
66
  }
59
67
 
60
- function _rebuild() {
61
- if (!messages || !track) return;
62
- const els = Array.from(messages.querySelectorAll(NAV_SELECTOR));
63
- items = els.map((el) => ({
64
- el,
65
- preview: _previewFor(el),
66
- }));
67
-
68
- // Hide navigator when content fits without scrolling
69
- const scrollable = messages.scrollHeight > messages.clientHeight + 40;
70
- nav.style.display = scrollable && items.length > 1 ? "" : "none";
71
- if (!scrollable || items.length <= 1) return;
72
-
73
- // Build bars
74
- track.innerHTML = "";
75
- items.forEach((item, i) => {
76
- const bar = document.createElement("div");
77
- bar.className = "chat-nav-bar";
78
- bar.dataset.idx = String(i);
79
- bar.title = item.preview.slice(0, 120);
80
- bar.addEventListener("mouseenter", () => _onBarHover(i));
81
- bar.addEventListener("click", () => _scrollToIdx(i));
82
- track.appendChild(bar);
83
- item.bar = bar;
84
- });
68
+ function _visiblePreviewIds(first, last) {
69
+ return items.slice(first, last)
70
+ .map((item, offset) => ({ id: item.id, index: first + offset }))
71
+ .filter(item => _intersectsViewport(item.index))
72
+ .map(item => item.id);
73
+ }
85
74
 
86
- // Build popup list (lazy: build once per rebuild)
87
- popupList.innerHTML = "";
88
- items.forEach((item, i) => {
89
- const li = document.createElement("div");
90
- li.className = "chat-nav-popup-item";
91
- li.dataset.idx = String(i);
92
- li.innerHTML =
93
- `<span class="chat-nav-popup-item-text">${escapeHtml(item.preview)}</span>`;
94
- li.addEventListener("mouseenter", () => _onItemHover(i));
95
- li.addEventListener("click", () => _scrollToIdx(i));
96
- popupList.appendChild(li);
97
- item.popupItem = li;
98
- });
75
+ function _syncBounds() {
76
+ if (viewportQuery?.matches) {
77
+ _renderTicks();
78
+ return;
79
+ }
80
+ const rect = messages.getBoundingClientRect();
81
+ const main = chatMain.getBoundingClientRect();
82
+ const opener = document.getElementById("btn-aside-open");
83
+ const openerStyle = opener && getComputedStyle(opener);
84
+ // Reserve the opener's CSS geometry even when the expanded aside hides it.
85
+ const openerBottom = opener
86
+ ? opener.parentElement.getBoundingClientRect().top + parseFloat(openerStyle.top) + parseFloat(openerStyle.height)
87
+ : rect.top;
88
+ const top = Math.max(rect.top, openerBottom) + 12;
89
+ const scrollbarWidth = messages.offsetWidth - messages.clientWidth;
90
+ const scrollbarSpace = Math.max(scrollbarWidth,
91
+ parseFloat(getComputedStyle(nav).getPropertyValue("--chat-nav-scrollbar-space")) || 12);
92
+ // Classic scrollbars already consume layout width; reserve only the overlay remainder.
93
+ chatMain.style.setProperty("--chat-nav-overlay-space", `${scrollbarSpace - scrollbarWidth}px`);
94
+ const availableHeight = Math.max(0, rect.bottom - top - 16);
95
+ // Thirteen row centers need twelve gaps plus independent top/bottom
96
+ // breathing room, otherwise the edge labels enter the rounded clip area.
97
+ const preferredHeight = STEP * (MAX_VISIBLE_ROWS - 1) + PADDING * 2;
98
+ const height = Math.min(availableHeight, preferredHeight);
99
+ const centeredTop = top + (availableHeight - height) / 2;
100
+ nav.style.right = `${main.right - rect.right + scrollbarSpace}px`;
101
+ nav.style.top = `${centeredTop - main.top}px`;
102
+ nav.style.height = `${height}px`;
103
+ const expandedWidth = parseFloat(getComputedStyle(chatMain).getPropertyValue("--chat-nav-expanded-width")) || 368;
104
+ popup.style.maxWidth = `${Math.max(0, rect.width - scrollbarSpace - expandedWidth - 12)}px`;
105
+ _renderTicks();
106
+ }
99
107
 
100
- activeIdx = -1;
101
- _updateActive();
108
+ function _renderTicks() {
109
+ if (!track) return;
110
+ const visible = !viewportQuery?.matches && (items.length > 1 || loading || failed);
111
+ nav.style.display = visible ? "" : "none";
112
+ chatMain.classList.toggle("has-chat-navigator", visible);
113
+ if (!visible && viewportQuery?.matches) {
114
+ for (const tick of renderedTicks.values()) tick.remove();
115
+ renderedTicks.clear();
116
+ canvas.style.height = "";
117
+ track.removeAttribute("aria-activedescendant");
118
+ return;
119
+ }
120
+ const contentHeight = _contentSpan() + PADDING * 2;
121
+ canvas.style.height = `${Math.max(track.clientHeight, contentHeight)}px`;
122
+ const compact = items.length > 0 && contentHeight <= track.clientHeight;
123
+ const frameTop = compact ? Math.max(0, _baseOffset() - STEP / 2 - 8) : 0;
124
+ const frameHeight = compact ? Math.min(track.clientHeight - frameTop, items.length * STEP + 16) : track.clientHeight;
125
+ nav.style.setProperty("--chat-nav-frame-top", `${frameTop}px`);
126
+ nav.style.setProperty("--chat-nav-frame-height", `${frameHeight}px`);
127
+ // Only visible rows and a small buffer get DOM nodes.
128
+ const { first, last } = _visibleBounds();
129
+ for (const [index, tick] of renderedTicks) {
130
+ if (index < first || index >= last) { tick.remove(); renderedTicks.delete(index); }
131
+ }
132
+ for (let i = first; i < last; i++) {
133
+ let tick = renderedTicks.get(i);
134
+ if (!tick) {
135
+ tick = document.createElement("div");
136
+ tick.className = "chat-nav-row";
137
+ const user = document.createElement("span");
138
+ user.className = "chat-nav-user";
139
+ const bar = document.createElement("span");
140
+ bar.className = "chat-nav-bar";
141
+ tick.appendChild(user);
142
+ tick.appendChild(bar);
143
+ tick.id = `chat-nav-tick-${i}`;
144
+ tick.setAttribute("role", "option");
145
+ renderedTicks.set(i, tick);
146
+ canvas.appendChild(tick);
147
+ }
148
+ const preview = previews.get(items[i].id);
149
+ const previewFailed = previewFailures.has(items[i].id);
150
+ tick.classList.toggle("active", i === activeIdx);
151
+ tick.classList.toggle("hovered", i === hoveredIdx);
152
+ tick.classList.toggle("loading", expanded && !preview && !previewFailed);
153
+ tick.classList.toggle("failed", previewFailed);
154
+ tick.style.top = `${_tickY(i)}px`;
155
+ tick.setAttribute("aria-selected", String(i === hoveredIdx));
156
+ tick.setAttribute("aria-label", preview?.user || I18n.t("chat.nav.round", { number: i + 1 }));
157
+ if (expanded) {
158
+ const user = preview
159
+ ? preview.user || I18n.t("chat.nav.empty")
160
+ : previewFailed ? I18n.t("chat.nav.retry") : "";
161
+ _setPreview(tick.querySelector(".chat-nav-user"), user);
162
+ }
163
+ }
164
+ if (hoveredIdx >= first && hoveredIdx < last) {
165
+ track.setAttribute("aria-activedescendant", `chat-nav-tick-${hoveredIdx}`);
166
+ }
167
+ else track.removeAttribute("aria-activedescendant");
168
+ _renderPreview();
169
+ if (expanded && !loading && !failed) _queueVisiblePreviews(first, last);
102
170
  }
103
171
 
104
- // ── Active bar tracking ─────────────────────────────────────────────────
172
+ function _setPreview(element, text) {
173
+ if (previewText.get(element) === text) return;
174
+ element.innerHTML = MarkdownPreview.render(text);
175
+ previewText.set(element, text);
176
+ }
105
177
 
106
- function _updateActive() {
107
- if (!items.length) return;
108
- const threshold = messages.getBoundingClientRect().top + 30;
109
- let idx = 0;
110
- for (let i = 0; i < items.length; i++) {
111
- if (!items[i].el || !items[i].el.isConnected) {
112
- _scheduleRebuild();
113
- return;
114
- }
115
- const top = items[i].el.getBoundingClientRect().top;
116
- if (top <= threshold) idx = i;
117
- else break;
178
+ function _renderPreview() {
179
+ if (!popup) return;
180
+ if (!expanded || !hoveringMessage || hoveredIdx < 0) {
181
+ popup.hidden = true;
182
+ return;
118
183
  }
119
- if (idx !== activeIdx) {
120
- activeIdx = idx;
121
- items.forEach((item, i) => {
122
- item.bar.classList.toggle("active", i === idx);
123
- });
184
+ popup.hidden = false;
185
+ const item = items[hoveredIdx];
186
+ const preview = item && previews.get(item.id);
187
+ const previewFailed = item && previewFailures.has(item.id);
188
+ const skeleton = !failed && !previewFailed && (jumping || (item ? !preview : loading));
189
+ popup.classList.toggle("loading", skeleton);
190
+ popup.setAttribute("aria-busy", String(skeleton));
191
+ _setPreview(answerPreview, skeleton ? "" : failed || previewFailed ? I18n.t("chat.nav.retry") : preview?.assistant || I18n.t("chat.nav.noReply"));
192
+ const center = item ? _tickY(hoveredIdx) - track.scrollTop : pointerY || 0;
193
+ popup.style.top = `${Math.max(0, Math.min(center - popup.offsetHeight / 2, nav.clientHeight - popup.offsetHeight))}px`;
194
+ }
195
+
196
+ function _queueVisiblePreviews(first, last) {
197
+ if (!expanded || previewRequest) return;
198
+ const ids = _visiblePreviewIds(first, last)
199
+ .filter(id => !previews.has(id) && !previewFailures.has(id))
200
+ .slice(0, PREVIEW_BATCH_SIZE);
201
+ const key = ids.join("\n");
202
+ if (!ids.length) {
203
+ clearTimeout(previewTimer);
204
+ previewQueueKey = null;
205
+ return;
124
206
  }
207
+ if (previewQueueKey === key) return;
208
+
209
+ clearTimeout(previewTimer);
210
+ previewQueueKey = key;
211
+ previewTimer = setTimeout(() => {
212
+ previewQueueKey = null;
213
+ _loadVisiblePreviews(ids);
214
+ }, PREVIEW_DELAY);
125
215
  }
126
216
 
127
- function _onScroll() {
128
- clearTimeout(scrollTimer);
129
- scrollTimer = setTimeout(_updateActive, 80);
217
+ async function _loadVisiblePreviews(ids) {
218
+ ids = ids.filter(id => !previews.has(id));
219
+ if (previewRequest || !ids.length) return;
220
+ const request = { ids, generation, controller: new AbortController() };
221
+ previewRequest = request;
222
+ try {
223
+ const params = new URLSearchParams({ previews: JSON.stringify(request.ids) });
224
+ const res = await fetch(`/api/sessions/${sessionId}/messages?${params}`, { signal: request.controller.signal });
225
+ if (res.status === 409 && request.generation === generation) refresh();
226
+ if (!res.ok) throw new Error(`Previews: ${res.status}`);
227
+ const data = await res.json();
228
+ if (request.generation !== generation) return;
229
+ for (const preview of data.previews || []) {
230
+ previews.delete(preview.id);
231
+ previews.set(preview.id, preview);
232
+ previewFailures.delete(preview.id);
233
+ while (previews.size > PREVIEW_CACHE_SIZE) previews.delete(previews.keys().next().value);
234
+ }
235
+ } catch (error) {
236
+ if (error.name !== "AbortError" && request.generation === generation) {
237
+ request.ids.forEach(id => previewFailures.add(id));
238
+ }
239
+ } finally {
240
+ if (previewRequest === request) {
241
+ previewRequest = null;
242
+ _renderTicks();
243
+ }
244
+ }
130
245
  }
131
246
 
132
- // ── Popup interaction ───────────────────────────────────────────────────
247
+ function _cancelPreview() {
248
+ clearTimeout(previewTimer);
249
+ previewRequest?.controller.abort();
250
+ previewRequest = null;
251
+ previewQueueKey = null;
252
+ previewFailures.clear();
253
+ }
133
254
 
134
- function _onBarHover(idx) {
255
+ function _expand() {
135
256
  clearTimeout(hideTimer);
136
- _showPopup(idx);
257
+ if (expanded) return;
258
+ expanded = true;
259
+ nav.classList.add("expanded");
260
+ _renderTicks();
137
261
  }
138
262
 
139
- function _onItemHover(idx) {
263
+ function _canExpandFrom(target) {
264
+ return !!target.closest?.(".chat-nav-bar");
265
+ }
266
+
267
+ function _hover(y, showAnswer = false) {
268
+ pointerY = y;
140
269
  clearTimeout(hideTimer);
141
- hoveredIdx = idx;
142
- items.forEach((item, i) => {
143
- item.bar.classList.toggle("hovered", i === idx);
144
- });
270
+ hoveredIdx = _nearest(y);
271
+ hoveringMessage = showAnswer;
272
+ _renderTicks();
145
273
  }
146
274
 
147
- function _showPopup(idx) {
148
- if (!popup || !items.length) return;
149
- hoveredIdx = idx;
150
- popup.style.display = "flex";
275
+ function _hide() {
276
+ clearTimeout(hideTimer);
277
+ hideTimer = setTimeout(() => {
278
+ keepOpenUntilPointerMove = false;
279
+ hoveredIdx = -1;
280
+ pointerY = null;
281
+ hoveringMessage = false;
282
+ expanded = false;
283
+ nav.classList.remove("expanded");
284
+ popup.hidden = true;
285
+ _cancelPreview();
286
+ _renderTicks();
287
+ }, 160);
288
+ }
289
+
290
+ function _click() {
291
+ // Jumping can replace or reposition message DOM under a stationary cursor,
292
+ // which produces a synthetic pointerleave. Keep the panel open until the
293
+ // user's next real pointer movement decides whether it should close.
294
+ keepOpenUntilPointerMove = true;
295
+ _jump();
296
+ }
151
297
 
152
- items.forEach((item, i) => {
153
- item.popupItem.classList.toggle("highlighted", i === idx);
154
- item.bar.classList.toggle("hovered", i === idx);
298
+ function _leave() {
299
+ if (!keepOpenUntilPointerMove) _hide();
300
+ }
301
+
302
+ function _handleDocumentPointerMove(event) {
303
+ if (!keepOpenUntilPointerMove) return;
304
+ keepOpenUntilPointerMove = false;
305
+ if (!nav.contains(event.target)) _hide();
306
+ }
307
+
308
+ function _reveal(index) {
309
+ if (index < 0) return;
310
+ const y = _tickY(index);
311
+ if (y < track.scrollTop + PADDING || y > track.scrollTop + track.clientHeight - PADDING) {
312
+ track.scrollTop = Math.max(0, y - track.clientHeight / 2);
313
+ }
314
+ }
315
+
316
+ function _updateActive() {
317
+ if (!loaded.length) return;
318
+ const threshold = messages.getBoundingClientRect().top + 30;
319
+ let found = loaded[0].index;
320
+ for (const entry of loaded) {
321
+ if (entry.el.getBoundingClientRect().top > threshold) break;
322
+ found = entry.index;
323
+ }
324
+ const atBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight <= BOTTOM_TOLERANCE;
325
+ if (atBottom && !Sessions.isHistoricalWindow()) found = loaded[loaded.length - 1].index;
326
+ activeIdx = found;
327
+ if (pointerY === null) _reveal(activeIdx);
328
+ _renderTicks();
329
+ }
330
+
331
+ function syncMessages() {
332
+ if (!messages || sessionId !== Sessions.activeId) return;
333
+ const byId = new Map(items.map((item, index) => [item.id, index]));
334
+ const liveByTime = new Map();
335
+ items.forEach((item, index) => {
336
+ if (!item.archived && item.created_at) liveByTime.set(String(item.created_at), index);
337
+ });
338
+ loaded = [];
339
+ messages.querySelectorAll(".msg-user-wrap").forEach(el => {
340
+ const bubble = el.querySelector(".msg-user");
341
+ let index = byId.get(bubble?.dataset.roundId);
342
+ // Only optimistic live bubbles need a timestamp fallback. Archived
343
+ // targets always use source locators, never synthetic timestamps.
344
+ if (index === undefined && bubble?.dataset.editable !== "false") index = liveByTime.get(bubble?.dataset.createdAt);
345
+ if (index !== undefined) loaded.push({ index, el });
155
346
  });
347
+ _updateActive();
348
+ }
156
349
 
157
- // Position popup vertically centered on the hovered bar
158
- const bar = items[idx].bar;
159
- const navRect = nav.getBoundingClientRect();
160
- const barRect = bar.getBoundingClientRect();
161
- const barCenter = barRect.top + barRect.height / 2 - navRect.top;
162
- popup.style.top = "";
163
- popup.style.bottom = "";
164
- const popupHeight = popup.offsetHeight;
165
- let top = barCenter - popupHeight / 2;
166
- top = Math.max(48, Math.min(top, navRect.height - popupHeight));
167
- popup.style.top = top + "px";
168
-
169
- const target = items[idx].popupItem;
170
- if (target) {
171
- const listRect = popupList.getBoundingClientRect();
172
- const itemRect = target.getBoundingClientRect();
173
- const offset =
174
- itemRect.top - listRect.top - (listRect.height - itemRect.height) / 2;
175
- popupList.scrollTop += offset;
350
+ async function _jump() {
351
+ if (failed) { refresh(); return; }
352
+ const item = items[hoveredIdx];
353
+ if (!item || jumping) return;
354
+ const id = sessionId;
355
+ const element = loaded.find(entry => entry.index === hoveredIdx)?.el;
356
+ if (element?.isConnected) {
357
+ messages.scrollTop += element.getBoundingClientRect().top - messages.getBoundingClientRect().top - 12;
358
+ return;
359
+ }
360
+ jumping = true;
361
+ _renderPreview();
362
+ const success = await Sessions.jumpToHistory(item.id);
363
+ if (id !== sessionId) return;
364
+ jumping = false;
365
+ if (!success) {
366
+ failed = true;
367
+ _renderPreview();
368
+ _setPreview(answerPreview, I18n.t("chat.history_load_failed"));
176
369
  }
177
370
  }
178
371
 
179
- function _hidePopup() {
180
- clearTimeout(hideTimer);
181
- hideTimer = setTimeout(() => {
182
- if (!popup) return;
183
- popup.style.display = "none";
184
- hoveredIdx = -1;
185
- items.forEach((item) => {
186
- item.bar.classList.remove("hovered");
187
- item.popupItem.classList.remove("highlighted");
188
- });
189
- }, 200);
372
+ async function _loadIndex() {
373
+ if (!sessionId || !nav || viewportQuery?.matches) return;
374
+ controller?.abort();
375
+ controller = new AbortController();
376
+ const signal = controller.signal;
377
+ const requestGeneration = ++generation;
378
+ _cancelPreview();
379
+ const id = sessionId;
380
+ loading = true;
381
+ failed = false;
382
+ _renderTicks();
383
+ try {
384
+ const res = await fetch(`/api/sessions/${id}/messages?navigation=1`, { signal });
385
+ if (!res.ok) throw new Error(`Navigation: ${res.status}`);
386
+ const data = await res.json();
387
+ if (requestGeneration !== generation || sessionId !== Sessions.activeId) return;
388
+ const anchorId = items[hoveredIdx]?.id;
389
+ const firstVisibleId = items[Math.max(0, Math.floor((track.scrollTop - _baseOffset()) / STEP))]?.id;
390
+ const sources = new Map(data.sources.map(source => [source.key, source]));
391
+ for (const key of previews.keys()) {
392
+ const [source, , version] = JSON.parse(key);
393
+ if (!sources.has(source) || sources.get(source).version !== version || sources.get(source).volatile) previews.delete(key);
394
+ }
395
+ items = data.sources.flatMap(source => Array.from({ length: source.count }, (_, offset) => {
396
+ const identity = source.identities?.[offset] || null;
397
+ return { id: JSON.stringify([source.key, offset, source.version, identity]),
398
+ archived: !source.identities, created_at: identity?.[0] };
399
+ }));
400
+ hoveredIdx = anchorId ? items.findIndex(item => item.id === anchorId) : -1;
401
+ _renderTicks();
402
+ if (pointerY !== null && firstVisibleId) {
403
+ const first = items.findIndex(item => item.id === firstVisibleId);
404
+ if (first >= 0) track.scrollTop = Math.max(0, _baseOffset() + first * STEP);
405
+ }
406
+ syncMessages();
407
+ } catch (error) {
408
+ if (error.name !== "AbortError" && requestGeneration === generation) failed = true;
409
+ } finally {
410
+ if (requestGeneration === generation) {
411
+ loading = false;
412
+ if (pointerY !== null && hoveredIdx < 0) hoveredIdx = _nearest(pointerY);
413
+ _renderTicks();
414
+ }
415
+ }
190
416
  }
191
417
 
192
- // ── Scroll to message ───────────────────────────────────────────────────
418
+ function refresh() {
419
+ if (!sessionId) return;
420
+ clearTimeout(refreshTimer);
421
+ refreshTimer = setTimeout(_loadIndex, 350);
422
+ }
193
423
 
194
- function _scrollToIdx(idx) {
195
- if (!items[idx] || !items[idx].el) return;
196
- const el = items[idx].el;
197
- const msgRect = messages.getBoundingClientRect();
198
- const elRect = el.getBoundingClientRect();
199
- const offset = elRect.top - msgRect.top;
200
- messages.scrollTop += offset - 12;
201
- _hidePopup();
424
+ function setSession(id) {
425
+ controller?.abort();
426
+ clearTimeout(refreshTimer);
427
+ clearTimeout(hideTimer);
428
+ generation++;
429
+ sessionId = id;
430
+ items = [];
431
+ loaded = [];
432
+ previews.clear();
433
+ _cancelPreview();
434
+ activeIdx = hoveredIdx = -1;
435
+ pointerY = null;
436
+ hoveringMessage = expanded = jumping = failed = keepOpenUntilPointerMove = false;
437
+ nav?.classList.remove("expanded");
438
+ if (popup) popup.hidden = true;
439
+ if (viewportQuery?.matches) _renderTicks();
440
+ else _loadIndex();
202
441
  }
203
442
 
204
- // ── Init ────────────────────────────────────────────────────────────────
443
+ function _handleViewportChange() {
444
+ if (viewportQuery.matches) {
445
+ controller?.abort();
446
+ controller = null;
447
+ clearTimeout(refreshTimer);
448
+ generation++;
449
+ items = [];
450
+ loaded = [];
451
+ previews.clear();
452
+ _cancelPreview();
453
+ activeIdx = hoveredIdx = -1;
454
+ pointerY = null;
455
+ hoveringMessage = expanded = jumping = loading = failed = keepOpenUntilPointerMove = false;
456
+ nav.classList.remove("expanded");
457
+ popup.hidden = true;
458
+ _renderTicks();
459
+ return;
460
+ }
461
+ _syncBounds();
462
+ if (sessionId) _loadIndex();
463
+ }
205
464
 
206
465
  function init() {
207
- messages = $("messages");
208
- chatMain = $("chat-main");
466
+ messages = document.getElementById("messages");
467
+ chatMain = document.getElementById("chat-main");
209
468
  if (!messages || !chatMain) return;
210
-
211
469
  nav = document.createElement("div");
212
470
  nav.className = "chat-navigator";
213
- nav.style.display = "none";
214
- nav.innerHTML =
215
- '<div class="chat-nav-track"></div>' +
216
- '<div class="chat-nav-popup" style="display:none">' +
217
- '<div class="chat-nav-popup-header">' +
218
- '<span class="chat-nav-popup-title"></span>' +
219
- "</div>" +
220
- '<div class="chat-nav-popup-list"></div>' +
221
- "</div>";
471
+ nav.innerHTML = '<div class="chat-nav-frame" aria-hidden="true"></div>' +
472
+ '<div class="chat-nav-viewport"><div class="chat-nav-track" tabindex="0" role="listbox"><div class="chat-nav-canvas"></div></div></div>' +
473
+ '<div class="chat-nav-popup" hidden><div class="chat-nav-popup-label"><span class="chat-nav-popup-dot" aria-hidden="true"></span>' +
474
+ '<span class="chat-nav-popup-label-text"></span></div><span class="chat-nav-answer"></span></div>';
222
475
  chatMain.appendChild(nav);
223
476
  track = nav.querySelector(".chat-nav-track");
477
+ canvas = nav.querySelector(".chat-nav-canvas");
224
478
  popup = nav.querySelector(".chat-nav-popup");
225
- popupList = nav.querySelector(".chat-nav-popup-list");
226
-
227
- const titleEl = nav.querySelector(".chat-nav-popup-title");
228
- if (titleEl) titleEl.textContent = I18n.t("chat.nav.title") || "Messages";
229
-
230
- // Hover open / leave close
231
- nav.addEventListener("mouseenter", () => clearTimeout(hideTimer));
232
- nav.addEventListener("mouseleave", _hidePopup);
233
-
234
- // Observe message children changes (add / remove / clear)
235
- mo = new MutationObserver(() => _scheduleRebuild());
236
- mo.observe(messages, { childList: true });
237
-
238
- // Sync navigator bounds when layout changes
239
- ro = new ResizeObserver(() => {
240
- _syncBounds();
241
- _scheduleRebuild();
479
+ answerPreview = nav.querySelector(".chat-nav-answer");
480
+ answerLabel = nav.querySelector(".chat-nav-popup-label-text");
481
+ viewportQuery = window.matchMedia?.(MOBILE_MEDIA);
482
+ viewportQuery?.addEventListener("change", _handleViewportChange);
483
+ track.setAttribute("aria-label", I18n.t("chat.nav.title"));
484
+ answerLabel.textContent = I18n.t("chat.nav.aiReply");
485
+ track.addEventListener("pointerover", event => {
486
+ if (!expanded && !_canExpandFrom(event.target)) return;
487
+ _expand();
488
+ _hover(event.clientY - track.getBoundingClientRect().top, false);
242
489
  });
243
- ro.observe(chatMain);
244
- ro.observe(messages);
245
-
246
- // Track scroll position for active bar
247
- messages.addEventListener("scroll", _onScroll, { passive: true });
248
-
249
- // Re-render title on language change
490
+ track.addEventListener("pointermove", event => {
491
+ if (!expanded) return;
492
+ const overMessage = !!event.target.closest?.(".chat-nav-user");
493
+ _hover(event.clientY - track.getBoundingClientRect().top, overMessage);
494
+ });
495
+ track.addEventListener("click", _click);
496
+ track.addEventListener("wheel", event => {
497
+ if (!expanded && !_canExpandFrom(event.target)) return;
498
+ _expand();
499
+ event.preventDefault();
500
+ event.stopPropagation();
501
+ const delta = event.deltaY * (event.deltaMode === 1 ? STEP : event.deltaMode === 2 ? track.clientHeight : 1);
502
+ track.scrollTop += delta;
503
+ const overMessage = !!event.target.closest?.(".chat-nav-user");
504
+ _hover(event.clientY - track.getBoundingClientRect().top, overMessage);
505
+ }, { passive: false });
506
+ track.addEventListener("scroll", _renderTicks, { passive: true });
507
+ track.addEventListener("keydown", event => {
508
+ _expand();
509
+ let index = hoveredIdx >= 0 ? hoveredIdx : Math.max(activeIdx, 0);
510
+ if (event.key === "ArrowUp") index--;
511
+ else if (event.key === "ArrowDown") index++;
512
+ else if (event.key === "Home") index = 0;
513
+ else if (event.key === "End") index = items.length - 1;
514
+ else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); _jump(); return; }
515
+ else if (event.key === "Escape") { _hide(); return; }
516
+ else return;
517
+ event.preventDefault();
518
+ hoveredIdx = Math.max(0, Math.min(items.length - 1, index));
519
+ _reveal(hoveredIdx);
520
+ _renderTicks();
521
+ });
522
+ nav.addEventListener("pointerenter", () => clearTimeout(hideTimer));
523
+ nav.addEventListener("pointerleave", _leave);
524
+ document.addEventListener("pointermove", _handleDocumentPointerMove);
525
+ nav.addEventListener("focusout", event => { if (!nav.contains(event.relatedTarget)) _hide(); });
526
+ new MutationObserver(() => {
527
+ clearTimeout(syncTimer);
528
+ syncTimer = setTimeout(syncMessages, 100);
529
+ }).observe(messages, { childList: true });
530
+ const observer = new ResizeObserver(_syncBounds);
531
+ observer.observe(messages);
532
+ observer.observe(chatMain);
533
+ messages.addEventListener("scroll", () => {
534
+ cancelAnimationFrame(scrollFrame);
535
+ scrollFrame = requestAnimationFrame(_updateActive);
536
+ }, { passive: true });
250
537
  document.addEventListener("langchange", () => {
251
- const t = nav.querySelector(".chat-nav-popup-title");
252
- if (t) t.textContent = I18n.t("chat.nav.title") || "Messages";
538
+ track.setAttribute("aria-label", I18n.t("chat.nav.title"));
539
+ answerLabel.textContent = I18n.t("chat.nav.aiReply");
540
+ _renderPreview();
253
541
  });
254
-
255
542
  _syncBounds();
256
- _scheduleRebuild();
543
+ if (Sessions.activeId) setSession(Sessions.activeId);
257
544
  }
258
545
 
259
- window.ChatNavigator = { init };
546
+ window.ChatNavigator = { init, setSession, refresh, syncMessages };
260
547
  })();