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
@@ -16,7 +16,7 @@
16
16
  "use strict";
17
17
 
18
18
  const WorkspaceView = (() => {
19
- const t = (key) => (typeof I18n !== "undefined" ? I18n.t(key) : key);
19
+ const t = (key, vars) => (typeof I18n !== "undefined" ? I18n.t(key, vars) : key);
20
20
 
21
21
  const ICON_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
22
22
  const ICON_FOLDER_OPEN = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>';
@@ -27,6 +27,11 @@ const WorkspaceView = (() => {
27
27
  const ICON_SAVE = '<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 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>';
28
28
  const ICON_EYE = '<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
29
29
  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>';
30
+ const ICON_CHEVRON_DOWN = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
31
+ const ICON_EXTERNAL = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" 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"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
32
+ const ICON_PANEL_LEFT = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
33
+ const ICON_ARROW_LEFT = '<svg xmlns="http://www.w3.org/2000/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>';
34
+ const ICON_COLUMNS = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>';
30
35
 
31
36
  function el(tag, cls) {
32
37
  const d = document.createElement(tag);
@@ -357,6 +362,94 @@ const WorkspaceView = (() => {
357
362
  if (existing) existing.remove();
358
363
  }
359
364
 
365
+ function showOpenWithMenu(anchorBtn, entry) {
366
+ closeOpenWithMenu();
367
+
368
+ const menu = el("div", "wt-context-menu session-context-menu wv-open-menu");
369
+ const appendAction = (iconSvg, label, onClick) => {
370
+ const item = el("div", "session-actions-menu-item");
371
+ const iconEl = el("span", "session-actions-menu-icon");
372
+ iconEl.innerHTML = iconSvg;
373
+ const labelEl = el("span", "session-actions-menu-label");
374
+ labelEl.textContent = label;
375
+ item.appendChild(iconEl);
376
+ item.appendChild(labelEl);
377
+ item.addEventListener("click", () => {
378
+ closeOpenWithMenu();
379
+ onClick();
380
+ });
381
+ menu.appendChild(item);
382
+ };
383
+ appendAction(ICON_FOLDER, t("workspace.revealInFinder"), () => revealFile(entry));
384
+ appendAction(ICON_DOWNLOAD, t("workspace.download"), () => downloadFile(entry));
385
+
386
+ document.body.appendChild(menu);
387
+ menu.addEventListener("contextmenu", (ev) => ev.preventDefault());
388
+ menu.style.position = "fixed";
389
+ const fitMenu = () => {
390
+ const r = anchorBtn.getBoundingClientRect();
391
+ const mr = menu.getBoundingClientRect();
392
+ if (mr.right > window.innerWidth - 8) {
393
+ menu.style.left = "auto";
394
+ menu.style.right = "8px";
395
+ }
396
+ if (mr.bottom > window.innerHeight - 8) {
397
+ menu.style.top = (Math.max(8, r.top - mr.height - 4)) + "px";
398
+ }
399
+ };
400
+ const r = anchorBtn.getBoundingClientRect();
401
+ menu.style.top = (r.bottom + 4) + "px";
402
+ menu.style.left = r.left + "px";
403
+ requestAnimationFrame(fitMenu);
404
+
405
+ setTimeout(() => {
406
+ document.addEventListener("click", closeOpenWithMenu, { once: true });
407
+ }, 0);
408
+
409
+ Promise.all([
410
+ Workspace.fetchOpenWithApps(entry),
411
+ Workspace.fetchDefaultApp(entry)
412
+ ]).then(([apps, defaultApp]) => {
413
+ if (!document.body.contains(menu)) return;
414
+ if (apps.length) {
415
+ const frag = document.createDocumentFragment();
416
+ apps.forEach((app) => {
417
+ const isDefault = defaultApp && defaultApp.path === app.path;
418
+ const item = el("div", "session-actions-menu-item" + (isDefault ? " wv-open-menu-item--default" : ""));
419
+ if (isDefault) item.title = t("workspace.openDefault");
420
+ const iconEl = el("span", "session-actions-menu-icon wv-open-app-icon");
421
+ const img = document.createElement("img");
422
+ img.alt = "";
423
+ img.src = "/api/app-icon?path=" + encodeURIComponent(app.path);
424
+ img.addEventListener("error", () => { img.remove(); });
425
+ iconEl.appendChild(img);
426
+ const labelEl = el("span", "session-actions-menu-label");
427
+ labelEl.textContent = app.name;
428
+ item.appendChild(iconEl);
429
+ item.appendChild(labelEl);
430
+ item.addEventListener("click", async () => {
431
+ closeOpenWithMenu();
432
+ try {
433
+ await Workspace.openWithApp(entry, app.path);
434
+ } catch (err) {
435
+ console.error("open_with failed:", err);
436
+ Modal.toast(t("workspace.openWithFailed") + ": " + err.message, "error");
437
+ }
438
+ });
439
+ frag.appendChild(item);
440
+ });
441
+ frag.appendChild(el("div", "wv-open-menu-separator"));
442
+ menu.insertBefore(frag, menu.firstChild);
443
+ fitMenu();
444
+ }
445
+ }).catch(() => { /* not macOS or scan failed — reveal/download still work */ });
446
+ }
447
+
448
+ function closeOpenWithMenu() {
449
+ const existing = document.querySelector(".wv-open-menu");
450
+ if (existing) existing.remove();
451
+ }
452
+
360
453
  async function copyPath(entry) {
361
454
  const fallback = Workspace.state.workingDir.replace(/\/+$/, "") + "/" + entry.path.replace(/^\/+/, "");
362
455
  let target = fallback;
@@ -443,27 +536,36 @@ const WorkspaceView = (() => {
443
536
 
444
537
  // ── Inline tabbed viewer ────────────────────────────────────────────────
445
538
 
446
- // The tree + viewer split needs horizontal room; widen the aside once when
447
- // the first file is opened (still user-resizable afterwards).
448
- function ensureAsideWidth() {
449
- const aside = document.getElementById("session-aside");
450
- if (!aside) return;
451
- let w = 0;
452
- try {
453
- w = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width")) || 0;
454
- } catch (_e) { w = 0; }
455
- const target = 620;
456
- if (w < target) {
457
- aside.style.setProperty("--session-aside-width", target + "px");
458
- try { localStorage.setItem("clacky.aside.width", String(target)); } catch (_e) { /* non-fatal */ }
459
- }
460
- }
539
+ const VIEWER_ASIDE_WIDTH = 700;
461
540
 
462
- function createViewer(onTabsChanged) {
541
+ // Active viewer instance (one per mounted Files tab). Kept at module level
542
+ // so openFile() — called from chat file:// link clicks — can reach the
543
+ // viewer without waiting for a DOM query or a custom event round-trip.
544
+ let _activeViewer = null;
545
+ // Unsubscriber for the active mount's refresh listeners. The aside can
546
+ // re-mount the Files tab without running the previous teardown (new-session
547
+ // page / same-session re-render), so the next mount releases these first to
548
+ // avoid stacking duplicate reloads.
549
+ let _activeOff = null;
550
+
551
+ function createViewer(onTabsChanged, onToggleTree, onShowMobileTree) {
463
552
  const tabs = [];
464
553
  let activeId = null;
554
+ let releaseAsideWidth = null;
465
555
  const notify = () => { if (onTabsChanged) onTabsChanged({ count: tabs.length, activeId }); };
466
556
 
557
+ function ensureAsideWidth() {
558
+ if (releaseAsideWidth || !window.Clacky || !Clacky.Aside || !Clacky.Aside.requestWidth) return;
559
+ releaseAsideWidth = Clacky.Aside.requestWidth(VIEWER_ASIDE_WIDTH);
560
+ }
561
+
562
+ function restoreAsideWidth() {
563
+ if (!releaseAsideWidth) return;
564
+ const release = releaseAsideWidth;
565
+ releaseAsideWidth = null;
566
+ release();
567
+ }
568
+
467
569
  const root = el("div", "wv-viewer");
468
570
 
469
571
  const tabbar = el("div", "wv-tabbar");
@@ -477,19 +579,68 @@ const WorkspaceView = (() => {
477
579
  const btn = el("button", "wv-action" + (cls ? " " + cls : ""));
478
580
  btn.type = "button";
479
581
  btn.title = title;
582
+ btn.setAttribute("aria-label", title);
480
583
  btn.innerHTML = icon;
481
584
  return btn;
482
585
  }
483
586
 
587
+ const toggleTreeBtn = actionBtn(ICON_PANEL_LEFT, t("workspace.toggleTree"));
588
+ toggleTreeBtn.classList.add("wv-toggle-tree", "active");
589
+ toggleTreeBtn.addEventListener("click", () => {
590
+ const hidden = onToggleTree ? onToggleTree() : false;
591
+ toggleTreeBtn.classList.toggle("active", !hidden);
592
+ });
593
+
594
+ const mobileBackBtn = actionBtn(ICON_ARROW_LEFT, t("workspace.backToFiles"), "wv-mobile-back");
595
+ mobileBackBtn.addEventListener("click", () => {
596
+ if (onShowMobileTree) onShowMobileTree();
597
+ });
598
+
484
599
  const previewBtn = actionBtn(ICON_EYE, t("workspace.preview"));
600
+ const splitBtn = actionBtn(ICON_COLUMNS, t("workspace.splitPreview"));
485
601
  const copyBtn = actionBtn(ICON_COPY, t("workspace.copyContent"));
486
- const downloadBtn = actionBtn(ICON_DOWNLOAD, t("workspace.download"));
487
602
  const saveBtn = actionBtn(ICON_SAVE, t("modal.save"));
603
+
604
+ // Split button shown for binary (non-previewable) tabs: text part opens
605
+ // with the OS default app, the chevron opens the "open with" dropdown.
606
+ const openGroup = el("div", "wv-action-group");
607
+ const openMainBtn = el("button", "wv-action wv-action--open");
608
+ openMainBtn.type = "button";
609
+ openMainBtn.title = t("workspace.openDefault");
610
+ const openIconEl = el("span", "wv-main-app-icon");
611
+ openIconEl.innerHTML = ICON_EXTERNAL;
612
+ const openLabel = el("span");
613
+ openLabel.textContent = t("workspace.open");
614
+ openMainBtn.appendChild(openIconEl);
615
+ openMainBtn.appendChild(openLabel);
616
+ openMainBtn.addEventListener("click", async () => {
617
+ const tab = activeTab();
618
+ if (!tab) return;
619
+ try {
620
+ await Workspace.openWithDefault(tab.entry);
621
+ } catch (err) {
622
+ console.error("open failed:", err);
623
+ Modal.toast(t("workspace.openWithFailed") + ": " + err.message, "error");
624
+ }
625
+ });
626
+ const openArrowBtn = actionBtn(ICON_CHEVRON_DOWN, t("workspace.openWith"));
627
+ openArrowBtn.addEventListener("click", (e) => {
628
+ e.stopPropagation();
629
+ const tab = activeTab();
630
+ if (!tab) return;
631
+ showOpenWithMenu(openArrowBtn, tab.entry);
632
+ });
633
+ openGroup.appendChild(openMainBtn);
634
+ openGroup.appendChild(openArrowBtn);
635
+
636
+ actionsEl.appendChild(toggleTreeBtn);
488
637
  actionsEl.appendChild(previewBtn);
638
+ actionsEl.appendChild(splitBtn);
489
639
  actionsEl.appendChild(copyBtn);
490
- actionsEl.appendChild(downloadBtn);
640
+ actionsEl.appendChild(openGroup);
491
641
  actionsEl.appendChild(saveBtn);
492
642
 
643
+ tabbar.appendChild(mobileBackBtn);
493
644
  tabbar.appendChild(tabsEl);
494
645
  tabbar.appendChild(actionsEl);
495
646
 
@@ -538,12 +689,39 @@ const WorkspaceView = (() => {
538
689
 
539
690
  function updateActions() {
540
691
  const tab = activeTab();
692
+ openGroup.style.display = tab ? "" : "none";
541
693
  previewBtn.style.display = tab && tab.kind === "text" && tab.isMd ? "" : "none";
694
+ splitBtn.style.display = tab && tab.kind === "text" && tab.isMd ? "" : "none";
542
695
  copyBtn.style.display = tab && tab.kind === "text" ? "" : "none";
543
696
  saveBtn.style.display = tab && tab.kind === "text" ? "" : "none";
544
- downloadBtn.style.display = tab ? "" : "none";
545
697
  previewBtn.classList.toggle("active", !!(tab && tab.previewMode));
698
+ splitBtn.classList.toggle("active", !!(tab && tab.splitMode));
546
699
  saveBtn.classList.toggle("dirty", !!(tab && tab.dirty));
700
+ refreshOpenIcon();
701
+ }
702
+
703
+ // Swap the main "Open" button icon to the default app's logo (asynchronous;
704
+ // falls back to the generic external-link icon). Guarded by tab id so a
705
+ // slow response never clobbers a newer tab.
706
+ let _openIconTabId = null;
707
+ function refreshOpenIcon() {
708
+ const tab = activeTab();
709
+ if (tab && tab.id === _openIconTabId) return;
710
+ _openIconTabId = tab ? tab.id : null;
711
+ openIconEl.innerHTML = ICON_EXTERNAL;
712
+ openMainBtn.title = t("workspace.openDefault");
713
+ if (!tab) return;
714
+ Workspace.fetchDefaultApp(tab.entry).then((app) => {
715
+ if (!app || _openIconTabId !== tab.id) return;
716
+ const img = document.createElement("img");
717
+ img.alt = "";
718
+ img.src = "/api/app-icon?path=" + encodeURIComponent(app.path);
719
+ img.addEventListener("error", () => {
720
+ if (_openIconTabId === tab.id) openIconEl.innerHTML = ICON_EXTERNAL;
721
+ });
722
+ openIconEl.replaceChildren(img);
723
+ openMainBtn.title = t("workspace.openWith", { app: app.name });
724
+ }).catch(() => { /* keep generic icon */ });
547
725
  }
548
726
 
549
727
  function activate(id) {
@@ -579,6 +757,7 @@ const WorkspaceView = (() => {
579
757
  } else {
580
758
  rebuildTabbar();
581
759
  }
760
+ if (tabs.length === 0) restoreAsideWidth();
582
761
  notify();
583
762
  }
584
763
 
@@ -600,12 +779,24 @@ const WorkspaceView = (() => {
600
779
  }
601
780
  }
602
781
 
603
- function togglePreview(tab) {
604
- tab.previewMode = !tab.previewMode;
605
- tab.el.dataset.mode = tab.previewMode ? "preview" : "edit";
606
- if (tab.previewMode && tab.editor) {
782
+ function applyMode(tab) {
783
+ tab.el.dataset.mode = tab.splitMode ? "split" : (tab.previewMode ? "preview" : "edit");
784
+ if ((tab.previewMode || tab.splitMode) && tab.editor) {
607
785
  tab.previewEl.innerHTML = CodeEditor.renderMarkdown(tab.editor.getContent());
608
786
  }
787
+ }
788
+
789
+ function togglePreview(tab) {
790
+ tab.previewMode = !tab.previewMode;
791
+ tab.splitMode = false;
792
+ applyMode(tab);
793
+ updateActions();
794
+ }
795
+
796
+ function toggleSplit(tab) {
797
+ tab.splitMode = !tab.splitMode;
798
+ tab.previewMode = false;
799
+ applyMode(tab);
609
800
  updateActions();
610
801
  }
611
802
 
@@ -627,6 +818,7 @@ const WorkspaceView = (() => {
627
818
  dirty: false,
628
819
  isMd,
629
820
  previewMode: false,
821
+ splitMode: false,
630
822
  previewEl: null,
631
823
  editor: null,
632
824
  imageUrl: null,
@@ -646,6 +838,9 @@ const WorkspaceView = (() => {
646
838
  tab.dirty = true;
647
839
  syncDirty(tab);
648
840
  }
841
+ if (tab.splitMode && tab.previewEl) {
842
+ tab.previewEl.innerHTML = CodeEditor.renderMarkdown(tab.editor.getContent());
843
+ }
649
844
  },
650
845
  onSave: () => doSave(tab),
651
846
  });
@@ -673,32 +868,118 @@ const WorkspaceView = (() => {
673
868
  };
674
869
  }
675
870
 
871
+ function buildPdfTab(entry, url) {
872
+ const pane = el("div", "wv-pane wv-pane--pdf");
873
+ const frame = document.createElement("iframe");
874
+ frame.title = entry.name;
875
+ frame.src = url;
876
+ pane.appendChild(frame);
877
+ return {
878
+ id: entry.path,
879
+ entry,
880
+ kind: "pdf",
881
+ el: pane,
882
+ dirty: false,
883
+ isMd: false,
884
+ previewMode: false,
885
+ previewEl: null,
886
+ editor: null,
887
+ imageUrl: null,
888
+ };
889
+ }
890
+
891
+ async function buildFallbackTab(entry) {
892
+ const pane = el("div", "wv-pane wv-pane--fallback");
893
+
894
+ const icon = el("div", "wv-fallback-icon");
895
+ icon.innerHTML = fileIconSvg(entry.name);
896
+
897
+ const name = el("div", "wv-fallback-name");
898
+ name.textContent = entry.name;
899
+
900
+ const hint = el("div", "wv-fallback-hint");
901
+ hint.textContent = t("workspace.fallbackHint");
902
+
903
+ const subhint = el("div", "wv-fallback-subhint");
904
+ subhint.textContent = t("workspace.fallbackSubHint");
905
+
906
+ pane.appendChild(icon);
907
+ pane.appendChild(name);
908
+ pane.appendChild(hint);
909
+ pane.appendChild(subhint);
910
+
911
+ return {
912
+ id: entry.path,
913
+ entry,
914
+ kind: "binary",
915
+ el: pane,
916
+ dirty: false,
917
+ isMd: false,
918
+ previewMode: false,
919
+ previewEl: null,
920
+ editor: null,
921
+ imageUrl: null,
922
+ };
923
+ }
924
+
925
+ function buildTableTab(entry, text) {
926
+ const pane = el("div", "wv-pane wv-pane--table");
927
+ const scroller = el("div", "wv-table-scroll");
928
+
929
+ const result = CodeEditor.renderTable(text, entry.name);
930
+ scroller.appendChild(result.table);
931
+ pane.appendChild(scroller);
932
+
933
+ if (result.truncated) {
934
+ const note = el("div", "wv-table-note");
935
+ note.textContent = t("workspace.tableTruncated").replace("%{rows}", result.rows);
936
+ pane.appendChild(note);
937
+ }
938
+
939
+ return {
940
+ id: entry.path,
941
+ entry,
942
+ kind: "table",
943
+ el: pane,
944
+ dirty: false,
945
+ isMd: false,
946
+ previewMode: false,
947
+ splitMode: false,
948
+ previewEl: null,
949
+ editor: null,
950
+ imageUrl: null,
951
+ };
952
+ }
953
+
676
954
  async function openTab(entry) {
677
- ensureAsideWidth();
678
955
  const existing = tabs.find((x) => x.id === entry.path);
679
956
  if (existing) {
680
957
  activate(existing.id);
681
958
  return;
682
959
  }
683
960
  const kind = CodeEditor.fileKind(entry.name);
684
- if (kind === "binary") {
685
- Modal.toast(t("workspace.previewUnsupported"), "info");
686
- return;
687
- }
688
961
  let tab = null;
689
962
  try {
690
963
  if (kind === "image") {
691
964
  const blob = await Workspace.fetchFileBlob(entry);
692
965
  tab = buildImageTab(entry, URL.createObjectURL(blob));
966
+ } else if (kind === "pdf") {
967
+ tab = buildPdfTab(entry, "/api/local-image?path=" + encodeURIComponent(Workspace.absPath(entry.path)));
968
+ } else if (kind === "binary") {
969
+ tab = await buildFallbackTab(entry);
970
+ } else if (kind === "table") {
971
+ const text = await Workspace.fetchFileText(entry);
972
+ tab = CodeEditor.looksBinary(text) ? await buildFallbackTab(entry) : buildTableTab(entry, text);
693
973
  } else {
694
974
  const text = await Workspace.fetchFileText(entry);
695
- tab = buildTextTab(entry, text);
975
+ tab = CodeEditor.looksBinary(text) ? await buildFallbackTab(entry) : buildTextTab(entry, text);
696
976
  }
697
977
  } catch (err) {
698
978
  console.error("preview failed:", err);
699
- Modal.toast(t("workspace.previewFailed"), "error");
979
+ Modal.toast(t("workspace.previewFailed") + ": " + err.message, "error");
700
980
  return;
701
981
  }
982
+ if (tabs.length === 0) ensureAsideWidth();
702
983
  tabs.push(tab);
703
984
  content.appendChild(tab.el);
704
985
  activate(tab.id);
@@ -709,6 +990,10 @@ const WorkspaceView = (() => {
709
990
  const tab = activeTab();
710
991
  if (tab && tab.isMd) togglePreview(tab);
711
992
  });
993
+ splitBtn.addEventListener("click", () => {
994
+ const tab = activeTab();
995
+ if (tab && tab.isMd) toggleSplit(tab);
996
+ });
712
997
  copyBtn.addEventListener("click", async () => {
713
998
  const tab = activeTab();
714
999
  if (!tab || !tab.editor) return;
@@ -719,16 +1004,13 @@ const WorkspaceView = (() => {
719
1004
  Modal.toast(t("workspace.copyFailed"), "error");
720
1005
  }
721
1006
  });
722
- downloadBtn.addEventListener("click", () => {
723
- const tab = activeTab();
724
- if (tab) downloadFile(tab.entry);
725
- });
726
1007
  saveBtn.addEventListener("click", () => {
727
1008
  const tab = activeTab();
728
1009
  if (tab) doSave(tab);
729
1010
  });
730
1011
 
731
1012
  function destroy() {
1013
+ restoreAsideWidth();
732
1014
  tabs.forEach((tab) => {
733
1015
  if (tab.editor) tab.editor.destroy();
734
1016
  if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
@@ -767,6 +1049,7 @@ const WorkspaceView = (() => {
767
1049
  const onUp = () => {
768
1050
  if (!dragging) return;
769
1051
  dragging = false;
1052
+ handle.classList.remove("active");
770
1053
  document.body.style.cursor = "";
771
1054
  document.body.style.userSelect = "";
772
1055
  document.removeEventListener("mousemove", onMove);
@@ -780,6 +1063,7 @@ const WorkspaceView = (() => {
780
1063
  dragging = true;
781
1064
  startX = e.clientX;
782
1065
  startW = treePane.getBoundingClientRect().width;
1066
+ handle.classList.add("active");
783
1067
  document.body.style.cursor = "col-resize";
784
1068
  document.body.style.userSelect = "none";
785
1069
  document.addEventListener("mousemove", onMove);
@@ -833,10 +1117,22 @@ const WorkspaceView = (() => {
833
1117
  });
834
1118
  tree.addEventListener("mouseleave", () => updateIndentGuides(null));
835
1119
 
836
- const viewer = createViewer(({ count, activeId }) => {
837
- root.classList.toggle("has-open-files", count > 0);
838
- setActiveFilePath(activeId);
839
- });
1120
+ const viewer = createViewer(
1121
+ ({ count, activeId }) => {
1122
+ root.classList.toggle("has-open-files", count > 0);
1123
+ if (activeId) root.classList.remove("mobile-tree-visible");
1124
+ if (!count) root.classList.remove("mobile-tree-visible");
1125
+ setActiveFilePath(activeId);
1126
+ },
1127
+ () => {
1128
+ const hidden = root.classList.toggle("tree-hidden");
1129
+ return hidden;
1130
+ },
1131
+ () => {
1132
+ root.classList.add("mobile-tree-visible");
1133
+ }
1134
+ );
1135
+ _activeViewer = viewer;
840
1136
 
841
1137
  // ── File search ─────────────────────────────────────────────────────
842
1138
 
@@ -970,21 +1266,77 @@ const WorkspaceView = (() => {
970
1266
  container.appendChild(root);
971
1267
 
972
1268
  loadRoot(tree, viewer.openTab);
1269
+
1270
+ // The tree is otherwise loaded only once per mount. Reload it when the
1271
+ // working directory changes (status-bar dir switch) and when the agent
1272
+ // finishes a task (it may have created/edited files under the dir).
1273
+ if (_activeOff) _activeOff();
1274
+ const offSessionChanged = Workspace.on("workspace:sessionChanged", () => {
1275
+ treeState.indexPromise = null;
1276
+ loadRoot(tree, viewer.openTab);
1277
+ });
1278
+ const offTaskComplete = Workspace.on("workspace:taskCompleted", (payload) => {
1279
+ if (payload && payload.sessionId === Workspace.state.sessionId) {
1280
+ treeState.indexPromise = null;
1281
+ loadRoot(tree, viewer.openTab);
1282
+ }
1283
+ });
1284
+ const offAll = () => { offSessionChanged(); offTaskComplete(); };
1285
+ _activeOff = offAll;
1286
+
973
1287
  return {
974
1288
  destroy() {
1289
+ if (_activeOff === offAll) {
1290
+ offAll();
1291
+ _activeOff = null;
1292
+ }
975
1293
  clearTimeout(searchTimer);
976
1294
  treeState.treeEl = null;
977
1295
  treeState.activePath = null;
978
1296
  treeState.indexPromise = null;
979
1297
  lastGuidedRow = null;
1298
+ if (_activeViewer === viewer) _activeViewer = null;
980
1299
  viewer.destroy();
981
1300
  },
982
1301
  };
983
1302
  }
984
1303
 
985
- return { mount };
1304
+ // Convert an absolute path (from a file:// link in chat) into a tree-style
1305
+ // entry. Paths inside the working directory become relative so tab
1306
+ // de-duplication matches tabs opened from the tree; paths outside stay
1307
+ // absolute (the store treats absolute paths as-is).
1308
+ function toEntry(absPath) {
1309
+ const wd = (Workspace.state.workingDir || "").replace(/\/+$/, "");
1310
+ const name = absPath.split("/").pop() || absPath;
1311
+ if (wd && absPath.startsWith(wd + "/")) {
1312
+ return { name, path: absPath.slice(wd.length + 1), type: "file", size: 0 };
1313
+ }
1314
+ return { name, path: absPath, type: "file", size: 0 };
1315
+ }
1316
+
1317
+ // Open a file:// link target in the Files tab of the session aside.
1318
+ // Returns true when the viewer handled it; false lets the caller fall back
1319
+ // to the system open action. HTML files open in the preview panel instead
1320
+ // (rendered as a page, not shown as source).
1321
+ function openFile(absPath) {
1322
+ if (!absPath || !Workspace.state.hasSession()) return false;
1323
+ const entry = toEntry(absPath);
1324
+ if (/\.html?$/i.test(entry.name) && window.Clacky && Clacky.Preview) {
1325
+ return Clacky.Preview.open(entry.path);
1326
+ }
1327
+ if (window.Clacky && Clacky.Aside) Clacky.Aside.open();
1328
+ const tabBtn = document.querySelector('.aside-tab[data-tab="files"]');
1329
+ if (tabBtn) tabBtn.click();
1330
+ if (!_activeViewer) return false;
1331
+ _activeViewer.openTab(entry);
1332
+ return true;
1333
+ }
1334
+
1335
+ return { mount, openFile };
986
1336
  })();
987
1337
 
1338
+ if (window.Clacky) Clacky.WorkspaceView = WorkspaceView;
1339
+
988
1340
  // Files is a built-in tab: visible for every session, after ext-studio
989
1341
  // publish (20) and before git (30) / time-machine (40).
990
1342
  if (window.Clacky && Clacky.ext) {