openclacky 1.5.13 → 1.5.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +38 -97
- data/lib/clacky/agent.rb +281 -276
- data/lib/clacky/agent_config.rb +1 -1
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +6 -0
- data/lib/clacky/brand_config.rb +13 -2
- data/lib/clacky/cli.rb +27 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +38 -3
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +8 -5
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +161 -14
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +79 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
- data/lib/clacky/ui2/components/input_area.rb +22 -2
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/ui_controller.rb +44 -7
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +114 -66
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +596 -130
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/model-picker.js +2 -1
- data/lib/clacky/web/components/quote-select.js +235 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +392 -40
- data/lib/clacky/web/i18n.js +90 -5
- data/lib/clacky/web/index.html +15 -6
- data/lib/clacky/web/sessions.js +193 -38
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +20 -0
- data/lib/clacky.rb +2 -0
- metadata +11 -1
|
@@ -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.
|
|
@@ -290,6 +290,37 @@ const BillingView = (() => {
|
|
|
290
290
|
|
|
291
291
|
// ── Tooltips ─────────────────────────────────────────────────────────────────
|
|
292
292
|
|
|
293
|
+
// Measured while hidden so the real size is known before choosing a side.
|
|
294
|
+
// The clamp matters on narrow viewports: flipping a 200px-wide tooltip left of
|
|
295
|
+
// a cursor near the left edge would otherwise push it off-screen.
|
|
296
|
+
//
|
|
297
|
+
// Below MOBILE_TOOLTIP_MAX_WIDTH the tooltip is as wide as 58%-73% of the chart,
|
|
298
|
+
// so following the pointer covers the data no matter which side it flips to.
|
|
299
|
+
// There it docks just outside `chart` instead, keeping the plot fully visible.
|
|
300
|
+
const MOBILE_TOOLTIP_MAX_WIDTH = 768;
|
|
301
|
+
|
|
302
|
+
function _placeTooltip(tooltip, e, chart) {
|
|
303
|
+
tooltip.style.display = "block";
|
|
304
|
+
tooltip.style.visibility = "hidden";
|
|
305
|
+
const rect = tooltip.getBoundingClientRect();
|
|
306
|
+
|
|
307
|
+
if (chart && window.innerWidth <= MOBILE_TOOLTIP_MAX_WIDTH) {
|
|
308
|
+
const chartRect = chart.getBoundingClientRect();
|
|
309
|
+
const below = chartRect.bottom + 8;
|
|
310
|
+
const top = below + rect.height > window.innerHeight ? chartRect.top - 8 - rect.height : below;
|
|
311
|
+
tooltip.style.left = `${Math.max(8, Math.min(chartRect.left, window.innerWidth - 8 - rect.width))}px`;
|
|
312
|
+
tooltip.style.top = `${Math.max(8, top)}px`;
|
|
313
|
+
tooltip.style.visibility = "visible";
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const ovfX = e.clientX + 15 + rect.width - window.innerWidth;
|
|
318
|
+
const ovfY = e.clientY - 10 + rect.height - window.innerHeight;
|
|
319
|
+
tooltip.style.left = ovfX > 0 ? `${Math.max(8, e.clientX - 15 - rect.width)}px` : `${e.clientX + 15}px`;
|
|
320
|
+
tooltip.style.top = ovfY > 0 ? `${Math.max(8, e.clientY - 10 - rect.height)}px` : `${e.clientY - 10}px`;
|
|
321
|
+
tooltip.style.visibility = "visible";
|
|
322
|
+
}
|
|
323
|
+
|
|
293
324
|
function _bindChartTooltip() {
|
|
294
325
|
const container = document.getElementById("billing-chart-container");
|
|
295
326
|
const tooltip = document.getElementById("billing-tooltip");
|
|
@@ -334,14 +365,7 @@ const BillingView = (() => {
|
|
|
334
365
|
<span class="tooltip-value">${output}</span>
|
|
335
366
|
</div>
|
|
336
367
|
`;
|
|
337
|
-
tooltip
|
|
338
|
-
tooltip.style.visibility = "hidden";
|
|
339
|
-
const rect = tooltip.getBoundingClientRect();
|
|
340
|
-
const ovfX = e.clientX + 15 + rect.width - window.innerWidth;
|
|
341
|
-
const ovfY = e.clientY - 10 + rect.height - window.innerHeight;
|
|
342
|
-
tooltip.style.left = ovfX > 0 ? `${e.clientX - 15 - rect.width}px` : `${e.clientX + 15}px`;
|
|
343
|
-
tooltip.style.top = ovfY > 0 ? `${e.clientY - 10 - rect.height}px` : `${e.clientY - 10}px`;
|
|
344
|
-
tooltip.style.visibility = "visible";
|
|
368
|
+
_placeTooltip(tooltip, e, container);
|
|
345
369
|
});
|
|
346
370
|
|
|
347
371
|
container.addEventListener("mouseleave", () => {
|
|
@@ -376,27 +400,59 @@ const BillingView = (() => {
|
|
|
376
400
|
<span class="tooltip-value">${cell.dataset.cost}</span>
|
|
377
401
|
</div>
|
|
378
402
|
`;
|
|
379
|
-
tooltip
|
|
380
|
-
tooltip.style.left = `${e.clientX + 15}px`;
|
|
381
|
-
tooltip.style.top = `${e.clientY - 10}px`;
|
|
403
|
+
_placeTooltip(tooltip, e, grid);
|
|
382
404
|
});
|
|
383
405
|
|
|
384
406
|
grid.addEventListener("mouseleave", () => {
|
|
385
407
|
tooltip.style.display = "none";
|
|
408
|
+
tooltip.style.visibility = "";
|
|
386
409
|
});
|
|
387
410
|
}
|
|
388
411
|
|
|
412
|
+
// The dots are r="3" in viewBox units, which the responsive SVG scales down to
|
|
413
|
+
// under 5px on a phone — far too small to hit. Snap to the nearest point on the
|
|
414
|
+
// x axis instead so the whole column width is clickable.
|
|
415
|
+
function _nearestTrendDot(svg, clientX, clientY) {
|
|
416
|
+
const count = parseInt(svg.dataset.count, 10);
|
|
417
|
+
const xStep = parseFloat(svg.dataset.xStep);
|
|
418
|
+
const plotLeft = parseFloat(svg.dataset.plotLeft);
|
|
419
|
+
if (!count || !xStep) return null;
|
|
420
|
+
const ctm = svg.getScreenCTM();
|
|
421
|
+
if (!ctm) return null;
|
|
422
|
+
const point = svg.createSVGPoint();
|
|
423
|
+
point.x = clientX;
|
|
424
|
+
point.y = clientY;
|
|
425
|
+
const x = point.matrixTransform(ctm.inverse()).x;
|
|
426
|
+
const index = Math.max(0, Math.min(count - 1, Math.round((x - plotLeft) / xStep)));
|
|
427
|
+
return svg.querySelector(`.billing-trend-dot[data-index="${index}"]`);
|
|
428
|
+
}
|
|
429
|
+
|
|
389
430
|
function _bindTrendTooltip() {
|
|
390
431
|
const svg = document.querySelector(".billing-trend-svg");
|
|
391
432
|
const tooltip = document.getElementById("billing-tooltip");
|
|
392
433
|
if (!svg || !tooltip) return;
|
|
393
434
|
|
|
435
|
+
const crosshairX = svg.querySelector(".billing-trend-crosshair-x");
|
|
436
|
+
const crosshairY = svg.querySelector(".billing-trend-crosshair-y");
|
|
437
|
+
|
|
394
438
|
svg.addEventListener("mousemove", (e) => {
|
|
395
|
-
const dot = e.
|
|
439
|
+
const dot = _nearestTrendDot(svg, e.clientX, e.clientY);
|
|
396
440
|
if (!dot) {
|
|
397
441
|
tooltip.style.display = "none";
|
|
398
442
|
return;
|
|
399
443
|
}
|
|
444
|
+
svg.querySelectorAll(".billing-trend-dot.is-active").forEach(d => d.classList.remove("is-active"));
|
|
445
|
+
dot.classList.add("is-active");
|
|
446
|
+
if (crosshairX && crosshairY) {
|
|
447
|
+
const cx = dot.getAttribute("cx");
|
|
448
|
+
const cy = dot.getAttribute("cy");
|
|
449
|
+
crosshairX.setAttribute("x1", cx);
|
|
450
|
+
crosshairX.setAttribute("x2", cx);
|
|
451
|
+
crosshairY.setAttribute("y1", cy);
|
|
452
|
+
crosshairY.setAttribute("y2", cy);
|
|
453
|
+
crosshairX.classList.add("is-visible");
|
|
454
|
+
crosshairY.classList.add("is-visible");
|
|
455
|
+
}
|
|
400
456
|
tooltip.innerHTML = `
|
|
401
457
|
<div class="tooltip-header">
|
|
402
458
|
<span class="tooltip-date">${dot.dataset.date}</span>
|
|
@@ -406,18 +462,14 @@ const BillingView = (() => {
|
|
|
406
462
|
<span class="tooltip-value">${dot.dataset.cost}</span>
|
|
407
463
|
</div>
|
|
408
464
|
`;
|
|
409
|
-
tooltip
|
|
410
|
-
tooltip.style.visibility = "hidden";
|
|
411
|
-
const rect = tooltip.getBoundingClientRect();
|
|
412
|
-
const ovf = e.clientX + 15 + rect.width - window.innerWidth;
|
|
413
|
-
tooltip.style.left = ovf > 0 ? `${e.clientX - 15 - rect.width}px` : `${e.clientX + 15}px`;
|
|
414
|
-
tooltip.style.top = `${e.clientY - 10}px`;
|
|
415
|
-
tooltip.style.visibility = "visible";
|
|
465
|
+
_placeTooltip(tooltip, e, svg);
|
|
416
466
|
});
|
|
417
467
|
|
|
418
468
|
svg.addEventListener("mouseleave", () => {
|
|
419
469
|
tooltip.style.display = "none";
|
|
420
470
|
tooltip.style.visibility = "";
|
|
471
|
+
svg.querySelectorAll(".billing-trend-dot.is-active").forEach(d => d.classList.remove("is-active"));
|
|
472
|
+
svg.querySelectorAll(".billing-trend-crosshair").forEach(l => l.classList.remove("is-visible"));
|
|
421
473
|
});
|
|
422
474
|
}
|
|
423
475
|
|
|
@@ -641,7 +693,7 @@ const BillingView = (() => {
|
|
|
641
693
|
<h4>${I18n.t("billing.costTrend") || "Cost Trend"}</h4>
|
|
642
694
|
</div>
|
|
643
695
|
<div class="billing-trend-chart">
|
|
644
|
-
<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" class="billing-trend-svg">
|
|
696
|
+
<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" class="billing-trend-svg" data-plot-left="${pad.left}" data-x-step="${xStep}" data-count="${costs.length}">
|
|
645
697
|
${yLabels.map(l => `
|
|
646
698
|
<line x1="${pad.left}" y1="${l.y}" x2="${w - pad.right}" y2="${l.y}" class="billing-trend-grid-line" />
|
|
647
699
|
<text x="${pad.left - 6}" y="${l.y + 4}" class="billing-trend-y-label">${currencySymbol}${_formatCost(l.val)}</text>
|
|
@@ -656,11 +708,13 @@ const BillingView = (() => {
|
|
|
656
708
|
</linearGradient>
|
|
657
709
|
</defs>
|
|
658
710
|
<polygon points="${areaPoints}" fill="url(#billing-trend-grad)" class="billing-trend-area" />
|
|
711
|
+
<line class="billing-trend-crosshair billing-trend-crosshair-x" x1="0" y1="${pad.top}" x2="0" y2="${pad.top + plotH}" />
|
|
712
|
+
<line class="billing-trend-crosshair billing-trend-crosshair-y" x1="${pad.left}" y1="0" x2="${w - pad.right}" y2="0" />
|
|
659
713
|
<polyline points="${points}" fill="none" class="billing-trend-line" />
|
|
660
714
|
${costs.map((c, i) => {
|
|
661
715
|
const cx = pad.left + i * xStep;
|
|
662
716
|
const cy = pad.top + plotH - ((c - minCost) / range) * plotH;
|
|
663
|
-
return `<circle cx="${cx}" cy="${cy}" r="3" class="billing-trend-dot" data-date="${days[i].date}" data-cost="${currencySymbol}${_formatCost(c)}" />`;
|
|
717
|
+
return `<circle cx="${cx}" cy="${cy}" r="3" class="billing-trend-dot" data-index="${i}" data-date="${days[i].date}" data-cost="${currencySymbol}${_formatCost(c)}" />`;
|
|
664
718
|
}).join("")}
|
|
665
719
|
</svg>
|
|
666
720
|
</div>
|
|
@@ -262,7 +262,7 @@ const SkillsStore = (() => {
|
|
|
262
262
|
const res = await fetch(`/api/skills/${encodeURIComponent(name)}/content`);
|
|
263
263
|
const data = await res.json();
|
|
264
264
|
if (!data.ok) throw new Error(data.error || "Load failed");
|
|
265
|
-
return { ok: true, content: data.content || "", path: data.path };
|
|
265
|
+
return { ok: true, content: data.content || "", path: data.path, fields: data.fields };
|
|
266
266
|
} catch (e) {
|
|
267
267
|
return { ok: false, error: e.message };
|
|
268
268
|
}
|
|
@@ -285,6 +285,23 @@ const SkillsStore = (() => {
|
|
|
285
285
|
}
|
|
286
286
|
},
|
|
287
287
|
|
|
288
|
+
/** Update a custom skill's editable fields (name/name_zh/description/description_zh/body). */
|
|
289
|
+
async updateSkillFields(name, fields) {
|
|
290
|
+
try {
|
|
291
|
+
const res = await fetch(`/api/skills/${encodeURIComponent(name)}/content`, {
|
|
292
|
+
method: "PUT",
|
|
293
|
+
headers: { "Content-Type": "application/json" },
|
|
294
|
+
body: JSON.stringify({ fields })
|
|
295
|
+
});
|
|
296
|
+
const data = await res.json();
|
|
297
|
+
if (!res.ok || !data.ok) throw new Error(data.error || "Save failed");
|
|
298
|
+
await Skills.load();
|
|
299
|
+
return { ok: true };
|
|
300
|
+
} catch (e) {
|
|
301
|
+
return { ok: false, error: e.message };
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
|
|
288
305
|
/** Import a skill: validate url/path, open a session, run /skill-add. */
|
|
289
306
|
async importSkill(url) {
|
|
290
307
|
const trimmed = (url || "").trim();
|
|
@@ -596,16 +596,137 @@ const SkillsView = (() => {
|
|
|
596
596
|
async function _editSkill(skill) {
|
|
597
597
|
const res = await Skills.fetchSkillContent(skill.name);
|
|
598
598
|
if (!res.ok) { alert(I18n.t("skills.editFail") + ": " + res.error); return; }
|
|
599
|
+
_openSkillFormEditor(skill, res);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function _openSkillFormEditor(skill, res) {
|
|
603
|
+
const f = res.fields || {};
|
|
604
|
+
|
|
605
|
+
let overlay = document.getElementById("skill-editor-overlay");
|
|
606
|
+
if (overlay) overlay.remove();
|
|
607
|
+
|
|
608
|
+
overlay = document.createElement("div");
|
|
609
|
+
overlay.id = "skill-editor-overlay";
|
|
610
|
+
overlay.className = "modal-overlay";
|
|
611
|
+
|
|
612
|
+
const cancelLabel = I18n.t("modal.cancel");
|
|
613
|
+
const saveLabel = I18n.t("modal.save");
|
|
614
|
+
|
|
615
|
+
overlay.innerHTML = `
|
|
616
|
+
<div class="modal-box skill-editor-box">
|
|
617
|
+
<div class="modal-header">
|
|
618
|
+
<h3 class="modal-title"></h3>
|
|
619
|
+
<button class="modal-close-btn" title="${cancelLabel}"></button>
|
|
620
|
+
</div>
|
|
621
|
+
<div class="modal-body">
|
|
622
|
+
<div class="modal-field">
|
|
623
|
+
<label class="modal-label" for="se-name">${I18n.t("skills.edit.name")}</label>
|
|
624
|
+
<input type="text" id="se-name" class="modal-input skill-editor-name">
|
|
625
|
+
<span class="form-hint">${I18n.t("skills.edit.nameHint")}</span>
|
|
626
|
+
</div>
|
|
627
|
+
<div class="modal-field">
|
|
628
|
+
<label class="modal-label" for="se-name-zh">${I18n.t("skills.edit.nameZh")}</label>
|
|
629
|
+
<input type="text" id="se-name-zh" class="modal-input skill-editor-name-zh">
|
|
630
|
+
</div>
|
|
631
|
+
<div class="modal-field">
|
|
632
|
+
<label class="modal-label" for="se-desc">${I18n.t("skills.edit.description")}</label>
|
|
633
|
+
<textarea id="se-desc" class="modal-input skill-editor-description" rows="2"></textarea>
|
|
634
|
+
</div>
|
|
635
|
+
<div class="modal-field">
|
|
636
|
+
<label class="modal-label" for="se-desc-zh">${I18n.t("skills.edit.descriptionZh")}</label>
|
|
637
|
+
<textarea id="se-desc-zh" class="modal-input skill-editor-description-zh" rows="2"></textarea>
|
|
638
|
+
</div>
|
|
639
|
+
<div class="modal-field">
|
|
640
|
+
<label class="modal-label" for="se-body">${I18n.t("skills.edit.body")}</label>
|
|
641
|
+
<textarea id="se-body" class="modal-input skill-editor-body-input" rows="10"></textarea>
|
|
642
|
+
</div>
|
|
643
|
+
</div>
|
|
644
|
+
<div class="modal-footer">
|
|
645
|
+
<span class="skill-editor-status"></span>
|
|
646
|
+
<button class="btn-secondary skill-editor-cancel">${cancelLabel}</button>
|
|
647
|
+
<button class="btn-secondary skill-editor-markdown">${I18n.t("skills.edit.editMarkdown")}</button>
|
|
648
|
+
<button class="btn-primary skill-editor-save">${saveLabel}</button>
|
|
649
|
+
</div>
|
|
650
|
+
</div>`;
|
|
651
|
+
|
|
652
|
+
document.body.appendChild(overlay);
|
|
653
|
+
overlay.querySelector(".modal-title").textContent = I18n.t("skills.edit.title") + ": " + skill.name;
|
|
654
|
+
|
|
655
|
+
const nameInput = overlay.querySelector(".skill-editor-name");
|
|
656
|
+
const nameZhInput = overlay.querySelector(".skill-editor-name-zh");
|
|
657
|
+
const descInput = overlay.querySelector(".skill-editor-description");
|
|
658
|
+
const descZhInput = overlay.querySelector(".skill-editor-description-zh");
|
|
659
|
+
const bodyInput = overlay.querySelector(".skill-editor-body-input");
|
|
660
|
+
const status = overlay.querySelector(".skill-editor-status");
|
|
661
|
+
const closeBtn = overlay.querySelector(".modal-close-btn");
|
|
662
|
+
const cancelBtn = overlay.querySelector(".skill-editor-cancel");
|
|
663
|
+
const markdownBtn = overlay.querySelector(".skill-editor-markdown");
|
|
664
|
+
const saveBtn = overlay.querySelector(".skill-editor-save");
|
|
665
|
+
|
|
666
|
+
nameInput.value = f.name || skill.name || "";
|
|
667
|
+
nameZhInput.value = f.name_zh || "";
|
|
668
|
+
descInput.value = f.description || "";
|
|
669
|
+
descZhInput.value = f.description_zh || "";
|
|
670
|
+
bodyInput.value = f.body || "";
|
|
671
|
+
|
|
672
|
+
// Auto-grow textareas so the modal body is the only scroll container.
|
|
673
|
+
[descInput, descZhInput, bodyInput].forEach((ta) => {
|
|
674
|
+
const autoResize = () => {
|
|
675
|
+
ta.style.height = "auto";
|
|
676
|
+
ta.style.height = ta.scrollHeight + "px";
|
|
677
|
+
};
|
|
678
|
+
ta.addEventListener("input", autoResize);
|
|
679
|
+
autoResize();
|
|
680
|
+
});
|
|
599
681
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
682
|
+
function close() {
|
|
683
|
+
overlay.remove();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
closeBtn.addEventListener("click", close);
|
|
687
|
+
cancelBtn.addEventListener("click", close);
|
|
688
|
+
|
|
689
|
+
markdownBtn.addEventListener("click", () => {
|
|
690
|
+
close();
|
|
691
|
+
CodeEditor.open({
|
|
692
|
+
content: res.content,
|
|
693
|
+
title: skill.name,
|
|
694
|
+
language: "markdown",
|
|
695
|
+
onSave: async (newContent) => {
|
|
696
|
+
const r = await Skills.updateSkillContent(skill.name, newContent);
|
|
697
|
+
if (!r.ok) throw new Error(r.error);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
saveBtn.addEventListener("click", async () => {
|
|
703
|
+
const name = nameInput.value.trim();
|
|
704
|
+
if (!name) {
|
|
705
|
+
nameInput.classList.add("input-error");
|
|
706
|
+
nameInput.focus();
|
|
707
|
+
return;
|
|
607
708
|
}
|
|
709
|
+
|
|
710
|
+
const fields = {
|
|
711
|
+
name: name,
|
|
712
|
+
name_zh: nameZhInput.value.trim(),
|
|
713
|
+
description: descInput.value.trim(),
|
|
714
|
+
description_zh: descZhInput.value.trim(),
|
|
715
|
+
body: bodyInput.value
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
saveBtn.disabled = true;
|
|
719
|
+
status.textContent = I18n.t("modal.saving");
|
|
720
|
+
status.className = "skill-editor-status";
|
|
721
|
+
const r = await Skills.updateSkillFields(skill.name, fields);
|
|
722
|
+
if (r.ok) { close(); return; }
|
|
723
|
+
|
|
724
|
+
status.textContent = I18n.t("skills.edit.saveFail") + (r.error ? ": " + r.error : "");
|
|
725
|
+
status.className = "skill-editor-status skill-editor-status-error";
|
|
726
|
+
saveBtn.disabled = false;
|
|
608
727
|
});
|
|
728
|
+
|
|
729
|
+
setTimeout(() => nameInput.focus(), 50);
|
|
609
730
|
}
|
|
610
731
|
|
|
611
732
|
async function _doImportFromBar() {
|