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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -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 +53 -98
  6. data/lib/clacky/agent.rb +477 -297
  7. data/lib/clacky/agent_config.rb +5 -3
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +7 -0
  10. data/lib/clacky/brand_config.rb +27 -19
  11. data/lib/clacky/cli.rb +92 -12
  12. data/lib/clacky/cli_guidance.rb +48 -0
  13. data/lib/clacky/client.rb +2 -2
  14. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  19. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  20. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  21. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  22. data/lib/clacky/extension/api_extension.rb +18 -6
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/json_ui_controller.rb +4 -2
  25. data/lib/clacky/media/openai_compat.rb +70 -30
  26. data/lib/clacky/message_format/bedrock.rb +6 -1
  27. data/lib/clacky/message_format/open_ai.rb +22 -5
  28. data/lib/clacky/message_format/open_ai_responses.rb +7 -3
  29. data/lib/clacky/plain_ui_controller.rb +1 -1
  30. data/lib/clacky/prompts/base.md +1 -1
  31. data/lib/clacky/providers.rb +44 -13
  32. data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
  33. data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
  34. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
  35. data/lib/clacky/search_config.rb +3 -3
  36. data/lib/clacky/server/channel/channel_manager.rb +25 -7
  37. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  38. data/lib/clacky/server/dir_picker.rb +67 -4
  39. data/lib/clacky/server/git_panel.rb +10 -2
  40. data/lib/clacky/server/http_server.rb +391 -75
  41. data/lib/clacky/server/preview.rb +351 -0
  42. data/lib/clacky/server/session_registry.rb +4 -0
  43. data/lib/clacky/server/web_ui_controller.rb +10 -5
  44. data/lib/clacky/session_manager.rb +52 -1
  45. data/lib/clacky/skill.rb +48 -0
  46. data/lib/clacky/tools/browser.rb +176 -19
  47. data/lib/clacky/tools/web_search.rb +91 -8
  48. data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
  49. data/lib/clacky/ui2/components/input_area.rb +37 -4
  50. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  51. data/lib/clacky/ui2/screen_buffer.rb +1 -0
  52. data/lib/clacky/ui2/ui_controller.rb +72 -12
  53. data/lib/clacky/ui_interface.rb +1 -1
  54. data/lib/clacky/utils/file_processor.rb +19 -7
  55. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  56. data/lib/clacky/utils/model_pricing.rb +118 -68
  57. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  58. data/lib/clacky/version.rb +1 -1
  59. data/lib/clacky/web/app.css +998 -168
  60. data/lib/clacky/web/app.js +19 -0
  61. data/lib/clacky/web/components/chat-navigator.js +489 -202
  62. data/lib/clacky/web/components/code-editor.js +191 -5
  63. data/lib/clacky/web/components/composer.js +3 -1
  64. data/lib/clacky/web/components/datepicker.js +19 -19
  65. data/lib/clacky/web/components/mentions.js +11 -14
  66. data/lib/clacky/web/components/model-picker.js +66 -23
  67. data/lib/clacky/web/components/quote-select.js +341 -0
  68. data/lib/clacky/web/core/aside.js +157 -8
  69. data/lib/clacky/web/core/ext.js +16 -0
  70. data/lib/clacky/web/features/billing/view.js +75 -21
  71. data/lib/clacky/web/features/extensions/store.js +4 -1
  72. data/lib/clacky/web/features/model-tester/store.js +37 -4
  73. data/lib/clacky/web/features/skills/store.js +18 -1
  74. data/lib/clacky/web/features/skills/view.js +128 -7
  75. data/lib/clacky/web/features/workspace/store.js +80 -7
  76. data/lib/clacky/web/features/workspace/view.js +474 -43
  77. data/lib/clacky/web/i18n.js +158 -23
  78. data/lib/clacky/web/index.html +57 -29
  79. data/lib/clacky/web/sessions.js +517 -127
  80. data/lib/clacky/web/settings.js +155 -6
  81. data/lib/clacky/web/utils.js +34 -0
  82. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  83. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  84. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  85. data/lib/clacky/web/ws-dispatcher.js +161 -4
  86. data/lib/clacky.rb +2 -0
  87. 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 = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width"));
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 = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width"));
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
- if (window.Clacky) Clacky.Aside = {
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).
@@ -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.