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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -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 +38 -97
  6. data/lib/clacky/agent.rb +281 -276
  7. data/lib/clacky/agent_config.rb +1 -1
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +6 -0
  10. data/lib/clacky/brand_config.rb +13 -2
  11. data/lib/clacky/cli.rb +27 -0
  12. data/lib/clacky/client.rb +2 -2
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  14. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  16. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  17. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  18. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  19. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  20. data/lib/clacky/extension/api_extension.rb +1 -1
  21. data/lib/clacky/extension/verifier.rb +1 -1
  22. data/lib/clacky/media/openai_compat.rb +70 -30
  23. data/lib/clacky/message_format/bedrock.rb +6 -1
  24. data/lib/clacky/prompts/base.md +1 -1
  25. data/lib/clacky/providers.rb +38 -3
  26. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  27. data/lib/clacky/search_config.rb +3 -3
  28. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  29. data/lib/clacky/server/git_panel.rb +10 -2
  30. data/lib/clacky/server/http_server.rb +161 -14
  31. data/lib/clacky/server/preview.rb +351 -0
  32. data/lib/clacky/server/web_ui_controller.rb +1 -1
  33. data/lib/clacky/skill.rb +48 -0
  34. data/lib/clacky/tools/browser.rb +176 -19
  35. data/lib/clacky/tools/web_search.rb +79 -8
  36. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  37. data/lib/clacky/ui2/components/input_area.rb +22 -2
  38. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  39. data/lib/clacky/ui2/ui_controller.rb +44 -7
  40. data/lib/clacky/utils/file_processor.rb +19 -7
  41. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  42. data/lib/clacky/utils/model_pricing.rb +114 -66
  43. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  44. data/lib/clacky/version.rb +1 -1
  45. data/lib/clacky/web/app.css +596 -130
  46. data/lib/clacky/web/components/chat-navigator.js +489 -202
  47. data/lib/clacky/web/components/code-editor.js +191 -5
  48. data/lib/clacky/web/components/model-picker.js +2 -1
  49. data/lib/clacky/web/components/quote-select.js +235 -0
  50. data/lib/clacky/web/core/aside.js +157 -8
  51. data/lib/clacky/web/core/ext.js +16 -0
  52. data/lib/clacky/web/features/billing/view.js +75 -21
  53. data/lib/clacky/web/features/skills/store.js +18 -1
  54. data/lib/clacky/web/features/skills/view.js +128 -7
  55. data/lib/clacky/web/features/workspace/store.js +80 -7
  56. data/lib/clacky/web/features/workspace/view.js +392 -40
  57. data/lib/clacky/web/i18n.js +90 -5
  58. data/lib/clacky/web/index.html +15 -6
  59. data/lib/clacky/web/sessions.js +193 -38
  60. data/lib/clacky/web/utils.js +34 -0
  61. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  62. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  63. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  64. data/lib/clacky/web/ws-dispatcher.js +20 -0
  65. data/lib/clacky.rb +2 -0
  66. metadata +11 -1
@@ -27,7 +27,9 @@
27
27
  const MAIN_BRANCHES = { main: true, master: true };
28
28
  const DIFF_ASIDE_WIDTH = 860;
29
29
  const MAX_DIFF_LINES = 3000;
30
+ let releaseAsideWidth = null; // set while the aside is widened for the diff
30
31
  const ICON_CLOSE = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>';
32
+ const ICON_BACK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>';
31
33
  const ICON_REVERT = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>';
