openclacky 1.5.13 → 1.5.15
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 +96 -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 +53 -98
- data/lib/clacky/agent.rb +477 -297
- data/lib/clacky/agent_config.rb +5 -3
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +7 -0
- data/lib/clacky/brand_config.rb +27 -19
- data/lib/clacky/cli.rb +92 -12
- data/lib/clacky/cli_guidance.rb +48 -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/api/handler.rb +4 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
- 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 +18 -6
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/json_ui_controller.rb +4 -2
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/message_format/open_ai.rb +22 -5
- data/lib/clacky/message_format/open_ai_responses.rb +7 -3
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +44 -13
- data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +25 -7
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/dir_picker.rb +67 -4
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +391 -75
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/session_registry.rb +4 -0
- data/lib/clacky/server/web_ui_controller.rb +10 -5
- data/lib/clacky/session_manager.rb +52 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +91 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
- data/lib/clacky/ui2/components/input_area.rb +37 -4
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/screen_buffer.rb +1 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -12
- data/lib/clacky/ui_interface.rb +1 -1
- 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 +118 -68
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +998 -168
- data/lib/clacky/web/app.js +19 -0
- 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/composer.js +3 -1
- data/lib/clacky/web/components/datepicker.js +19 -19
- data/lib/clacky/web/components/mentions.js +11 -14
- data/lib/clacky/web/components/model-picker.js +66 -23
- data/lib/clacky/web/components/quote-select.js +341 -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/extensions/store.js +4 -1
- data/lib/clacky/web/features/model-tester/store.js +37 -4
- 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 +474 -43
- data/lib/clacky/web/i18n.js +158 -23
- data/lib/clacky/web/index.html +57 -29
- data/lib/clacky/web/sessions.js +517 -127
- data/lib/clacky/web/settings.js +155 -6
- 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 +161 -4
- data/lib/clacky.rb +2 -0
- metadata +13 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// ── quote-select.js — Quote-on-selection ────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Drag-select text inside any user or assistant message bubble; a floating
|
|
4
|
+
// "Quote" pill appears above the selection. Clicking it drops the selection
|
|
5
|
+
// into the composer as an atomic quote chip:
|
|
6
|
+
//
|
|
7
|
+
// [❝ the selected text…] ▏what the user types next
|
|
8
|
+
//
|
|
9
|
+
// The chip reuses the mention-chip mechanics in composer.js: it is
|
|
10
|
+
// contenteditable=false, the caret skips over it, Backspace removes it whole,
|
|
11
|
+
// and Composer.text() leaves it out of the plain-text value. The composer DOM
|
|
12
|
+
// is the single source of truth — nothing is mirrored in JS state, so editing
|
|
13
|
+
// the composer by hand can never desync the staged quotes.
|
|
14
|
+
//
|
|
15
|
+
// QuoteSelect.toReferences() walks those chips at send time and turns them into
|
|
16
|
+
// reference objects. They travel the same `references` channel as @mention
|
|
17
|
+
// chips, so the sent bubble — and every replay of it — renders them with the
|
|
18
|
+
// same badge (see sessions.js _renderMentionBadge), and http_server.rb inlines
|
|
19
|
+
// the excerpt into the model's context. The excerpt never reaches the message
|
|
20
|
+
// body, so it can't be shown twice.
|
|
21
|
+
//
|
|
22
|
+
// Event bindings are delegation-style on document, so they survive history
|
|
23
|
+
// lazy-loading and streaming DOM rebuilds. The floating button itself is a
|
|
24
|
+
// single recycled element, created lazily and hidden when any selection dies.
|
|
25
|
+
|
|
26
|
+
const QuoteSelect = (() => {
|
|
27
|
+
const INPUT_ID = "user-input";
|
|
28
|
+
const CHIP_CLASS = "quote-chip";
|
|
29
|
+
|
|
30
|
+
// Lucide "message-square-text" — the comment glyph. Reads clearly at 13px and
|
|
31
|
+
// stays distinct from the plain speech bubble the mention chips use.
|
|
32
|
+
const ICON_QUOTE =
|
|
33
|
+
'<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>' +
|
|
34
|
+
'<path d="M13 8H7"/><path d="M17 12H7"/>';
|
|
35
|
+
|
|
36
|
+
let _btn = null; // floating quote button (lazily created, recycled)
|
|
37
|
+
let _snap = null; // selection snapshot captured when the button appears
|
|
38
|
+
let _pending = null; // mouseup validation timer
|
|
39
|
+
|
|
40
|
+
// ── DOM helpers ──────────────────────────────────────────────────────────
|
|
41
|
+
// Nearest message root: .msg-user-wrap (user) or .msg-assistant. Returns
|
|
42
|
+
// null when the node is outside any quotable message (e.g. composer, modal).
|
|
43
|
+
function _msgRoot(node) {
|
|
44
|
+
if (!node || node.nodeType === Node.DOCUMENT_NODE) return null;
|
|
45
|
+
const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
46
|
+
if (!el) return null;
|
|
47
|
+
return el.closest(".msg-user-wrap, .msg-assistant");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Message role + 1-based chat index, counted among quotable messages only
|
|
51
|
+
// (user / assistant), so the index stays consistent with rendered history.
|
|
52
|
+
function _msgMeta(root) {
|
|
53
|
+
const items = root.parentElement.querySelectorAll(
|
|
54
|
+
":scope > .msg-user-wrap, :scope > .msg-assistant"
|
|
55
|
+
);
|
|
56
|
+
return {
|
|
57
|
+
role: root.classList.contains("msg-user-wrap") ? "user" : "assistant",
|
|
58
|
+
index: Array.prototype.indexOf.call(items, root) + 1,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _roleWord(role) {
|
|
63
|
+
return role === "user"
|
|
64
|
+
? I18n.t("chat.quote.roleUser")
|
|
65
|
+
: I18n.t("chat.quote.roleAssistant");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _signature(quote) {
|
|
69
|
+
return I18n.t("chat.quote.sig", {
|
|
70
|
+
role: _roleWord(quote.role),
|
|
71
|
+
index: quote.index,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _icon(paths, size) {
|
|
76
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" width="' + size + '" height="' + size +
|
|
77
|
+
'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
78
|
+
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + paths + "</svg>";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _input() {
|
|
82
|
+
return document.getElementById(INPUT_ID);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Text cleaning ────────────────────────────────────────────────────────
|
|
86
|
+
// Normalize raw selection text into quote lines: strip leading/trailing
|
|
87
|
+
// blank lines, collapse consecutive blanks inside, keep each line's
|
|
88
|
+
// trailing whitespace trimmed.
|
|
89
|
+
function _cleanLines(raw) {
|
|
90
|
+
const lines = String(raw || "").replace(/\r\n/g, "\n").split("\n");
|
|
91
|
+
while (lines.length && !lines[0].trim()) lines.shift();
|
|
92
|
+
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
if (!line.trim()) {
|
|
96
|
+
if (!out.length || out[out.length - 1] !== "") out.push("");
|
|
97
|
+
} else {
|
|
98
|
+
out.push(line.replace(/\s+$/, ""));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _cleanText(raw) {
|
|
105
|
+
return _cleanLines(raw).join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Serialization ────────────────────────────────────────────────────────
|
|
109
|
+
// Read the staged quotes straight off the composer — document order, which is
|
|
110
|
+
// the order the user arranged them in.
|
|
111
|
+
function list() {
|
|
112
|
+
const el = _input();
|
|
113
|
+
if (!el) return [];
|
|
114
|
+
return Array.prototype.map.call(el.querySelectorAll("." + CHIP_CLASS), function (chip) {
|
|
115
|
+
return {
|
|
116
|
+
role: chip.dataset.quoteRole || "user",
|
|
117
|
+
index: Number(chip.dataset.quoteIndex) || 0,
|
|
118
|
+
text: chip.dataset.quoteText || "",
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Whole staged list → reference objects for the outgoing payload. The excerpt
|
|
124
|
+
// is the point of the reference, so it rides along verbatim; `label` is the
|
|
125
|
+
// provenance line ("Assistant · #82") the badge tooltip shows.
|
|
126
|
+
function toReferences() {
|
|
127
|
+
return list().map(function (quote) {
|
|
128
|
+
return { type: "quote", label: _signature(quote), text: _cleanText(quote.text) };
|
|
129
|
+
}).filter(function (ref) { return ref.text; });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Shared with sessions.js, which draws the same glyph on the sent bubble.
|
|
133
|
+
function icon(size) {
|
|
134
|
+
return _icon(ICON_QUOTE, size || 13);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Composer chip ────────────────────────────────────────────────────────
|
|
138
|
+
function _buildChip(quote) {
|
|
139
|
+
const chip = document.createElement("span");
|
|
140
|
+
chip.className = "mention-chip quote-chip";
|
|
141
|
+
chip.contentEditable = "false";
|
|
142
|
+
chip.dataset.quoteRole = quote.role;
|
|
143
|
+
chip.dataset.quoteIndex = String(quote.index);
|
|
144
|
+
chip.dataset.quoteText = quote.text;
|
|
145
|
+
chip.title = _signature(quote) + "\n" + quote.text;
|
|
146
|
+
chip.innerHTML = _icon(ICON_QUOTE, 13);
|
|
147
|
+
|
|
148
|
+
const label = document.createElement("span");
|
|
149
|
+
label.className = "mention-chip-label";
|
|
150
|
+
// Flatten onto one line: the chip is a single-line affordance, the markdown
|
|
151
|
+
// keeps the real line breaks.
|
|
152
|
+
label.textContent = quote.text.replace(/\s*\n\s*/g, " ");
|
|
153
|
+
chip.appendChild(label);
|
|
154
|
+
|
|
155
|
+
return chip;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Drop a chip in at the caret (end of composer when the caret is elsewhere)
|
|
159
|
+
// and leave the caret after it, ready for typing.
|
|
160
|
+
function _insert(chip) {
|
|
161
|
+
const el = _input();
|
|
162
|
+
if (!el) return;
|
|
163
|
+
const sel = window.getSelection();
|
|
164
|
+
const range = (sel && sel.rangeCount && el.contains(sel.anchorNode))
|
|
165
|
+
? sel.getRangeAt(0)
|
|
166
|
+
: (function () {
|
|
167
|
+
const r = document.createRange();
|
|
168
|
+
r.selectNodeContents(el);
|
|
169
|
+
r.collapse(false);
|
|
170
|
+
return r;
|
|
171
|
+
})();
|
|
172
|
+
range.collapse(false);
|
|
173
|
+
|
|
174
|
+
const space = document.createTextNode(" ");
|
|
175
|
+
range.insertNode(chip);
|
|
176
|
+
chip.parentNode.insertBefore(space, chip.nextSibling);
|
|
177
|
+
|
|
178
|
+
// Without a text anchor before it the browser wraps an atomic chip onto its
|
|
179
|
+
// own line; composer.js keeps re-seeding these guards on every input.
|
|
180
|
+
const before = chip.previousSibling;
|
|
181
|
+
if (!before || before.nodeType === Node.TEXT_NODE && before.nodeValue === "") {
|
|
182
|
+
chip.parentNode.insertBefore(document.createTextNode("\u200B"), chip);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const after = document.createRange();
|
|
186
|
+
after.setStartAfter(space);
|
|
187
|
+
after.collapse(true);
|
|
188
|
+
sel.removeAllRanges();
|
|
189
|
+
sel.addRange(after);
|
|
190
|
+
el.focus();
|
|
191
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function stage(snap) {
|
|
195
|
+
const text = _cleanText(snap.text);
|
|
196
|
+
if (!text) return;
|
|
197
|
+
_insert(_buildChip({ role: snap.role, index: snap.index, text: text }));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Replay saved quotes into the composer (session draft switching).
|
|
201
|
+
function restore(items) {
|
|
202
|
+
clear();
|
|
203
|
+
(items || []).forEach(function (item) {
|
|
204
|
+
if (item && item.text) {
|
|
205
|
+
_insert(_buildChip({
|
|
206
|
+
role: item.role || "user",
|
|
207
|
+
index: Number(item.index) || 0,
|
|
208
|
+
text: String(item.text),
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function clear() {
|
|
215
|
+
const el = _input();
|
|
216
|
+
if (!el) return;
|
|
217
|
+
Array.prototype.forEach.call(el.querySelectorAll("." + CHIP_CLASS), function (chip) {
|
|
218
|
+
if (typeof Composer !== "undefined" && Composer.removeChip) Composer.removeChip(chip);
|
|
219
|
+
else chip.remove();
|
|
220
|
+
});
|
|
221
|
+
// Removing the chips can leave their zero-width anchors behind, which would
|
|
222
|
+
// keep the :empty placeholder from coming back.
|
|
223
|
+
if (el.innerHTML !== "" && el.textContent.replace(/[\u200B\s]/g, "") === "") {
|
|
224
|
+
el.innerHTML = "";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function count() {
|
|
229
|
+
return list().length;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Button lifecycle ─────────────────────────────────────────────────────
|
|
233
|
+
function _createBtn() {
|
|
234
|
+
const btn = document.createElement("button");
|
|
235
|
+
btn.type = "button";
|
|
236
|
+
btn.className = "quote-select-btn";
|
|
237
|
+
btn.disabled = false;
|
|
238
|
+
btn.innerHTML =
|
|
239
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">${ICON_QUOTE}</svg>` +
|
|
240
|
+
`<span>${escapeHtml(I18n.t("chat.quote.btn"))}</span>`;
|
|
241
|
+
// Keep the textarea focus / selection alive: a bare mousedown on a button
|
|
242
|
+
// collapses the selection before click fires.
|
|
243
|
+
btn.addEventListener("mousedown", e => e.preventDefault());
|
|
244
|
+
btn.addEventListener("click", _onQuoteClick);
|
|
245
|
+
document.body.appendChild(btn);
|
|
246
|
+
return btn;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function _show(rect) {
|
|
250
|
+
if (!_btn) _btn = _createBtn();
|
|
251
|
+
// The button is a single recycled element — when it was first created it
|
|
252
|
+
// baked in the language of that moment. Sync the label with the current
|
|
253
|
+
// language on every show so switching en/zh mid-session stays consistent.
|
|
254
|
+
// (textContent assignment, no escaping needed.)
|
|
255
|
+
const labelQ = _btn.querySelector("span");
|
|
256
|
+
if (labelQ) labelQ.textContent = I18n.t("chat.quote.btn");
|
|
257
|
+
// The button is display:none until visible — measuring its rect while
|
|
258
|
+
// hidden returns 0×0, which would misplace it by its own height. Make it
|
|
259
|
+
// visible first, then measure and position (all synchronous, no flicker).
|
|
260
|
+
_btn.classList.add("visible");
|
|
261
|
+
const btnRect = _btn.getBoundingClientRect();
|
|
262
|
+
let top = rect.top - btnRect.height - 10; // 10px above the selection
|
|
263
|
+
if (top < 8) top = rect.bottom + 10; // flip below when clipped at top
|
|
264
|
+
_btn.style.top = top + "px";
|
|
265
|
+
// Horizontal center of the selection; CSS translateX(-50%) finishes it.
|
|
266
|
+
_btn.style.left = (rect.left + rect.width / 2) + "px";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function _hide() {
|
|
270
|
+
if (_pending) { clearTimeout(_pending); _pending = null; }
|
|
271
|
+
if (_btn) _btn.classList.remove("visible");
|
|
272
|
+
_snap = null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Quote action ─────────────────────────────────────────────────────────
|
|
276
|
+
function _onQuoteClick(e) {
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
e.stopPropagation();
|
|
279
|
+
if (!_snap) return _hide();
|
|
280
|
+
const snap = _snap;
|
|
281
|
+
_hide();
|
|
282
|
+
stage(snap);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Selection validation ─────────────────────────────────────────────────
|
|
286
|
+
// Runs 60ms after mouseup/touchend so the browser has finished updating the
|
|
287
|
+
// selection (dbl-click word selection, drag end, etc.).
|
|
288
|
+
function _onMouseUp() {
|
|
289
|
+
if (_pending) clearTimeout(_pending);
|
|
290
|
+
_pending = setTimeout(_validate, 60);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function _validate() {
|
|
294
|
+
_pending = null;
|
|
295
|
+
const sel = window.getSelection();
|
|
296
|
+
if (!sel || sel.isCollapsed || sel.rangeCount < 1) return _hide();
|
|
297
|
+
const range = sel.getRangeAt(0);
|
|
298
|
+
const r1 = _msgRoot(range.startContainer);
|
|
299
|
+
const r2 = _msgRoot(range.endContainer);
|
|
300
|
+
if (!r1 || r1 !== r2) return _hide(); // cross-message selection: no unique provenance
|
|
301
|
+
const messages = document.getElementById("messages");
|
|
302
|
+
if (!messages || !messages.contains(r1)) return _hide();
|
|
303
|
+
|
|
304
|
+
const text = sel.toString().trim();
|
|
305
|
+
if (!text) return _hide();
|
|
306
|
+
|
|
307
|
+
_snap = Object.assign({ text }, _msgMeta(r1), { rect: range.getBoundingClientRect() });
|
|
308
|
+
_show(_snap.rect);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function _onDocMouseDown(e) {
|
|
312
|
+
if (_btn && e.target !== _btn && !_btn.contains(e.target)) _hide();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Init ─────────────────────────────────────────────────────────────────
|
|
316
|
+
let _inited = false; // guard against double init (listener stacking)
|
|
317
|
+
function init() {
|
|
318
|
+
if (_inited) return;
|
|
319
|
+
_inited = true;
|
|
320
|
+
document.addEventListener("mouseup", _onMouseUp);
|
|
321
|
+
document.addEventListener("touchend", _onMouseUp);
|
|
322
|
+
document.addEventListener("mousedown", _onDocMouseDown);
|
|
323
|
+
document.addEventListener("scroll", _hide, true); // any scroll collides with the anchor
|
|
324
|
+
document.addEventListener("keydown", e => { if (e.key === "Escape") _hide(); });
|
|
325
|
+
window.addEventListener("resize", _hide);
|
|
326
|
+
|
|
327
|
+
// Streaming rewrites message DOM wholesale; a detached selection cannot
|
|
328
|
+
// anchor the button anymore, so hide whenever the message list mutates.
|
|
329
|
+
const messages = document.getElementById("messages");
|
|
330
|
+
if (messages && typeof MutationObserver !== "undefined") {
|
|
331
|
+
new MutationObserver(() => { if (_snap) _hide(); }).observe(messages, {
|
|
332
|
+
childList: true,
|
|
333
|
+
subtree: true,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
init, stage, list, restore, clear, count, toReferences, icon,
|
|
340
|
+
};
|
|
341
|
+
})();
|
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
// chrome so slot re-renders never disturb width or collapse state.
|
|
6
6
|
//
|
|
7
7
|
// - drag #session-aside-resize to change width (persisted)
|
|
8
|
+
// - panels ask for extra width via Clacky.Aside.requestWidth(); the clamp
|
|
9
|
+
// lives here so no caller can push the mobile drawer off-screen
|
|
8
10
|
// - #btn-aside-collapse hides the column; #btn-aside-open brings it back
|
|
11
|
+
// - #btn-aside-fullscreen hides the chat column and lets the aside take
|
|
12
|
+
// the whole workspace (Esc or the button again to leave; collapsing the
|
|
13
|
+
// panel also leaves fullscreen)
|
|
9
14
|
// - when the slot is empty (no panels for this agent) CSS collapses the
|
|
10
15
|
// column on its own; the opener stays hidden in that case
|
|
11
16
|
//
|
|
@@ -16,11 +21,106 @@
|
|
|
16
21
|
(() => {
|
|
17
22
|
const WIDTH_KEY = "clacky.aside.width";
|
|
18
23
|
const OPEN_KEY = "clacky.aside.open";
|
|
24
|
+
const FULLSCREEN_KEY = "clacky.aside.fullscreen";
|
|
19
25
|
const MIN_W = 256;
|
|
20
26
|
const maxW = () => Math.max(MIN_W, Math.round(window.innerWidth * 0.8));
|
|
27
|
+
// Matches the CSS breakpoint where the aside becomes a fixed overlay drawer.
|
|
28
|
+
const isNarrow = () => window.matchMedia("(max-width: 768px)").matches;
|
|
29
|
+
const widthRequests = new Map();
|
|
30
|
+
let nextWidthRequestId = 1;
|
|
31
|
+
let widthBeforeRequests = null;
|
|
32
|
+
let appliedRequestWidth = null;
|
|
33
|
+
let manuallyResizedWhileRequested = false;
|
|
21
34
|
|
|
22
35
|
const $ = (id) => document.getElementById(id);
|
|
23
36
|
|
|
37
|
+
// Custom properties resolve to the authored token, not px — the CSS default
|
|
38
|
+
// is 16rem, so it needs converting. Every width set here (and the persisted
|
|
39
|
+
// one) is already px.
|
|
40
|
+
function currentWidth() {
|
|
41
|
+
const aside = $("session-aside");
|
|
42
|
+
if (!aside) return 0;
|
|
43
|
+
let raw = "";
|
|
44
|
+
try {
|
|
45
|
+
raw = getComputedStyle(aside).getPropertyValue("--session-aside-width").trim();
|
|
46
|
+
} catch (_e) { return 0; }
|
|
47
|
+
const n = parseFloat(raw);
|
|
48
|
+
if (!isFinite(n)) return 0;
|
|
49
|
+
if (/rem$/.test(raw)) {
|
|
50
|
+
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
51
|
+
return n * root;
|
|
52
|
+
}
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setWidth(px) {
|
|
57
|
+
const aside = $("session-aside");
|
|
58
|
+
if (aside) aside.style.setProperty("--session-aside-width", px + "px");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Reconcile temporary requests centrally so nested panels can close in any
|
|
62
|
+
// order. For example, closing a Files preview while an 860px Git diff is
|
|
63
|
+
// still open must not make the later Git release restore a stale 700px width.
|
|
64
|
+
function reconcileRequestedWidths() {
|
|
65
|
+
if (!$('session-aside')) return;
|
|
66
|
+
const width = currentWidth();
|
|
67
|
+
|
|
68
|
+
if (widthRequests.size === 0) {
|
|
69
|
+
if (!manuallyResizedWhileRequested && widthBeforeRequests !== null &&
|
|
70
|
+
appliedRequestWidth !== null && Math.round(width) === appliedRequestWidth) {
|
|
71
|
+
setWidth(widthBeforeRequests);
|
|
72
|
+
}
|
|
73
|
+
widthBeforeRequests = null;
|
|
74
|
+
appliedRequestWidth = null;
|
|
75
|
+
manuallyResizedWhileRequested = false;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (widthBeforeRequests === null) widthBeforeRequests = width;
|
|
80
|
+
if (manuallyResizedWhileRequested) return;
|
|
81
|
+
const requested = Math.min(maxW(), Math.max(...widthRequests.values()));
|
|
82
|
+
const desired = Math.max(widthBeforeRequests, requested);
|
|
83
|
+
if (Math.round(width) !== Math.round(desired)) setWidth(desired);
|
|
84
|
+
appliedRequestWidth = Math.round(desired);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Panels that need horizontal room (diff, file preview) ask for a wider
|
|
88
|
+
// aside. Narrow layouts deliberately ignore temporary requests because the
|
|
89
|
+
// aside is a fixed mobile drawer there. Persistent requests remain available
|
|
90
|
+
// for legacy panels that intentionally update the user's preferred width.
|
|
91
|
+
function requestWidth(px, opts) {
|
|
92
|
+
const aside = $("session-aside");
|
|
93
|
+
if (!aside || isNarrow()) return null;
|
|
94
|
+
const requested = Number(px);
|
|
95
|
+
if (!Number.isFinite(requested) || requested <= 0) return null;
|
|
96
|
+
const target = Math.min(maxW(), Math.max(MIN_W, requested));
|
|
97
|
+
if (opts && opts.persist) {
|
|
98
|
+
const preferred = Math.max(currentWidth(), target);
|
|
99
|
+
setWidth(preferred);
|
|
100
|
+
try { localStorage.setItem(WIDTH_KEY, String(preferred)); } catch (_e) { /* non-fatal */ }
|
|
101
|
+
if (widthRequests.size > 0) widthBeforeRequests = preferred;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const id = nextWidthRequestId++;
|
|
106
|
+
widthRequests.set(id, target);
|
|
107
|
+
reconcileRequestedWidths();
|
|
108
|
+
let released = false;
|
|
109
|
+
return () => {
|
|
110
|
+
if (released) return;
|
|
111
|
+
released = true;
|
|
112
|
+
widthRequests.delete(id);
|
|
113
|
+
reconcileRequestedWidths();
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function clampWidth() {
|
|
118
|
+
const w = currentWidth();
|
|
119
|
+
if (!w) return;
|
|
120
|
+
const max = maxW();
|
|
121
|
+
if (w > max) setWidth(max);
|
|
122
|
+
}
|
|
123
|
+
|
|
24
124
|
function slotEmpty() {
|
|
25
125
|
const slot = $("ext-slot-session-aside");
|
|
26
126
|
return !slot || slot.childElementCount === 0;
|
|
@@ -40,6 +140,9 @@
|
|
|
40
140
|
aside.classList.toggle("collapsed", !open);
|
|
41
141
|
if (opener) opener.style.display = (!open && !empty) ? "" : "none";
|
|
42
142
|
if (overlay) overlay.classList.toggle("active", open && !empty);
|
|
143
|
+
// A fullscreen class with an empty slot would show an empty column over
|
|
144
|
+
// the whole workspace (the fullscreen rule outranks the empty-slot one).
|
|
145
|
+
if (empty) setFullscreen(false);
|
|
43
146
|
}
|
|
44
147
|
|
|
45
148
|
function isOpen() {
|
|
@@ -51,9 +154,28 @@
|
|
|
51
154
|
|
|
52
155
|
function setOpen(open) {
|
|
53
156
|
try { localStorage.setItem(OPEN_KEY, open ? "1" : "0"); } catch (_e) { /* ignore */ }
|
|
157
|
+
if (!open) setFullscreen(false);
|
|
54
158
|
applyOpenState();
|
|
55
159
|
}
|
|
56
160
|
|
|
161
|
+
function isFullscreen() {
|
|
162
|
+
try { return localStorage.getItem(FULLSCREEN_KEY) === "1"; } catch (_e) { return false; }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function applyFullscreenState() {
|
|
166
|
+
const chatPanel = $("chat-panel");
|
|
167
|
+
if (chatPanel) chatPanel.classList.toggle("aside-fullscreen", isFullscreen());
|
|
168
|
+
const btn = $("btn-aside-fullscreen");
|
|
169
|
+
if (btn && window.Clacky && Clacky.I18n) {
|
|
170
|
+
btn.title = Clacky.I18n.t(isFullscreen() ? "aside.exitFullscreen" : "aside.fullscreen");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function setFullscreen(on) {
|
|
175
|
+
try { localStorage.setItem(FULLSCREEN_KEY, on ? "1" : "0"); } catch (_e) { /* ignore */ }
|
|
176
|
+
applyFullscreenState();
|
|
177
|
+
}
|
|
178
|
+
|
|
57
179
|
function initResize() {
|
|
58
180
|
const aside = $("session-aside");
|
|
59
181
|
const handle = $("session-aside-resize");
|
|
@@ -72,7 +194,7 @@
|
|
|
72
194
|
e.preventDefault();
|
|
73
195
|
dragging = true;
|
|
74
196
|
startX = e.clientX;
|
|
75
|
-
startW =
|
|
197
|
+
startW = currentWidth();
|
|
76
198
|
handle.classList.add("active");
|
|
77
199
|
aside.style.transition = "none";
|
|
78
200
|
document.body.style.cursor = "col-resize";
|
|
@@ -93,8 +215,13 @@
|
|
|
93
215
|
aside.style.transition = "";
|
|
94
216
|
document.body.style.cursor = "";
|
|
95
217
|
document.body.style.userSelect = "";
|
|
96
|
-
const w =
|
|
218
|
+
const w = currentWidth();
|
|
97
219
|
try { localStorage.setItem(WIDTH_KEY, w); } catch (_e) { /* ignore */ }
|
|
220
|
+
if (widthRequests.size > 0) {
|
|
221
|
+
widthBeforeRequests = w;
|
|
222
|
+
appliedRequestWidth = null;
|
|
223
|
+
manuallyResizedWhileRequested = true;
|
|
224
|
+
}
|
|
98
225
|
});
|
|
99
226
|
}
|
|
100
227
|
|
|
@@ -102,25 +229,47 @@
|
|
|
102
229
|
// Expose setOpen so external code (e.g. WS dispatcher) can open/close the
|
|
103
230
|
// aside panel programmatically without duplicating localStorage logic here.
|
|
104
231
|
// open() is a no-op when the panel is already open — safe to call unconditionally.
|
|
105
|
-
|
|
232
|
+
const publicApi = () => ({
|
|
106
233
|
open: () => { if (!isOpen()) setOpen(true); },
|
|
107
234
|
close: () => setOpen(false),
|
|
108
|
-
|
|
235
|
+
fullscreen: (on) => setFullscreen(on !== false),
|
|
236
|
+
requestWidth: requestWidth,
|
|
237
|
+
});
|
|
238
|
+
if (window.Clacky) Clacky.Aside = publicApi();
|
|
109
239
|
|
|
110
240
|
function init() {
|
|
111
241
|
// Re-assign in case Clacky was not yet defined when the IIFE ran.
|
|
112
|
-
if (window.Clacky) Clacky.Aside =
|
|
113
|
-
open: () => { if (!isOpen()) setOpen(true); },
|
|
114
|
-
close: () => setOpen(false),
|
|
115
|
-
};
|
|
242
|
+
if (window.Clacky) Clacky.Aside = publicApi();
|
|
116
243
|
const collapse = $("btn-aside-collapse");
|
|
117
244
|
const opener = $("btn-aside-open");
|
|
118
245
|
const overlay = $("workspace-overlay");
|
|
246
|
+
const fsBtn = $("btn-aside-fullscreen");
|
|
119
247
|
if (collapse) collapse.addEventListener("click", () => setOpen(false));
|
|
120
248
|
if (opener) opener.addEventListener("click", () => setOpen(true));
|
|
121
249
|
if (overlay) overlay.addEventListener("click", () => setOpen(false));
|
|
250
|
+
if (fsBtn) fsBtn.addEventListener("click", () => setFullscreen(!isFullscreen()));
|
|
251
|
+
document.addEventListener("keydown", (e) => {
|
|
252
|
+
if (e.key !== "Escape" || !isFullscreen()) return;
|
|
253
|
+
// Let overlay panels (settings / skills) consume Esc first.
|
|
254
|
+
const blocked = ["settings-panel", "skills-panel"].some((id) => {
|
|
255
|
+
const el = document.getElementById(id);
|
|
256
|
+
return el && el.style.display && el.style.display !== "none";
|
|
257
|
+
});
|
|
258
|
+
if (!blocked) setFullscreen(false);
|
|
259
|
+
});
|
|
122
260
|
initResize();
|
|
123
261
|
applyOpenState();
|
|
262
|
+
applyFullscreenState();
|
|
263
|
+
|
|
264
|
+
// A width set while the window was wide survives into the narrow layout,
|
|
265
|
+
// where the aside is a fixed drawer — clamp it back on resize so it cannot
|
|
266
|
+
// sit off-screen. (Persisted widths are already filtered on load.)
|
|
267
|
+
let clampScheduled = false;
|
|
268
|
+
window.addEventListener("resize", () => {
|
|
269
|
+
if (clampScheduled) return;
|
|
270
|
+
clampScheduled = true;
|
|
271
|
+
requestAnimationFrame(() => { clampScheduled = false; clampWidth(); });
|
|
272
|
+
});
|
|
124
273
|
|
|
125
274
|
// Re-evaluate opener visibility whenever the slot content changes (panels
|
|
126
275
|
// re-render on session / agent switch).
|
data/lib/clacky/web/core/ext.js
CHANGED
|
@@ -48,6 +48,7 @@ Clacky.ext = (() => {
|
|
|
48
48
|
const _dataSources = {}; // name => fn
|
|
49
49
|
const _subscribers = {}; // event => [handler]
|
|
50
50
|
const _slotRenderers = {}; // slot => [{ fn, extId, agents, panel, order, tab }]
|
|
51
|
+
const _slotTabActivators = {}; // tabbed slot => activate(tabId) from the latest render
|
|
51
52
|
const _workspaces = {}; // id => { id, title, render, extId }
|
|
52
53
|
let _currentExtId = null; // set while an extension file is loading
|
|
53
54
|
|
|
@@ -581,6 +582,9 @@ Clacky.ext = (() => {
|
|
|
581
582
|
// render pass. The host chrome (resize / collapse) lives in the surrounding
|
|
582
583
|
// DOM and is not touched here. Re-rendered wholesale on every session switch.
|
|
583
584
|
function renderTabbedSlot(slot, container, ctx) {
|
|
585
|
+
// Invalidate any activator captured by a previous render of this slot;
|
|
586
|
+
// if this pass finds no renderers it must stay invalid.
|
|
587
|
+
_slotTabActivators[slot] = null;
|
|
584
588
|
container.replaceChildren();
|
|
585
589
|
const profile = (ctx && ctx.agentProfile) || context.agentProfile;
|
|
586
590
|
const renderCtx = Object.assign({}, context, ctx || {});
|
|
@@ -607,6 +611,7 @@ Clacky.ext = (() => {
|
|
|
607
611
|
const rendered = {};
|
|
608
612
|
|
|
609
613
|
function activate(id) {
|
|
614
|
+
if (!tabBtns[id]) return false;
|
|
610
615
|
Object.keys(tabBtns).forEach((k) => tabBtns[k].classList.toggle("active", k === id));
|
|
611
616
|
Object.keys(bodyEls).forEach((k) => bodyEls[k].classList.toggle("active", k === id));
|
|
612
617
|
if (!rendered[id]) {
|
|
@@ -624,7 +629,9 @@ Clacky.ext = (() => {
|
|
|
624
629
|
else body.appendChild(node);
|
|
625
630
|
}
|
|
626
631
|
}
|
|
632
|
+
return true;
|
|
627
633
|
}
|
|
634
|
+
_slotTabActivators[slot] = activate;
|
|
628
635
|
|
|
629
636
|
renderers.forEach((entry) => {
|
|
630
637
|
const id = entry.tab.id;
|
|
@@ -698,6 +705,14 @@ Clacky.ext = (() => {
|
|
|
698
705
|
return Object.keys(_slotRenderers).filter((s) => _slotRenderers[s].length > 0);
|
|
699
706
|
}
|
|
700
707
|
|
|
708
|
+
// Programmatically activate a tab in a tabbed slot (e.g. switch the session
|
|
709
|
+
// aside to the preview tab when the user clicks an HTML file or a localhost
|
|
710
|
+
// link in chat). Returns true when the slot is rendered and the tab exists.
|
|
711
|
+
function activateTab(slot, tabId) {
|
|
712
|
+
const activate = _slotTabActivators[slot];
|
|
713
|
+
return activate ? !!activate(tabId) : false;
|
|
714
|
+
}
|
|
715
|
+
|
|
701
716
|
// Update the current session context (host calls this on every session
|
|
702
717
|
// switch) and re-render all slots so panels match the new agent profile.
|
|
703
718
|
// Runs the previous session's view-level teardowns first (event listeners /
|
|
@@ -756,6 +771,7 @@ Clacky.ext = (() => {
|
|
|
756
771
|
emit,
|
|
757
772
|
renderSlot,
|
|
758
773
|
slots,
|
|
774
|
+
activateTab,
|
|
759
775
|
// Host-only: look up a registered workspace by id. Returns
|
|
760
776
|
// { id, title, render, extId } or undefined. Used by the Router when
|
|
761
777
|
// handling the `ext-workspace` view — extensions never call this.
|