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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +38 -97
- data/lib/clacky/agent.rb +281 -276
- data/lib/clacky/agent_config.rb +1 -1
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +6 -0
- data/lib/clacky/brand_config.rb +13 -2
- data/lib/clacky/cli.rb +27 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +38 -3
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +8 -5
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +161 -14
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +79 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
- data/lib/clacky/ui2/components/input_area.rb +22 -2
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/ui_controller.rb +44 -7
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +114 -66
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +596 -130
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/model-picker.js +2 -1
- data/lib/clacky/web/components/quote-select.js +235 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +392 -40
- data/lib/clacky/web/i18n.js +90 -5
- data/lib/clacky/web/index.html +15 -6
- data/lib/clacky/web/sessions.js +193 -38
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +20 -0
- data/lib/clacky.rb +2 -0
- metadata +11 -1
|
@@ -1,260 +1,547 @@
|
|
|
1
|
-
// ── Chat Navigator
|
|
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
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
function _tickY(index) {
|
|
48
|
+
return _baseOffset() + index * STEP;
|
|
49
|
+
}
|
|
44
50
|
|
|
45
|
-
function
|
|
46
|
-
if (!
|
|
47
|
-
const
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
if (!
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
247
|
+
function _cancelPreview() {
|
|
248
|
+
clearTimeout(previewTimer);
|
|
249
|
+
previewRequest?.controller.abort();
|
|
250
|
+
previewRequest = null;
|
|
251
|
+
previewQueueKey = null;
|
|
252
|
+
previewFailures.clear();
|
|
253
|
+
}
|
|
133
254
|
|
|
134
|
-
function
|
|
255
|
+
function _expand() {
|
|
135
256
|
clearTimeout(hideTimer);
|
|
136
|
-
|
|
257
|
+
if (expanded) return;
|
|
258
|
+
expanded = true;
|
|
259
|
+
nav.classList.add("expanded");
|
|
260
|
+
_renderTicks();
|
|
137
261
|
}
|
|
138
262
|
|
|
139
|
-
function
|
|
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 =
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
});
|
|
270
|
+
hoveredIdx = _nearest(y);
|
|
271
|
+
hoveringMessage = showAnswer;
|
|
272
|
+
_renderTicks();
|
|
145
273
|
}
|
|
146
274
|
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
418
|
+
function refresh() {
|
|
419
|
+
if (!sessionId) return;
|
|
420
|
+
clearTimeout(refreshTimer);
|
|
421
|
+
refreshTimer = setTimeout(_loadIndex, 350);
|
|
422
|
+
}
|
|
193
423
|
|
|
194
|
-
function
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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 =
|
|
208
|
-
chatMain =
|
|
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.
|
|
214
|
-
|
|
215
|
-
'<div class="chat-nav-
|
|
216
|
-
'<
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
543
|
+
if (Sessions.activeId) setSession(Sessions.activeId);
|
|
257
544
|
}
|
|
258
545
|
|
|
259
|
-
window.ChatNavigator = { init };
|
|
546
|
+
window.ChatNavigator = { init, setSession, refresh, syncMessages };
|
|
260
547
|
})();
|