openclacky 1.5.2 → 1.5.4

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +13 -0
  3. data/CHANGELOG.md +50 -0
  4. data/Dockerfile +21 -1
  5. data/README.md +26 -7
  6. data/README_CN.md +26 -7
  7. data/README_JA.md +26 -7
  8. data/lib/clacky/agent/message_compressor.rb +4 -29
  9. data/lib/clacky/agent/message_compressor_helper.rb +13 -15
  10. data/lib/clacky/agent/session_serializer.rb +6 -0
  11. data/lib/clacky/agent.rb +13 -1
  12. data/lib/clacky/agent_config.rb +42 -11
  13. data/lib/clacky/brand_config.rb +32 -12
  14. data/lib/clacky/client.rb +7 -0
  15. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
  16. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +2 -6
  17. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
  18. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
  19. data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
  20. data/lib/clacky/extension/api_extension.rb +3 -2
  21. data/lib/clacky/message_format/anthropic.rb +27 -1
  22. data/lib/clacky/message_format/open_ai.rb +92 -4
  23. data/lib/clacky/providers.rb +67 -14
  24. data/lib/clacky/server/http_server.rb +361 -36
  25. data/lib/clacky/server/project_manager.rb +150 -0
  26. data/lib/clacky/server/server_master.rb +7 -0
  27. data/lib/clacky/server/session_registry.rb +14 -1
  28. data/lib/clacky/session_manager.rb +5 -4
  29. data/lib/clacky/tools/file_reader.rb +2 -1
  30. data/lib/clacky/utils/model_pricing.rb +77 -18
  31. data/lib/clacky/utils/parser_manager.rb +115 -7
  32. data/lib/clacky/version.rb +1 -1
  33. data/lib/clacky/web/app.css +1053 -143
  34. data/lib/clacky/web/app.js +19 -5
  35. data/lib/clacky/web/components/code-editor.js +50 -12
  36. data/lib/clacky/web/components/notify.js +139 -22
  37. data/lib/clacky/web/core/aside.js +21 -0
  38. data/lib/clacky/web/features/billing/view.js +2 -1
  39. data/lib/clacky/web/features/brand/store.js +3 -1
  40. data/lib/clacky/web/features/brand/view.js +29 -5
  41. data/lib/clacky/web/features/extensions/store.js +15 -6
  42. data/lib/clacky/web/features/extensions/view.js +8 -4
  43. data/lib/clacky/web/features/new-session/view.js +2 -2
  44. data/lib/clacky/web/features/trash/store.js +23 -9
  45. data/lib/clacky/web/features/trash/view.js +51 -0
  46. data/lib/clacky/web/features/workspace/store.js +9 -0
  47. data/lib/clacky/web/features/workspace/view.js +8 -1
  48. data/lib/clacky/web/i18n.js +99 -4
  49. data/lib/clacky/web/index.html +70 -17
  50. data/lib/clacky/web/projects.js +1143 -0
  51. data/lib/clacky/web/sessions.js +318 -537
  52. data/lib/clacky/web/settings.js +146 -7
  53. data/lib/clacky/web/ws-dispatcher.js +59 -1
  54. data/scripts/build/src/install_system_deps.sh.cc +11 -3
  55. data/scripts/install_system_deps.sh +11 -3
  56. metadata +5 -2
  57. data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