32
34
  const t = (k, fallback) => {
33
35
  const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
@@ -38,6 +40,7 @@
38
40
  const style = document.createElement("style");
39
41
  style.id = "changes-panel-style";
40
42
  style.textContent = `
43
+ .changes-root { display: flex; flex-direction: column; flex: 1; min-height: 0; }
41
44
  .changes-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; }
42
45
  .changes-summary { flex: none; padding: 10px 12px 8px; border-bottom: 1px solid var(--color-border-secondary); }
43
46
  .changes-summary .h { font-size: 13px; color: var(--color-text-secondary); }
@@ -47,7 +50,7 @@
47
50
  .changes-branch code { font-family: ui-monospace, monospace; font-weight: 600; }
48
51
  .cg-body { flex: 1; min-height: 0; display: flex; }
49
52
  .cg-list-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; }
50
- .changes-panel.has-diff .cg-list-pane { flex: 0 0 232px; border-right: 1px solid var(--color-border-primary); }
53
+ .changes-panel.has-diff .cg-list-pane { flex: 0 1 232px; min-width: 120px; max-width: 40%; border-right: 1px solid var(--color-border-primary); }
51
54
  .cg-listbar { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--color-border-secondary); font-size: 11px; color: var(--color-text-secondary); user-select: none; }
52
55
  .cg-listbar .cg-check { cursor: pointer; }
53
56
  .cg-count { margin-left: auto; color: var(--color-text-tertiary); }
@@ -69,6 +72,8 @@
69
72
  .cg-diff-pane { display: none; flex: 1; min-width: 0; min-height: 0; flex-direction: column; }
70
73
  .changes-panel.has-diff .cg-diff-pane { display: flex; }
71
74
  .cg-diff-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 8px 6px 12px; border-bottom: 1px solid var(--color-border-secondary); }
75
+ .cg-diff-back { flex: none; display: none; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-secondary); cursor: pointer; }
76
+ .cg-diff-back:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
72
77
  .cg-diff-path { flex: 1; min-width: 0; font-family: ui-monospace, monospace; font-size: 11.5px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
73
78
  .cg-diff-close { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
74
79
  .cg-diff-close:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
@@ -90,6 +95,15 @@
90
95
  .changes-hint { text-align: center; font-size: 11px; color: var(--color-text-tertiary); margin-top: 7px; min-height: 1em; }
91
96
  .changes-empty, .changes-loading, .changes-error { color: var(--color-text-tertiary); padding: 16px; font-size: 12px; text-align: center; }
92
97
  .changes-error { color: var(--color-error); }
98
+ @media (max-width: 768px) {
99
+ .changes-panel.has-diff .changes-summary,
100
+ .changes-panel.has-diff .cg-list-pane,
101
+ .changes-panel.has-diff .changes-foot { display: none; }
102
+ .changes-panel.has-diff .cg-diff-pane { display: flex; width: 100%; }
103
+ .cg-diff-head { padding-left: 6px; }
104
+ .cg-diff-back { display: inline-flex; }
105
+ .cg-diff-close { display: none; }
106
+ }
93
107
  `;
94
108
  document.head.appendChild(style);
95
109
  }
@@ -140,19 +154,19 @@
140
154
  return `${t("changes.save.prefix", "手动存档")} · ${stamp}`;
141
155
  }
142
156
 
143
- // The diff pane needs horizontal room; widen the aside once when a diff is
144
- // first opened (same trick as the Files tab, still user-resizable after).
157
+ // The diff pane needs horizontal room, so borrow it from the host while a
158
+ // diff is open. The host coordinates overlapping panels and preserves any
159
+ // width the user chooses manually in the meantime.
145
160
  function ensureAsideWidth() {
146
- const aside = document.getElementById("session-aside");
147
- if (!aside) return;
148
- let w = 0;
149
- try {
150
- w = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width")) || 0;
151
- } catch (_e) { w = 0; }
152
- if (w < DIFF_ASIDE_WIDTH) {
153
- aside.style.setProperty("--session-aside-width", DIFF_ASIDE_WIDTH + "px");
154
- try { localStorage.setItem("clacky.aside.width", String(DIFF_ASIDE_WIDTH)); } catch (_e) { /* non-fatal */ }
155
- }
161
+ if (releaseAsideWidth || !window.Clacky || !Clacky.Aside || !Clacky.Aside.requestWidth) return;
162
+ releaseAsideWidth = Clacky.Aside.requestWidth(DIFF_ASIDE_WIDTH);
163
+ }
164
+
165
+ function restoreAsideWidth() {
166
+ if (!releaseAsideWidth) return;
167
+ const release = releaseAsideWidth;
168
+ releaseAsideWidth = null;
169
+ release();
156
170
  }
157
171
 
158
172
  // ── Panel state ─────────────────────────────────────────────────────────
@@ -319,6 +333,7 @@
319
333
  }
320
334
 
321
335
  function closeDiff() {
336
+ restoreAsideWidth();
322
337
  if (!state.els) return;
323
338
  state.selected = null;
324
339
  state.els.panelEl.classList.remove("has-diff");
@@ -541,6 +556,10 @@
541
556
  const listEl = el("div", { class: "changes-list" });
542
557
  const listPane = el("div", { class: "cg-list-pane" }, listbar, listEl);
543
558
 
559
+ const diffBack = el("button", { class: "cg-diff-back", type: "button",
560
+ title: t("changes.backToList", "返回改动列表"), "aria-label": t("changes.backToList", "返回改动列表") });
561
+ diffBack.innerHTML = ICON_BACK;
562
+ diffBack.addEventListener("click", closeDiff);
544
563
  const diffPathEl = el("span", { class: "cg-diff-path" });
545
564
  const diffRevert = el("button", { class: "cg-diff-revert", type: "button",
546
565
  title: t("changes.restore.title", "撤销此文件的改动") });
@@ -554,7 +573,7 @@
554
573
  diffClose.addEventListener("click", closeDiff);
555
574
  const diffCanvas = el("div", { class: "cg-diff" });
556
575
  const diffPane = el("div", { class: "cg-diff-pane" },
557
- el("div", { class: "cg-diff-head" }, diffPathEl, diffRevert, diffClose), diffCanvas);
576
+ el("div", { class: "cg-diff-head" }, diffBack, diffPathEl, diffRevert, diffClose), diffCanvas);
558
577
 