@@ -97,7 +97,11 @@ const Router = (() => {
97
97
  if (h === "mcp") return { view: "mcp", params: {} };
98
98
  if (h === "extensions") return { view: "extensions", params: {} };
99
99
  const mExtDetail = h.match(/^extensions\/(.+)$/);
100
- if (mExtDetail) return { view: "extensions", params: { detailId: mExtDetail[1] } };
100
+ if (mExtDetail) {
101
+ const [detailId, qs] = mExtDetail[1].split("?");
102
+ const source = new URLSearchParams(qs || "").get("source") || null;
103
+ return { view: "extensions", params: { detailId, source } };
104
+ }
101
105
  if (h === "trash") return { view: "trash", params: {} };
102
106
  if (h === "profile") return { view: "profile", params: {} };
103
107
  // Legacy: #memories redirects to #profile (memories are now merged into
@@ -256,8 +260,9 @@ const Router = (() => {
256
260
  case "extensions":
257
261
  $("extensions-panel").style.display = "flex";
258
262
  if (params.detailId) {
259
- _setHash(`extensions/${params.detailId}`);
260
- Extensions.onPanelShow({ detailId: params.detailId });
263
+ const sourceQs = params.source ? `?source=${encodeURIComponent(params.source)}` : "";
264
+ _setHash(`extensions/${params.detailId}${sourceQs}`);
265
+ Extensions.onPanelShow({ detailId: params.detailId, source: params.source });
261
266
  } else {
262
267
  _setHash("extensions");
263
268
  Extensions.onPanelShow();
@@ -501,11 +506,20 @@ const Modal = (() => {
501
506
  });
502
507
  }
503
508
 
504
- /** Show a rename dialog. Returns a Promise<string|null>. */
505
- function rename(currentName = "") {
509
+ /** Show a rename dialog. Returns a Promise<string|null>.
510
+ * @param {string} currentName - pre-filled value
511
+ * @param {string} [labelI18nKey] - i18n key for the field label (default: sessions.modal.name)
512
+ */
513
+ function rename(currentName = "", labelI18nKey = "sessions.modal.name") {
506
514
  return new Promise(resolve => {
507
515
  const overlay = $("rename-modal-overlay");
508
516
  const input = $("rename-modal-input");
517
+ // Update label text dynamically
518
+ const labelSpan = overlay.querySelector("label[for='rename-modal-input'] span[data-i18n]");
519
+ if (labelSpan) {
520
+ labelSpan.dataset.i18n = labelI18nKey;
521
+ labelSpan.textContent = I18n.t(labelI18nKey);
522
+ }
509
523
  input.value = currentName;
510
524
  input.classList.remove("input-error");
511
525
  // Re-attach to <body> end so it stacks above dynamically-appended overlays.
@@ -1,4 +1,4 @@
1
- /* global CM */
1
+ /* global CM, marked */
2
2
  /**
3
3
  * CodeEditor — a reusable wrapper around CodeMirror 6.
4
4
  *
@@ -31,16 +31,20 @@
31
31
  }
32
32
 
33
33
  function _detectLanguage(filename) {
34
- if (!filename) return "markdown";
34
+ if (!filename) return null;
35
35
  const ext = filename.split(".").pop().toLowerCase();
36
36
  const map = { md: "markdown", markdown: "markdown" };
37
- return map[ext] || "markdown";
37
+ return map[ext] || null;
38
38
  }
39
39
 
40
40
  function _isDark() {
41
41
  return document.documentElement.getAttribute("data-theme") === "dark";
42
42
  }
43
43
 
44
+ function _isMarkdown(language) {
45
+ return language === "markdown" || language === "md";
46
+ }
47
+
44
48
  function _buildExtensions(opts) {
45
49
  const extensions = [
46
50
  CM.lineNumbers(),
@@ -86,9 +90,24 @@
86
90
  }]));
87
91
  }
88
92
 
93
+ if (opts.onChange) {
94
+ extensions.push(CM.EditorView.updateListener.of((update) => {
95
+ if (update.docChanged) opts.onChange(update.state.doc.toString());
96
+ }));
97
+ }
98
+
89
99
  return extensions;
90
100
  }
91
101
 
102
+ function _renderMarkdownHtml(text) {
103
+ if (typeof marked !== "undefined") {
104
+ try {
105
+ return marked.parse(text, { breaks: true, gfm: true });
106
+ } catch (_) { /* fall through */ }
107
+ }
108
+ return `<pre>${text.replace(/</g, "&lt;")}</pre>`;
109
+ }
110
+
92
111
  function open(opts) {
93
112
  const {
94
113
  content = "",
@@ -101,6 +120,7 @@
101
120
 
102
121
  const kind = opts.kind || (opts.filename ? _fileKind(opts.filename) : "text");
103
122
  const language = opts.language || _detectLanguage(opts.filename);
123
+ const isMd = _isMarkdown(language) && kind !== "image";
104
124
 
105
125
  let overlay = document.getElementById("code-editor-overlay");
106
126
  if (overlay) overlay.remove();
@@ -110,8 +130,8 @@
110
130
  overlay.className = "modal-overlay";
111
131
 
112
132
  const cancelLabel = I18n.t("modal.cancel");
113
- const closeLabel = I18n.t("modal.close");
114
- const saveLabel = I18n.t("modal.save");
133
+ const closeLabel = I18n.t("modal.close");
134
+ const saveLabel = I18n.t("modal.save");
115
135
 
116
136
  const isReadOnlyOrImage = readOnly || kind === "image";
117
137
  const footerActions = isReadOnlyOrImage
@@ -119,12 +139,14 @@
119
139
  : `<button class="btn btn-secondary code-editor-cancel">${cancelLabel}</button><button class="btn btn-primary code-editor-save">${saveLabel}</button>`;
120
140
 
121
141
  overlay.innerHTML = `
122
- <div class="code-editor-modal${kind === "image" ? " code-editor-modal--image" : ""}">
142
+ <div class="code-editor-modal${kind === "image" ? " code-editor-modal--image" : ""}${isMd ? " code-editor-modal--md" : ""}">
123
143
  <div class="code-editor-header">
124
144
  <h3 class="code-editor-title"></h3>
125
145
  <button class="code-editor-close" title="${closeLabel}">&times;</button>
126
146
  </div>
127
- <div class="code-editor-body"></div>
147
+ <div class="code-editor-body${isMd ? " code-editor-body--split" : ""}">
148
+ ${isMd ? `<div class="code-editor-split-left"></div><div class="code-editor-split-divider"></div><div class="code-editor-split-right"><div class="code-editor-markdown-preview"></div></div>` : ""}
149
+ </div>
128
150
  <div class="code-editor-footer">
129
151
  <span class="code-editor-status"></span>
130
152
  <div class="code-editor-actions">${footerActions}</div>
@@ -134,8 +156,8 @@
134
156
  document.body.appendChild(overlay);
135
157
  overlay.querySelector(".code-editor-title").textContent = title;
136
158
 
137
- const body = overlay.querySelector(".code-editor-body");
138
- const status = overlay.querySelector(".code-editor-status");
159
+ const body = overlay.querySelector(".code-editor-body");
160
+ const status = overlay.querySelector(".code-editor-status");
139
161
  const closeBtn = overlay.querySelector(".code-editor-close");
140
162
  const cancelBtn = overlay.querySelector(".code-editor-cancel");
141
163
  const saveBtn = overlay.querySelector(".code-editor-save");
@@ -147,7 +169,6 @@
147
169
 
148
170
  closeBtn.addEventListener("click", close);
149
171
  if (cancelBtn) cancelBtn.addEventListener("click", close);
150
- overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
151
172
 
152
173
  if (kind === "image") {
153
174
  body.classList.add("code-editor-body--image");
@@ -159,7 +180,21 @@
159
180
  return { close };
160
181
  }
161
182
 
162
- const editorOpts = { language, readOnly, onSave: null, _getContent: null };
183
+ // Editor mount target: left pane for markdown split, body itself otherwise
184
+ const editorParent = isMd ? overlay.querySelector(".code-editor-split-left") : body;
185
+ const previewPane = isMd ? overlay.querySelector(".code-editor-markdown-preview") : null;
186
+
187
+ function updatePreview(text) {
188
+ if (previewPane) previewPane.innerHTML = _renderMarkdownHtml(text);
189
+ }
190
+
191
+ const editorOpts = {
192
+ language: language || "markdown",
193
+ readOnly,
194
+ onSave: null,
195
+ _getContent: null,
196
+ onChange: isMd ? updatePreview : null,
197
+ };
163
198
  const getContent = () => view.state.doc.toString();
164
199
  editorOpts._getContent = getContent;
165
200
  editorOpts.onSave = onSave ? () => doSave() : null;
@@ -169,9 +204,12 @@
169
204
  doc: content,
170
205
  extensions: _buildExtensions(editorOpts),
171
206
  }),
172
- parent: body,
207
+ parent: editorParent,
173
208
  });
174
209
 
210
+ // Render initial preview
211
+ if (isMd) updatePreview(content);
212
+
175
213
  async function doSave() {
176
214
  if (!onSave) return;
177
215
  if (saveBtn) saveBtn.disabled = true;
@@ -1,14 +1,15 @@
1
- // notify.js — Task-complete sound notification module
1
+ // notify.js — Task-complete sound + desktop notification module
2
2
  //
3
- // Plays a short sound when an agent task finishes, driven by the global
4
- // `task_finished` event the server broadcasts (broadcast_all) the moment a
5
- // task completes. We listen to this dedicated signal — rather than `complete`
6
- // (only delivered to subscribers of that session) — so a background session
7
- // finishing still reaches every browser. Whether a task *finished* is decided
8
- // on the backend; this module only decides whether the user is looking.
3
+ // Plays a short sound and (when permitted) shows a browser Notification when
4
+ // an agent task finishes, driven by the global `task_finished` event the
5
+ // server broadcasts (broadcast_all) the moment a task completes. We listen to
6
+ // this dedicated signal — rather than `complete` (only delivered to subscribers
7
+ // of that session) — so a background session finishing still reaches every
8
+ // browser. Whether a task *finished* is decided on the backend; this module
9
+ // only decides whether the user is looking.
9
10
  //
10
- // The chime only fires when the user is NOT actively looking at the
11
- // finished session. "Not looking" means ANY of:
11
+ // Alerts only fire when the user is NOT actively looking at the finished
12
+ // session. "Not looking" means ANY of:
12
13
  // 1. The finished session is not the currently open one
13
14
  // (sid !== Sessions.activeId)
14
15
  // 2. The browser window has lost focus (!document.hasFocus())
@@ -18,15 +19,20 @@
18
19
  // silent — they can already see the result.
19
20
  //
20
21
  // The feature is gated behind a header toggle (🔔/🔕) next to the theme
21
- // switcher. Default OFF; the choice is persisted to localStorage.
22
+ // switcher. Default OFF; the choice is persisted to localStorage. Enabling
23
+ // the toggle also requests browser Notification permission (must happen in
24
+ // the click gesture) so desktop toasts work when the tab is in the background.
22
25
  //
23
- // No history replay: a chime is a live cue, never re-fired on page refresh.
24
- // The audio file is served as a static asset (/notify.mp3) by WEBrick.
26
+ // No history replay: a chime/toast is a live cue, never re-fired on page
27
+ // refresh. The audio file is served as a static asset (/assets/notify.mp3).
25
28
  //
26
- // Depends on: Sessions (sessions.js) for activeId, I18n (i18n.js) for the
27
- // tooltip text. Both are optional — guarded with typeof checks.
29
+ // Depends on: Sessions (sessions.js) for activeId/find, Router (app.js) for
30
+ // focus-on-click navigation, I18n (i18n.js) for tooltip text.
31
+ // All are optional — guarded with typeof checks.
28
32
  // ─────────────────────────────────────────────────────────────────────────
29
33
  const Notify = (() => {
34
+ // Historical key name kept for existing installs; value now means
35
+ // "task alerts" (sound + desktop when permitted), not sound-only.
30
36
  const STORAGE_KEY = "clacky-notify-sound";
31
37
  const AUDIO_SRC = "/assets/notify.mp3";
32
38
 
@@ -41,13 +47,54 @@ const Notify = (() => {
41
47
  function setEnabled(on) {
42
48
  localStorage.setItem(STORAGE_KEY, on ? "on" : "off");
43
49
  _updateToggleIcon();
44
- // On enabling, "prime" the audio element within this user gesture so the
45
- // browser's autoplay policy lets later programmatic play() calls through.
46
- if (on) _prime();
47
50
  }
48
51
 
49
- function toggle() {
50
- setEnabled(!enabled());
52
+ async function toggle() {
53
+ const next = !enabled();
54
+ if (next) {
55
+ // Prime audio inside the click gesture first, then request permission.
56
+ // Awaiting permission before priming can drop the user-gesture token.
57
+ _prime();
58
+ const perm = await _ensurePermission();
59
+ setEnabled(true);
60
+ if (perm === "denied" && typeof Modal !== "undefined" && Modal.toast) {
61
+ Modal.toast(
62
+ (typeof I18n !== "undefined" && I18n.t("notify.toast.denied")) ||
63
+ "Sound alerts enabled. Desktop notifications are blocked in browser settings.",
64
+ "warning"
65
+ );
66
+ } else if (perm === "unsupported" && typeof Modal !== "undefined" && Modal.toast) {
67
+ Modal.toast(
68
+ (typeof I18n !== "undefined" && I18n.t("notify.toast.noDesktop")) ||
69
+ "Sound alerts enabled. This browser has no desktop notifications.",
70
+ "info"
71
+ );
72
+ }
73
+ } else {
74
+ setEnabled(false);
75
+ }
76
+ }
77
+
78
+ // ── Browser Notification permission ──────────────────────────────────
79
+ function _notificationsSupported() {
80
+ return typeof window !== "undefined" && "Notification" in window;
81
+ }
82
+
83
+ function permission() {
84
+ if (!_notificationsSupported()) return "unsupported";
85
+ return Notification.permission; // "granted" | "denied" | "default"
86
+ }
87
+
88
+ async function _ensurePermission() {
89
+ if (!_notificationsSupported()) return "unsupported";
90
+ if (Notification.permission === "granted") return "granted";
91
+ if (Notification.permission === "denied") return "denied";
92
+ try {
93
+ const result = await Notification.requestPermission();
94
+ return result;
95
+ } catch (_e) {
96
+ return Notification.permission;
97
+ }
51
98
  }
52
99
 
53
100
  // ── Audio ────────────────────────────────────────────────────────────
@@ -88,6 +135,46 @@ const Notify = (() => {
88
135
  } catch (_e) { /* ignore */ }
89
136
  }
90
137
 
138
+ // ── Desktop notification ─────────────────────────────────────────────
139
+ function _notifyDesktop(sid) {
140
+ if (!_notificationsSupported()) return;
141
+ if (Notification.permission !== "granted") return;
142
+
143
+ // Keep body generic on purpose — session names can be sensitive on shared
144
+ // screens. Click focuses the window and navigates to the session.
145
+ const title = typeof I18n !== "undefined"
146
+ ? I18n.t("notify.desktop.title")
147
+ : "Task complete";
148
+ const body = typeof I18n !== "undefined"
149
+ ? I18n.t("notify.desktop.body")
150
+ : "A background task finished. Click to open it.";
151
+
152
+ let n;
153
+ try {
154
+ n = new Notification(title, {
155
+ body,
156
+ tag: sid ? `clacky-task-${sid}` : "clacky-task",
157
+ renotify: true,
158
+ silent: true, // sound is handled by our own chime
159
+ });
160
+ } catch (_e) {
161
+ return;
162
+ }
163
+
164
+ n.onclick = () => {
165
+ try { window.focus(); } catch (_e) { /* ignore */ }
166
+ if (sid && typeof Router !== "undefined" && typeof Router.navigate === "function") {
167
+ Router.navigate("session", { id: sid });
168
+ }
169
+ try { n.close(); } catch (_e) { /* ignore */ }
170
+ };
171
+
172
+ // Auto-dismiss so toasts don't pile up forever.
173
+ setTimeout(() => {
174
+ try { n.close(); } catch (_e) { /* ignore */ }
175
+ }, 8000);
176
+ }
177
+
91
178
  // ── Trigger decision ───────────────────────────────────────────────────
92
179
  // Returns true when the user is NOT actively viewing the given session.
93
180
  function _userIsAway(sid) {
@@ -109,9 +196,19 @@ const Notify = (() => {
109
196
  if (!enabled()) return;
110
197
  if (!_userIsAway(sid)) return;
111
198
  _play();
199
+ _notifyDesktop(sid);
112
200
  }
113
201
 
114
202
  // ── Toggle button UI ───────────────────────────────────────────────────
203
+ function _tooltipKey(on) {
204
+ if (!on) return "notify.tooltip.off";
205
+ const perm = permission();
206
+ if (perm === "denied") return "notify.tooltip.onDenied";
207
+ if (perm === "unsupported") return "notify.tooltip.onNoDesktop";
208
+ if (perm === "default") return "notify.tooltip.onPending";
209
+ return "notify.tooltip.on";
210
+ }
211
+
115
212
  function _updateToggleIcon() {
116
213
  const btn = document.getElementById("notify-toggle-header");
117
214
  if (!btn) return;
@@ -130,9 +227,10 @@ const Notify = (() => {
130
227
  </svg>`;
131
228
  btn.classList.toggle("notify-on", on);
132
229
  if (typeof I18n !== "undefined") {
133
- const tip = I18n.t(on ? "notify.tooltip.on" : "notify.tooltip.off");
230
+ const tip = I18n.t(_tooltipKey(on));
134
231
  btn.title = tip;
135
232
  btn.setAttribute("aria-label", tip);
233
+ btn.setAttribute("data-i18n-title", _tooltipKey(on));
136
234
  }
137
235
  }
138
236
 
@@ -140,10 +238,29 @@ const Notify = (() => {
140
238
  function init() {
141
239
  _updateToggleIcon();
142
240
  const btn = document.getElementById("notify-toggle-header");
143
- if (btn) btn.addEventListener("click", toggle);
241
+ if (btn) {
242
+ btn.addEventListener("click", () => {
243
+ // Fire-and-forget; permission prompt is awaited inside toggle.
244
+ toggle().then(() => _updateToggleIcon()).catch(() => _updateToggleIcon());
245
+ });
246
+ }
247
+ // Permission may change outside the page (browser settings); refresh tip.
248
+ document.addEventListener("visibilitychange", () => {
249
+ if (!document.hidden) _updateToggleIcon();
250
+ });
251
+ if (typeof I18n !== "undefined") {
252
+ document.addEventListener("langchange", () => _updateToggleIcon());
253
+ }
144
254
  }
145
255
 
146
- return { init, toggle, enabled, setEnabled, onTaskFinished };
256
+ return {
257
+ init,
258
+ toggle,
259
+ enabled,
260
+ setEnabled,
261
+ permission,
262
+ onTaskFinished,
263
+ };
147
264
  })();
148
265
 
149
266
  Clacky.Notify = Notify;
@@ -42,6 +42,13 @@
42
42
  if (overlay) overlay.classList.toggle("active", open && !empty);
43
43
  }
44
44
 
45
+ function isOpen() {
46
+ try {
47
+ const stored = localStorage.getItem(OPEN_KEY);
48
+ return stored !== null && stored !== "0";
49
+ } catch (_e) { return false; }
50
+ }
51
+
45
52
  function setOpen(open) {
46
53
  try { localStorage.setItem(OPEN_KEY, open ? "1" : "0"); } catch (_e) { /* ignore */ }
47
54
  applyOpenState();
@@ -91,7 +98,21 @@
91
98
  });
92
99
  }
93
100
 
101
+ // ── Public API ────────────────────────────────────────────────────────
102
+ // Expose setOpen so external code (e.g. WS dispatcher) can open/close the
103
+ // aside panel programmatically without duplicating localStorage logic here.
104
+ // open() is a no-op when the panel is already open — safe to call unconditionally.
105
+ if (window.Clacky) Clacky.Aside = {
106
+ open: () => { if (!isOpen()) setOpen(true); },
107
+ close: () => setOpen(false),
108
+ };
109
+
94
110
  function init() {
111
+ // 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
+ };
95
116
  const collapse = $("btn-aside-collapse");
96
117
  const opener = $("btn-aside-open");
97
118
  const overlay = $("workspace-overlay");
@@ -138,7 +138,8 @@ const BillingView = (() => {
138
138
  <div class="billing-period-group">${periodBtns}</div>
139
139
  <select id="billing-model-filter" class="billing-model-filter">${modelOptions}</select>
140
140
  <button id="billing-share-btn" class="billing-share-btn" title="${I18n.t('billing.share.tooltip') || 'Share scorecard'}">
141
- 📤 ${I18n.t('billing.share.btn') || 'Share scorecard'}
141
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><polyline points="16 6 12 2 8 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="12" y1="2" x2="12" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
142
+ ${I18n.t('billing.share.btn') || 'Share scorecard'}
142
143
  </button>
143
144
  <div class="billing-clear-container">
144
145
  <button id="billing-clear-btn" class="billing-clear-btn" title="${I18n.t('billing.clearData') || 'Clear Data'}">
@@ -76,7 +76,7 @@ const BrandStore = (() => {
76
76
  return false;
77
77
  },
78
78
 
79
- // Re-fetch status to refresh flags only (no UI boot driving).
79
+ // Re-fetch status and notify views without driving the main boot sequence.
80
80
  async refresh() {
81
81
  try {
82
82
  const res = await fetch("/api/brand/status");
@@ -84,8 +84,10 @@ const BrandStore = (() => {
84
84
  _testMode = !!data.test_mode;
85
85
  _userLicensed = !!data.user_licensed;
86
86
  _branded = !!data.branded;
87
+ _emit("brand:status", data);
87
88
  return data;
88
89
  } catch (_) {
90
+ _emit("brand:status", null);
89
91
  return null;
90
92
  }
91
93
  },
@@ -15,7 +15,22 @@
15
15
  const BrandView = (() => {
16
16
 
17
17
  function _onStatus(data) {
18
- if (!data || !data.branded) return;
18
+ if (!data) return;
19
+ if (!data.branded) {
20
+ if (data.distribution_refresh_pending) {
21
+ _scheduleDistributionRefreshPoll();
22
+ return;
23
+ }
24
+ _applyBrandName("OpenClacky");
25
+ Brand.clearBrandCache();
26
+ _applyHeaderLogo();
27
+ const banner = document.getElementById("brand-activation-banner");
28
+ if (banner) banner.remove();
29
+ if (typeof Settings !== "undefined" && Settings.loadBrand) Settings.loadBrand();
30
+ return;
31
+ }
32
+
33
+ if (data.product_name) _applyBrandName(data.product_name);
19
34
 
20
35
  if (data.needs_activation) {
21
36
  if (data.theme_color) Settings.applyAccentColor(data.theme_color, { persist: true });
@@ -268,6 +283,7 @@ const BrandView = (() => {
268
283
  logoImg.alt = "OpenClacky";
269
284
  logoImg.style.display = "";
270
285
  if (brandWrap) brandWrap.classList.add("has-logo");
286
+ _applyFavicon("/favicon.svg");
271
287
  }
272
288
 
273
289
  function _applyFavicon(url) {
@@ -333,11 +349,19 @@ const BrandView = (() => {
333
349
  let attempt = 0;
334
350
 
335
351
  const poll = () => {
336
- Brand.clearBrandCache();
337
- Brand.fetchInfo().then(info => {
338
- const hasFullBrand = !!(info && info.logo_url && info.theme_color);
352
+ Brand.refresh().then(status => {
353
+ Brand.clearBrandCache();
354
+ return Brand.fetchInfo().then(info => ({ status, info }));
355
+ }).then(({ status, info }) => {
356
+ if (info && info.product_name) _applyBrandName(info.product_name);
339
357
  _applyHeaderLogo();
340
- if (hasFullBrand || attempt >= delays.length) {
358
+ const refreshSettled = !!(
359
+ status && status.branded && !status.distribution_refresh_pending
360
+ );
361
+ if (refreshSettled && typeof Settings !== "undefined" && Settings.loadBrand) {
362
+ Settings.loadBrand();
363
+ }
364
+ if (refreshSettled || attempt >= delays.length) {
341
365
  _distRefreshPolling = false;
342
366
  return;
343
367
  }
@@ -178,14 +178,15 @@ const ExtensionsStore = (() => {
178
178
  },
179
179
 
180
180
  /** Open the detail view for one extension (fetches contributes + versions). */
181
- async loadDetail(id) {
181
+ async loadDetail(id, source) {
182
182
  if (!id) return;
183
183
  _detail = null;
184
184
  _detailLoading = true;
185
185
  _detailError = null;
186
186
  _emit("extensions:detail");
187
187
  try {
188
- const url = _filterBrand
188
+ const isBrand = source === "brand" || _filterBrand;
189
+ const url = isBrand
189
190
  ? "/api/store/extension?id=" + encodeURIComponent(id) + "&source=brand"
190
191
  : "/api/store/extension?id=" + encodeURIComponent(id);
191
192
  const res = await fetch(url);
@@ -250,10 +251,18 @@ const ExtensionsStore = (() => {
250
251
  async install(id) {
251
252
  if (!id) return;
252
253
  try {
253
- const detailRes = await fetch("/api/store/extension?id=" + encodeURIComponent(id));
254
- const detailData = await detailRes.json();
255
- if (!detailRes.ok || !detailData.ok) throw new Error(detailData.error || "fetch detail failed");
256
- const ext = detailData.extension;
254
+ // Prefer the already-loaded detail (avoids a second round-trip and correctly
255
+ // handles brand-private extensions whose detail was fetched with &source=brand).
256
+ let ext;
257
+ if (_detail && String(_detail.id) === String(id)) {
258
+ ext = _detail;
259
+ } else {
260
+ const source = _filterBrand ? "&source=brand" : "";
261
+ const detailRes = await fetch("/api/store/extension?id=" + encodeURIComponent(id) + source);
262
+ const detailData = await detailRes.json();
263
+ if (!detailRes.ok || !detailData.ok) throw new Error(detailData.error || "fetch detail failed");
264
+ ext = detailData.extension;
265
+ }
257
266
  const download_url = ext.download_url;
258
267
  if (!download_url) throw new Error("No download URL available");
259
268
  const res = await fetch("/api/store/extension/install", {
@@ -121,7 +121,8 @@ const ExtensionsView = (() => {
121
121
 
122
122
  const card = document.createElement("div");
123
123
  card.className = "extension-card extension-card-clickable";
124
- card.dataset.extId = ext.id != null ? String(ext.id) : (ext.name || "");
124
+ card.dataset.extId = ext.id != null ? String(ext.id) : (ext.name || "");
125
+ card.dataset.extOrigin = ext.origin || "";
125
126
  card.innerHTML = `
126
127
  <div class="extension-card-main">
127
128
  ${emojiHtml}
@@ -474,8 +475,11 @@ const ExtensionsView = (() => {
474
475
  const card = e.target.closest(".extension-card-clickable");
475
476
  if (card && card.dataset.extId) {
476
477
  const router = window.Clacky && window.Clacky.Router;
477
- if (router) router.navigate("extensions", { detailId: card.dataset.extId });
478
- else Extensions.loadDetail(card.dataset.extId);
478
+ // Use origin stored on the card; origin="self" means brand-private extension.
479
+ const isBrand = card.dataset.extOrigin === "self" || Extensions.state.filterBrand;
480
+ const source = isBrand ? "brand" : null;
481
+ if (router) router.navigate("extensions", { detailId: card.dataset.extId, source });
482
+ else Extensions.loadDetail(card.dataset.extId, source);
479
483
  }
480
484
  });
481
485
  }
@@ -506,7 +510,7 @@ const ExtensionsView = (() => {
506
510
  const detailId = opts && opts.detailId;
507
511
  if (detailId) {
508
512
  Extensions.load();
509
- Extensions.loadDetail(detailId);
513
+ Extensions.loadDetail(detailId, opts && opts.source);
510
514
  } else {
511
515
  Extensions.closeDetail();
512
516
  Extensions.load();
@@ -306,7 +306,7 @@ const NewSessionView = (() => {
306
306
  const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
307
307
  const MAX_IMAGE_BYTES_SEND = 512 * 1024;
308
308
  const MAX_IMAGE_LONG_EDGE = 1920;
309
- const MAX_FILE_BYTES = 32 * 1024 * 1024;
309
+ const MAX_FILE_BYTES = 300 * 1024 * 1024;
310
310
  const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
311
311
 
312
312
  function _docTypeIcon(mimeType, filename) {
@@ -390,7 +390,7 @@ const NewSessionView = (() => {
390
390
 
391
391
  function _addGenericFile(file) {
392
392
  if (file.size > MAX_FILE_BYTES) {
393
- alert(I18n.t("chat.file.tooLarge", { name: file.name, max: "32 MB" }));
393
+ alert(I18n.t("chat.file.tooLarge", { name: file.name, max: "300 MB" }));
394
394
  return;
395
395
  }
396
396
  const formData = new FormData();