559
578
  const saveBtn = el("button", { class: "changes-save-btn", type: "button", text: t("changes.save.btn", "存档当前版本") });
560
579
  const hint = el("div", { class: "changes-hint" });
@@ -602,6 +621,7 @@
602
621
  return () => {
603
622
  unsubscribeComplete();
604
623
  unsubscribeFileSaved();
624
+ restoreAsideWidth();
605
625
  if (activeSessionId === sid) {
606
626
  activeRoot = null;
607
627
  activeBody = null;
@@ -0,0 +1,20 @@
1
+ id: preview
2
+ name: Preview
3
+ display_name: Preview
4
+ display_name_zh: 预览
5
+ description: Live preview panel — click an HTML file or localhost URL in chat to open it
6
+ description_zh: 预览面板 — 点击聊天里的 HTML 文件或 localhost 链接打开
7
+ version: "0.1.0"
8
+ author: OpenClacky
9
+ homepage: https://www.openclacky.com
10
+ license: MIT
11
+ contributes:
12
+ panels:
13
+ - id: preview
14
+ title: Preview
15
+ title_zh: 预览
16
+ description: Live preview of workspace HTML files
17
+ description_zh: 工作区 HTML 文件实时预览
18
+ order: 40
19
+ attach: ["*"]
20
+ view: panels/preview/view.js
@@ -0,0 +1,475 @@
1
+ // ── Official panel: live preview ──────────────────────────────────────────
2
+ //
3
+ // "预览 / Preview": a mini browser view of workspace HTML files (served by
4
+ // GET /preview/s/<session>/<rel>) and of any http(s) URL — dev servers, local
5
+ // hosts — loaded straight into the sandboxed iframe. The toolbar mirrors a
6
+ // browser chrome: back / forward / reload on the left, a centred address bar,
7
+ // open-external on the right. Clicking an HTML file or a localhost URL in the
8
+ // chat stream calls Clacky.Preview.open(), which opens the aside, activates
9
+ // this tab and (re)loads the page.
10
+ //
11
+ // Host-origin pages (proxied workspace files and proxied dev servers) are
12
+ // sandboxed WITHOUT allow-same-origin so AI-generated pages can run
13
+ // scripts/forms/popups but never touch the host origin (cookies, localStorage,
14
+ // DOM). Third-party URLs loaded directly (e.g. a Vite dev server) get
15
+ // allow-same-origin added, otherwise their ES-module imports fail and the
16
+ // frame stays blank. Because the sandbox hides load errors, the panel HEADs
17
+ // the URL first and shows a friendly 404 card instead of a blank frame.
18
+ // ───────────────────────────────────────────────────────────────────────────
19
+
20
+ (() => {
21
+ if (!window.Clacky || !Clacky.ext) return;
22
+
23
+ const PREVIEW_ASIDE_WIDTH = 620;
24
+ const SANDBOX = "allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals";
25
+
26
+ const ICON_GLOBE = '<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg>';
27
+ const ICON_BACK = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>';
28
+ const ICON_FORWARD = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>';
29
+ const ICON_RELOAD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36L21 8"/><path d="M21 3v5h-5"/></svg>';
30
+ const ICON_GO = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>';
31
+ const ICON_EXTERNAL = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/></svg>';
32
+
33
+ const t = (k, fallback) => {
34
+ const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
35
+ return (v && v !== k) ? v : fallback;
36
+ };
37
+
38
+ function el(tag, attrs, ...kids) {
39
+ const node = document.createElement(tag);
40
+ if (attrs) {
41
+ for (const [k, v] of Object.entries(attrs)) {
42
+ if (k === "class") node.className = v;
43
+ else if (k === "text") node.textContent = v;
44
+ else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v);
45
+ else node.setAttribute(k, v);
46
+ }
47
+ }
48
+ kids.forEach((c) => node.appendChild(typeof c === "string" ? document.createTextNode(c) : c));
49
+ return node;
50
+ }
51
+
52
+ if (!document.getElementById("preview-panel-style")) {
53
+ const style = document.createElement("style");
54
+ style.id = "preview-panel-style";
55
+ style.textContent = `
56
+ .preview-root { display: flex; flex-direction: column; flex: 1; min-height: 0; }
57
+ .preview-bar { flex: none; display: flex; align-items: center; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--color-border-secondary); }
58
+ .preview-nav-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
59
+ .preview-nav-btn:hover:not(:disabled) { background: var(--color-bg-hover); color: var(--color-text-primary); }
60
+ .preview-nav-btn:disabled { opacity: .35; cursor: default; }
61
+ .preview-address-wrap { flex: 1; min-width: 0; position: relative; display: flex; align-items: center; }
62
+ .preview-address { width: 100%; height: 27px; padding: 0 32px 0 14px; border: none; border-radius: 14px; background: var(--color-bg-secondary); color: var(--color-text-primary); font-size: 12px; font-family: ui-monospace, monospace; text-align: center; transition: box-shadow var(--transition-fast), background var(--transition-fast); }
63
+ .preview-address::placeholder { color: var(--color-text-tertiary); font-family: -apple-system, sans-serif; }
64
+ .preview-address:hover:not(:focus) { box-shadow: 0 0 0 1px var(--color-border-strong); }
65
+ .preview-address:focus { outline: none; box-shadow: 0 0 0 1px var(--color-accent-primary); background: var(--color-bg-primary); }
66
+ .preview-go { position: absolute; right: 5px; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; border: none; border-radius: 50%; background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
67
+ .preview-go:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
68
+ .preview-icon-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
69
+ .preview-icon-btn:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
70
+ .preview-frame-wrap { flex: 1; min-height: 0; overflow: hidden; }
71
+ .preview-frame { display: block; width: 100%; height: 100%; border: none; background: #fff; }
72
+ .preview-empty, .preview-notfound { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 24px; text-align: center; }
73
+ .preview-empty-icon { color: var(--color-text-tertiary); opacity: .5; margin-bottom: 4px; }
74
+ .preview-empty-title { font-size: 15px; font-weight: 600; color: var(--color-text-primary); }
75
+ .preview-empty-sub { font-size: 12px; color: var(--color-text-tertiary); max-width: 240px; line-height: 1.5; }
76
+ .preview-notfound-title { font-size: 13px; color: var(--color-text-secondary); }
77
+ .preview-retry { margin-top: 4px; padding: 6px 14px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: transparent; color: var(--color-text-primary); font-size: 12px; cursor: pointer; }
78
+ .preview-retry:hover { background: var(--color-bg-hover); }
79
+ `;
80
+ document.head.appendChild(style);
81
+ }
82
+
83
+ // ── Panel state ────────────────────────────────────────────────────────
84
+ // The tab body is re-rendered on every session switch; these refs live at
85
+ // module level so the eager onAttach subscription can route an event into
86
+ // a not-yet-rendered body via `pending`.
87
+ let els = null; // live DOM refs while the tab body is rendered
88
+ let activeSessionId = null;
89
+ let currentRel = null;
90
+ let backStack = []; // rels reachable via back, oldest → newest
91
+ let forwardStack = []; // rels reachable via forward, newest → oldest
92
+ let pending = null; // { sid, rel } arrived before that body rendered
93
+ let releaseAsideWidth = null; // set while the aside is widened for the preview
94
+
95
+ function isExternal(rel) {
96
+ return /^https?:\/\//i.test(rel);
97
+ }
98
+
99
+ // /Users/... (bare absolute path) or file:///Users/... — a local file
100
+ // addressed by absolute path, served via /preview/f/ instead of the
101
+ // workspace route.
102
+ function isFile(rel) {
103
+ return /^file:\/\//i.test(rel) || /^\//.test(rel);
104
+ }
105
+
106
+ function filePath(rel) {
107
+ if (/^file:\/\//i.test(rel)) {
108
+ try {
109
+ let p = new URL(rel).pathname;
110
+ try { p = decodeURIComponent(p); } catch (_e) { /* keep encoded */ }
111
+ return p;
112
+ } catch (_e) {
113
+ return rel.replace(/^file:\/\//i, "");
114
+ }
115
+ }
116
+ return rel;
117
+ }
118
+
119
+ // True when the URL is served from the host origin through the preview
120
+ // proxy (workspace files /preview/s/ or dev servers /preview/p/). These
121
+ // pages must stay sandboxed without allow-same-origin so AI-generated
122
+ // content can never touch the host's cookies / localStorage / DOM.
123
+ function isProxied(url) {
124
+ return url.startsWith("/preview/s/") || url.startsWith("/preview/p/") || url.startsWith("/preview/f/");
125
+ }
126
+
127
+ // A localhost / 127.0.0.1 dev server is opened through the host's proxy
128
+ // (/preview/p/<port>/<path>) so pages that set X-Frame-Options still render
129
+ // in the sandboxed iframe. Everything else loads straight into the iframe.
130
+ function proxyUrlFor(rel) {
131
+ try {
132
+ const u = new URL(rel);
133
+ const host = u.hostname.toLowerCase();
134
+ if (host !== "localhost" && host !== "127.0.0.1") return null;
135
+ const port = u.port || "80";
136
+ return `/preview/p/${port}${u.pathname}${u.search}`;
137
+ } catch (_e) {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ function buildUrl(sid, rel) {
143
+ if (isExternal(rel)) return proxyUrlFor(rel) || rel;
144
+ if (isFile(rel)) {
145
+ const enc = filePath(rel).split("/").map(encodeURIComponent).join("/");
146
+ return "/preview/f" + enc;
147
+ }
148
+ const enc = rel.split("/").map(encodeURIComponent).join("/");
149
+ return `/preview/s/${encodeURIComponent(sid)}/${enc}`;
150
+ }
151
+
152
+ // The address bar shows workspace files as their full /preview/s/ URL so
153
+ // the user can see where the file lives; dev-server rels already carry
154
+ // their own http(s) URL.
155
+ function displayUrl(rel) {
156
+ if (isExternal(rel) || isFile(rel)) return rel;
157
+ return location.origin + buildUrl(activeSessionId, rel);
158
+ }
159
+
160
+ function cleanRel(input) {
161
+ const s = String(input || "").trim().replace(/\\/g, "/");
162
+ return /^\//.test(s) ? s : s.replace(/^\.\//, "");
163
+ }
164
+
165
+ // The preview needs horizontal room, so the aside is widened while a page is
166
+ // shown. Same contract as the changes panel: borrowed space, given back on
167
+ // close, never persisted over the user's own width.
168
+ function ensureAsideWidth() {
169
+ if (releaseAsideWidth || !window.Clacky || !Clacky.Aside) return;
170
+ releaseAsideWidth = Clacky.Aside.requestWidth(PREVIEW_ASIDE_WIDTH);
171
+ }
172
+
173
+ function restoreAsideWidth() {
174
+ if (!releaseAsideWidth) return;
175
+ const release = releaseAsideWidth;
176
+ releaseAsideWidth = null;
177
+ release();
178
+ }
179
+
180
+ function showOverlay(which) {
181
+ if (!els) return;
182
+ els.empty.style.display = which === "empty" ? "flex" : "none";
183
+ els.notFound.style.display = which === "notFound" ? "flex" : "none";
184
+ els.unreachable.style.display = which === "unreachable" ? "flex" : "none";
185
+ els.frame.style.display = which ? "none" : "block";
186
+ }
187
+
188
+ function syncAddress(rel) {
189
+ if (!els) return;
190
+ if (document.activeElement === els.address) return;
191
+ els.address.value = rel ? displayUrl(rel) : "";
192
+ }
193
+
194
+ function updateNavButtons() {
195
+ if (!els) return;
196
+ els.backBtn.disabled = backStack.length === 0;
197
+ els.forwardBtn.disabled = forwardStack.length === 0;
198
+ }
199
+
200
+ // Follows in-frame navigation. A sandboxed frame without allow-same-origin
201
+ // cannot be inspected: contentWindow.location is sealed and the src property
202
+ // does not follow internal navigations. Instead the server injects a
203
+ // reporter into every preview HTML page that announces its URL on load, so
204
+ // link clicks inside the frame keep the address bar and the back/forward
205
+ // stacks in sync with what is actually on screen.
206
+ function relFromUrl(url) {
207
+ try {
208
+ const u = new URL(url, location.href);
209
+ const m = u.pathname.match(/^\/preview\/s\/[^/]+\/(.*)$/);
210
+ if (!m || !m[1]) return null;
211
+ return m[1].split("/").map(decodeURIComponent).join("/");
212
+ } catch (_e) { return null; }
213
+ }
214
+
215
+ if (!window.__clackyPreviewNav) {
216
+ window.__clackyPreviewNav = true;
217
+ window.addEventListener("message", (e) => {
218
+ const data = e.data;
219
+ if (!data || data.__clackyPreview !== 1) return;
220
+ if (els && els.iframe && e.source !== els.iframe.contentWindow) return;
221
+ const rel = relFromUrl(data.href);
222
+ if (!rel || rel === currentRel) return;
223
+ if (currentRel) backStack.push(currentRel);
224
+ forwardStack = [];
225
+ currentRel = rel;
226
+ syncAddress(rel);
227
+ updateNavButtons();
228
+ });
229
+ }
230
+
231
+ // A brand-new element is the only reload primitive that works uniformly:
232
+ // re-assigning an identical src is a no-op, and contentWindow is sealed by
233
+ // the sandbox.
234
+ function freshFrame(url, opaque) {
235
+ const sandbox = opaque ? SANDBOX : (SANDBOX + " allow-same-origin");
236
+ const f = el("iframe", {
237
+ class: "preview-frame",
238
+ sandbox: sandbox,
239
+ referrerpolicy: "no-referrer",
240
+ title: t("preview.tab", "预览"),
241
+ });
242
+ els.frame.replaceChildren(f);
243
+ els.iframe = f;
244
+ f.src = url;
245
+ }
246
+
247
+ async function openRel(sid, rel, record) {
248
+ rel = cleanRel(rel);
249
+ if (!rel || !sid) return;
250
+
251
+ if (record) {
252
+ if (currentRel && currentRel !== rel) backStack.push(currentRel);
253
+ forwardStack = [];
254
+ }
255
+ currentRel = rel;
256
+ if (els && activeSessionId === sid) {
257
+ syncAddress(rel);
258
+ updateNavButtons();
259
+ } else {
260
+ pending = { sid: sid, rel: rel };
261
+ return;
262
+ }
263
+
264
+ const url = buildUrl(sid, rel);
265
+ let frameUrl = url;
266
+ // Proxied URLs (workspace files and dev servers) are same-origin, so they
267
+ // can be HEAD-probed. A non-200 from the proxy means the dev server is
268
+ // down — show a friendly "unreachable" card instead of the raw text in
269
+ // the frame. Truly cross-origin URLs skip the probe (CORS blocks HEAD).
270
+ if (isProxied(url)) {
271
+ try {
272
+ const res = await fetch(url, { method: "HEAD" });
273
+ if (res.status !== 200) {
274
+ if (currentRel === rel) {
275
+ showOverlay(url.startsWith("/preview/p/") ? "unreachable" : "notFound");
276
+ }
277
+ return;
278
+ }
279
+ // The proxy reports whether the upstream allows framing on its own.
280
+ // Vite-style dev servers send no X-Frame-Options, and their modules
281
+ // import by absolute path (/node_modules/.vite/deps/...), which the
282
+ // proxy cannot rewrite — for those, load the dev server directly so
283
+ // the ESM graph (and HMR websockets) resolves against the right origin.
284
+ if (url.startsWith("/preview/p/") && res.headers.get("X-Clacky-Upstream-Frameable") === "1") {
285
+ frameUrl = rel;
286
+ }
287
+ } catch (_e) { /* offline/race — try the iframe anyway */ }
288
+ }
289
+ if (currentRel !== rel || !els) return;
290
+ showOverlay(null);
291
+ syncAddress(rel);
292
+ ensureAsideWidth();
293
+ // Only third-party origins may self-identify: Vite-style dev servers
294
+ // need allow-same-origin for their ES-module graph to load. Proxy URLs
295
+ // (/preview/p/, /preview/s/) stay on the host origin and must remain
296
+ // opaque so AI pages can never touch the host's cookies or localStorage.
297
+ freshFrame(frameUrl, isProxied(frameUrl));
298
+ }
299
+
300
+ function goBack() {
301
+ const rel = backStack.pop();
302
+ if (!rel || !activeSessionId) return;
303
+ if (currentRel) forwardStack.push(currentRel);
304
+ openRel(activeSessionId, rel, false);
305
+ }
306
+
307
+ function goForward() {
308
+ const rel = forwardStack.pop();
309
+ if (!rel || !activeSessionId) return;
310
+ if (currentRel) backStack.push(currentRel);
311
+ openRel(activeSessionId, rel, false);
312
+ }
313
+
314
+ function reloadCurrent() {
315
+ if (activeSessionId && currentRel) openRel(activeSessionId, currentRel, false);
316
+ }
317
+
318
+ function openExternal() {
319
+ if (!activeSessionId || !currentRel) return;
320
+ // External URLs open as-is: the proxy strips X-Frame-Options only to
321
+ // satisfy the iframe, a fresh tab needs no such workaround. Bare
322
+ // absolute paths get a file:// prefix for the new tab.
323
+ let url;
324
+ if (isExternal(currentRel)) {
325
+ url = currentRel;
326
+ } else if (isFile(currentRel)) {
327
+ url = /^file:\/\//i.test(currentRel) ? currentRel : "file://" + currentRel;
328
+ } else {
329
+ url = buildUrl(activeSessionId, currentRel);
330
+ }
331
+ window.open(url, "_blank", "noopener");
332
+ }
333
+
334
+ Clacky.ext.ui.mount("session.aside", (container, ctx) => {
335
+ if (!ctx || !ctx.sessionId) return;
336
+
337
+ const address = el("input", {
338
+ class: "preview-address", type: "text",
339
+ placeholder: t("preview.address", "输入本地文件路径或 localhost 地址"),
340
+ "aria-label": t("preview.address", "输入本地文件路径或 localhost 地址"),
341
+ spellcheck: "false",
342
+ });
343
+ const submitAddress = () => {
344
+ // The bar shows a full URL; map /preview/s/<sid>/<rel> back to the rel
345
+ // the rest of the panel navigates by, and leave everything else as-is.
346
+ openRel(ctx.sessionId, relFromUrl(address.value) || address.value, true);
347
+ address.blur();
348
+ };
349
+ address.addEventListener("keydown", (e) => {
350
+ if (e.key === "Enter") submitAddress();
351
+ });
352
+ address.addEventListener("focus", () => address.select());
353
+
354
+ const goBtn = el("button", {
355
+ class: "preview-go", type: "button",
356
+ title: t("preview.go", "前往"), onclick: submitAddress,
357
+ });
358
+ goBtn.innerHTML = ICON_GO;
359
+ const addressWrap = el("div", { class: "preview-address-wrap" }, address, goBtn);
360
+
361
+ const backBtn = el("button", {
362
+ class: "preview-nav-btn", type: "button",
363
+ title: t("preview.back", "后退"), onclick: goBack,
364
+ });
365
+ backBtn.innerHTML = ICON_BACK;
366
+
367
+ const forwardBtn = el("button", {
368
+ class: "preview-nav-btn", type: "button",
369
+ title: t("preview.forward", "前进"), onclick: goForward,
370
+ });
371
+ forwardBtn.innerHTML = ICON_FORWARD;
372
+
373
+ const reloadBtn = el("button", {
374
+ class: "preview-nav-btn", type: "button",
375
+ title: t("preview.reload", "刷新"), onclick: reloadCurrent,
376
+ });
377
+ reloadBtn.innerHTML = ICON_RELOAD;
378
+
379
+ const extBtn = el("button", {
380
+ class: "preview-icon-btn", type: "button",
381
+ title: t("preview.openExternal", "在新标签页打开"), onclick: openExternal,
382
+ });
383
+ extBtn.innerHTML = ICON_EXTERNAL;
384
+
385
+ const bar = el("div", { class: "preview-bar" }, backBtn, forwardBtn, reloadBtn, addressWrap, extBtn);
386
+ const frame = el("div", { class: "preview-frame-wrap" });
387
+
388
+ const notFound = el("div", { class: "preview-notfound" },
389
+ el("div", { class: "preview-notfound-title", text: t("preview.notFound", "文件不存在") }),
390
+ el("button", { class: "preview-retry", type: "button",
391
+ text: t("preview.reload", "刷新"), onclick: reloadCurrent }));
392
+
393
+ const unreachable = el("div", { class: "preview-notfound" },
394
+ el("div", { class: "preview-notfound-title", text: t("preview.unreachable", "无法连接到开发服务器") }),
395
+ el("button", { class: "preview-retry", type: "button",
396
+ text: t("preview.reload", "刷新"), onclick: reloadCurrent }));
397
+
398
+ const emptyIcon = el("div", { class: "preview-empty-icon" });
399
+ emptyIcon.innerHTML = ICON_GLOBE;
400
+ const empty = el("div", { class: "preview-empty" },
401
+ emptyIcon,
402
+ el("div", { class: "preview-empty-title", text: t("preview.empty.title", "开始浏览") }),
403
+ el("div", { class: "preview-empty-sub",
404
+ text: t("preview.empty.sub", "输入本地文件路径或 localhost 地址以打开页面") }));
405
+
406
+ const root = el("div", { class: "preview-root", "data-panel": "preview" },
407
+ bar, frame, notFound, unreachable, empty);
408
+ container.appendChild(root);
409
+
410
+ els = {
411
+ frame: frame, iframe: null,
412
+ empty: empty, notFound: notFound, unreachable: unreachable,
413
+ address: address,
414
+ backBtn: backBtn, forwardBtn: forwardBtn,
415
+ };
416
+ activeSessionId = ctx.sessionId;
417
+ syncAddress(currentRel);
418
+ updateNavButtons();
419
+
420
+ if (pending && pending.sid === ctx.sessionId) {
421
+ const rel = pending.rel;
422
+ pending = null;
423
+ openRel(ctx.sessionId, rel, false);
424
+ } else if (currentRel) {
425
+ openRel(ctx.sessionId, currentRel, false);
426
+ } else {
427
+ showOverlay("empty");
428
+ }
429
+ }, {
430
+ order: 50,
431
+ tab: {
432
+ id: "preview",
433
+ label: () => t("preview.tab", "预览"),
434
+ // Eager onAttach: runs before the tab is ever opened by hand, and only
435
+ // handles session-switch cleanup — restoring the borrowed aside width
436
+ // and resetting the preview state for the next session.
437
+ onAttach(ctx) {
438
+ if (!ctx || !ctx.sessionId) return;
439
+ const sid = ctx.sessionId;
440
+ return () => {
441
+ restoreAsideWidth();
442
+ // A same-session re-render keeps the preview state; only a real
443
+ // session switch starts the next workspace from a clean slate.
444
+ const switchedAway = !(Clacky.ext.context && Clacky.ext.context.sessionId === sid);
445
+ if (switchedAway) {
446
+ currentRel = null;
447
+ backStack = [];
448
+ forwardStack = [];
449
+ if (pending && pending.sid === sid) pending = null;
450
+ }
451
+ if (activeSessionId === sid) {
452
+ els = null;
453
+ activeSessionId = null;
454
+ }
455
+ };
456
+ },
457
+ },
458
+ });
459
+
460
+ // Programmatic entry point used by the host (the workspace viewer and the
461
+ // chat link interceptor): open a workspace-relative HTML path or an http(s)
462
+ // URL. Returns false when there is no active session.
463
+ if (window.Clacky) {
464
+ Clacky.Preview = {
465
+ open(rel) {
466
+ const sid = Clacky.ext.context && Clacky.ext.context.sessionId;
467
+ if (!sid || !rel) return false;
468
+ openRel(sid, rel, true);
469
+ if (window.Clacky && Clacky.Aside) Clacky.Aside.open();
470
+ Clacky.ext.activateTab("session.aside", "preview");
471
+ return true;
472
+ },
473
+ };
474
+ }
475
+ })();
@@ -211,16 +211,7 @@
211
211
  // The diff pane needs horizontal room; widen the aside once when a diff is
212
212
  // first opened (same trick as the Files/Git tabs, still user-resizable).
213
213
  function ensureAsideWidth() {
214
- const aside = document.getElementById("session-aside");
215
- if (!aside) return;
216
- let w = 0;
217
- try {
218
- w = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width")) || 0;
219
- } catch (_e) { w = 0; }
220
- if (w < DIFF_ASIDE_WIDTH) {
221
- aside.style.setProperty("--session-aside-width", DIFF_ASIDE_WIDTH + "px");
222
- try { localStorage.setItem("clacky.aside.width", String(DIFF_ASIDE_WIDTH)); } catch (_e) { /* non-fatal */ }
223
- }
214
+ if (window.Clacky && Clacky.Aside) Clacky.Aside.requestWidth(DIFF_ASIDE_WIDTH, { persist: true });
224
215
  }
225
216
 
226
217
  function renderTimeline(state) {
@@ -17,7 +17,7 @@ module Clacky
17
17
  #
18
18
  # class MyDashboardExt < Clacky::ApiExtension
19
19
  # get "/summary" do
20
- # json(sessions: session_manager.list.size)
20
+ # json(sessions: session_manager.all_sessions.size)
21
21
  # end
22
22
  # end
23
23
  #
@@ -22,7 +22,7 @@ module Clacky
22
22
  PANEL_KEYS = %w[id title title_zh description description_zh view order attach entry_points].freeze
23
23
  API_KEYS = %w[id handler].freeze
24
24
  SKILL_KEYS = %w[id dir protected].freeze
25
- AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills avatar hidden tools].freeze
25
+ AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills disabled_skills avatar hidden tools].freeze
26
26
  CHANNEL_KEYS = %w[id platform adapter].freeze
27
27
  PATCH_KEYS = %w[target file fingerprint on_mismatch].freeze
28
28
  HOOK_KEYS = %w[event file].freeze