openclacky 1.5.13 → 1.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +53 -98
  6. data/lib/clacky/agent.rb +477 -297
  7. data/lib/clacky/agent_config.rb +5 -3
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +7 -0
  10. data/lib/clacky/brand_config.rb +27 -19
  11. data/lib/clacky/cli.rb +92 -12
  12. data/lib/clacky/cli_guidance.rb +48 -0
  13. data/lib/clacky/client.rb +2 -2
  14. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  19. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  20. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  21. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  22. data/lib/clacky/extension/api_extension.rb +18 -6
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/json_ui_controller.rb +4 -2
  25. data/lib/clacky/media/openai_compat.rb +70 -30
  26. data/lib/clacky/message_format/bedrock.rb +6 -1
  27. data/lib/clacky/message_format/open_ai.rb +22 -5
  28. data/lib/clacky/message_format/open_ai_responses.rb +7 -3
  29. data/lib/clacky/plain_ui_controller.rb +1 -1
  30. data/lib/clacky/prompts/base.md +1 -1
  31. data/lib/clacky/providers.rb +44 -13
  32. data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
  33. data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
  34. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
  35. data/lib/clacky/search_config.rb +3 -3
  36. data/lib/clacky/server/channel/channel_manager.rb +25 -7
  37. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  38. data/lib/clacky/server/dir_picker.rb +67 -4
  39. data/lib/clacky/server/git_panel.rb +10 -2
  40. data/lib/clacky/server/http_server.rb +391 -75
  41. data/lib/clacky/server/preview.rb +351 -0
  42. data/lib/clacky/server/session_registry.rb +4 -0
  43. data/lib/clacky/server/web_ui_controller.rb +10 -5
  44. data/lib/clacky/session_manager.rb +52 -1
  45. data/lib/clacky/skill.rb +48 -0
  46. data/lib/clacky/tools/browser.rb +176 -19
  47. data/lib/clacky/tools/web_search.rb +91 -8
  48. data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
  49. data/lib/clacky/ui2/components/input_area.rb +37 -4
  50. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  51. data/lib/clacky/ui2/screen_buffer.rb +1 -0
  52. data/lib/clacky/ui2/ui_controller.rb +72 -12
  53. data/lib/clacky/ui_interface.rb +1 -1
  54. data/lib/clacky/utils/file_processor.rb +19 -7
  55. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  56. data/lib/clacky/utils/model_pricing.rb +118 -68
  57. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  58. data/lib/clacky/version.rb +1 -1
  59. data/lib/clacky/web/app.css +998 -168
  60. data/lib/clacky/web/app.js +19 -0
  61. data/lib/clacky/web/components/chat-navigator.js +489 -202
  62. data/lib/clacky/web/components/code-editor.js +191 -5
  63. data/lib/clacky/web/components/composer.js +3 -1
  64. data/lib/clacky/web/components/datepicker.js +19 -19
  65. data/lib/clacky/web/components/mentions.js +11 -14
  66. data/lib/clacky/web/components/model-picker.js +66 -23
  67. data/lib/clacky/web/components/quote-select.js +341 -0
  68. data/lib/clacky/web/core/aside.js +157 -8
  69. data/lib/clacky/web/core/ext.js +16 -0
  70. data/lib/clacky/web/features/billing/view.js +75 -21
  71. data/lib/clacky/web/features/extensions/store.js +4 -1
  72. data/lib/clacky/web/features/model-tester/store.js +37 -4
  73. data/lib/clacky/web/features/skills/store.js +18 -1
  74. data/lib/clacky/web/features/skills/view.js +128 -7
  75. data/lib/clacky/web/features/workspace/store.js +80 -7
  76. data/lib/clacky/web/features/workspace/view.js +474 -43
  77. data/lib/clacky/web/i18n.js +158 -23
  78. data/lib/clacky/web/index.html +57 -29
  79. data/lib/clacky/web/sessions.js +517 -127
  80. data/lib/clacky/web/settings.js +155 -6
  81. data/lib/clacky/web/utils.js +34 -0
  82. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  83. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  84. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  85. data/lib/clacky/web/ws-dispatcher.js +161 -4
  86. data/lib/clacky.rb +2 -0
  87. metadata +13 -1
@@ -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);
@@ -192,7 +197,7 @@ const WorkspaceView = (() => {
192
197
 
193
198
  // ── File tree ───────────────────────────────────────────────────────────
194
199
 
195
- const treeState = { activePath: null, treeEl: null, indexPromise: null };
200
+ const treeState = { activePath: null, treeEl: null, indexPromise: null, loadPromise: null };
196
201
 
197
202
  function highlightActiveRow() {
198
203
  const tree = treeState.treeEl;
@@ -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;
@@ -441,29 +534,86 @@ const WorkspaceView = (() => {
441
534
  }
442
535
  }
443
536
 
537
+ // Reveal a path in the tree: expand every parent folder on the way down and
538
+ // mark the file's row active, so a file opened from a chat link shows where it
539
+ // lives instead of staying hidden inside a collapsed folder. A level's rows
540
+ // only exist once its parent has been fetched, so the walk is sequential.
541
+ async function revealTreePath(relPath) {
542
+ const tree = treeState.treeEl;
543
+ if (!tree || !relPath) return;
544
+ // Files outside the working dir have no row to reveal.
545
+ if (relPath.startsWith("/") || relPath.startsWith("~") || /^[A-Za-z]:/.test(relPath)) return;
546
+ if (treeState.loadPromise) await treeState.loadPromise;
547
+
548
+ const parts = relPath.split("/").filter(Boolean);
549
+ for (let depth = 0; depth < parts.length - 1; depth++) {
550
+ const dirPath = parts.slice(0, depth + 1).join("/");
551
+ const row = tree.querySelector(`.wt-row[data-path="${CSS.escape(dirPath)}"]`);
552
+ const children = row && row.parentElement ? row.parentElement.querySelector(".wt-children") : null;
553
+ if (!children) return;
554
+ const caret = row.querySelector(".wt-caret");
555
+ if (!caret || caret.classList.contains("open")) continue;
556
+ await toggleDir(
557
+ { name: parts[depth], path: dirPath, type: "dir" },
558
+ caret,
559
+ children,
560
+ row.querySelector(".wt-icon"),
561
+ _activeViewer && _activeViewer.openTab
562
+ );
563
+ }
564
+ setActiveFilePath(relPath);
565
+ }
566
+
444
567
  // ── Inline tabbed viewer ────────────────────────────────────────────────
445
568
 
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
- }
569
+ const VIEWER_ASIDE_WIDTH = 700;
570
+
571
+ // Active viewer instance (one per mounted Files tab). Kept at module level
572
+ // so openFile() — called from chat file:// link clicks — can reach the
573
+ // viewer without waiting for a DOM query or a custom event round-trip.
574
+ let _activeViewer = null;
575
+ // Unsubscriber for the active mount's refresh listeners. The aside can
576
+ // re-mount the Files tab without running the previous teardown (new-session
577
+ // page / same-session re-render), so the next mount releases these first to
578
+ // avoid stacking duplicate reloads.
579
+ let _activeOff = null;
580
+
581
+ // Binary formats the viewer cannot render are handed straight to the OS
582
+ // default application, so clicking a .docx link opens Word instead of parking
583
+ // the user on a "can't preview this" page with a button they still have to
584
+ // press. Extensions the system would run or mount rather than display stay
585
+ // behind the fallback page: clicking a link is not consent to run a program.
586
+ const NO_AUTO_OPEN_EXTS = new Set([
587
+ "exe", "msi", "dmg", "iso", "jar", "war", "app", "command",
588
+ "sh", "bash", "zsh", "bat", "cmd", "ps1", "scpt", "workflow",
589
+ "so", "dylib", "dll", "class", "pyc", "bin", "wasm",
590
+ ]);
591
+
592
+ // Only reached for entries CodeEditor classified as binary, so an extension
593
+ // missing here means the OS opens it in a viewer rather than running it.
594
+ function autoOpenWithSystem(filename) {
595
+ const ext = (filename || "").split(".").pop().toLowerCase();
596
+ return !!ext && !NO_AUTO_OPEN_EXTS.has(ext);
460
597
  }
461
598
 
462
- function createViewer(onTabsChanged) {
599
+ function createViewer(onTabsChanged, onToggleTree, onShowMobileTree) {
463
600
  const tabs = [];
464
601
  let activeId = null;
602
+ let releaseAsideWidth = null;
465
603
  const notify = () => { if (onTabsChanged) onTabsChanged({ count: tabs.length, activeId }); };
466
604
 
605
+ function ensureAsideWidth() {
606
+ if (releaseAsideWidth || !window.Clacky || !Clacky.Aside || !Clacky.Aside.requestWidth) return;
607
+ releaseAsideWidth = Clacky.Aside.requestWidth(VIEWER_ASIDE_WIDTH);
608
+ }
609
+
610
+ function restoreAsideWidth() {
611
+ if (!releaseAsideWidth) return;
612
+ const release = releaseAsideWidth;
613
+ releaseAsideWidth = null;
614
+ release();
615
+ }
616
+
467
617
  const root = el("div", "wv-viewer");
468
618
 
469
619
  const tabbar = el("div", "wv-tabbar");
@@ -477,19 +627,63 @@ const WorkspaceView = (() => {
477
627
  const btn = el("button", "wv-action" + (cls ? " " + cls : ""));
478
628
  btn.type = "button";
479
629
  btn.title = title;
630
+ btn.setAttribute("aria-label", title);
480
631
  btn.innerHTML = icon;
481
632
  return btn;
482
633
  }
483
634
 
635
+ const toggleTreeBtn = actionBtn(ICON_PANEL_LEFT, t("workspace.toggleTree"));
636
+ toggleTreeBtn.classList.add("wv-toggle-tree", "active");
637
+ toggleTreeBtn.addEventListener("click", () => {
638
+ const hidden = onToggleTree ? onToggleTree() : false;
639
+ toggleTreeBtn.classList.toggle("active", !hidden);
640
+ });
641
+
642
+ const mobileBackBtn = actionBtn(ICON_ARROW_LEFT, t("workspace.backToFiles"), "wv-mobile-back");
643
+ mobileBackBtn.addEventListener("click", () => {
644
+ if (onShowMobileTree) onShowMobileTree();
645
+ });
646
+
484
647
  const previewBtn = actionBtn(ICON_EYE, t("workspace.preview"));
648
+ const splitBtn = actionBtn(ICON_COLUMNS, t("workspace.splitPreview"));
485
649
  const copyBtn = actionBtn(ICON_COPY, t("workspace.copyContent"));
486
- const downloadBtn = actionBtn(ICON_DOWNLOAD, t("workspace.download"));
487
650
  const saveBtn = actionBtn(ICON_SAVE, t("modal.save"));
651
+
652
+ // Split button shown for binary (non-previewable) tabs: text part opens
653
+ // with the OS default app, the chevron opens the "open with" dropdown.
654
+ const openGroup = el("div", "wv-action-group");
655
+ const openMainBtn = el("button", "wv-action wv-action--open");
656
+ openMainBtn.type = "button";
657
+ openMainBtn.title = t("workspace.openDefault");
658
+ const openIconEl = el("span", "wv-main-app-icon");
659
+ openIconEl.innerHTML = ICON_EXTERNAL;
660
+ const openLabel = el("span");
661
+ openLabel.textContent = t("workspace.open");
662
+ openMainBtn.appendChild(openIconEl);
663
+ openMainBtn.appendChild(openLabel);
664
+ openMainBtn.addEventListener("click", async () => {
665
+ const tab = activeTab();
666
+ if (!tab) return;
667
+ await openWithSystem(tab.entry);
668
+ });
669
+ const openArrowBtn = actionBtn(ICON_CHEVRON_DOWN, t("workspace.openWith"));
670
+ openArrowBtn.addEventListener("click", (e) => {
671
+ e.stopPropagation();
672
+ const tab = activeTab();
673
+ if (!tab) return;
674
+ showOpenWithMenu(openArrowBtn, tab.entry);
675
+ });
676
+ openGroup.appendChild(openMainBtn);
677
+ openGroup.appendChild(openArrowBtn);
678
+
679
+ actionsEl.appendChild(toggleTreeBtn);
488
680
  actionsEl.appendChild(previewBtn);
681
+ actionsEl.appendChild(splitBtn);
489
682
  actionsEl.appendChild(copyBtn);
490
- actionsEl.appendChild(downloadBtn);
683
+ actionsEl.appendChild(openGroup);
491
684
  actionsEl.appendChild(saveBtn);
492
685
 
686
+ tabbar.appendChild(mobileBackBtn);
493
687
  tabbar.appendChild(tabsEl);
494
688
  tabbar.appendChild(actionsEl);
495
689
 
@@ -498,6 +692,19 @@ const WorkspaceView = (() => {
498
692
 
499
693
  const activeTab = () => tabs.find((x) => x.id === activeId) || null;
500
694
 
695
+ // Hand a file to the OS default application. Returns whether it worked so
696
+ // callers can decide what to tell the user.
697
+ async function openWithSystem(entry) {
698
+ try {
699
+ await Workspace.openWithDefault(entry);
700
+ return true;
701
+ } catch (err) {
702
+ console.error("open failed:", err);
703
+ Modal.toast(t("workspace.openWithFailed") + ": " + err.message, "error");
704
+ return false;
705
+ }
706
+ }
707
+
501
708
  function rebuildTabbar() {
502
709
  tabsEl.replaceChildren();
503
710
  tabs.forEach((tab) => {
@@ -538,12 +745,39 @@ const WorkspaceView = (() => {
538
745
 
539
746
  function updateActions() {
540
747
  const tab = activeTab();
748
+ openGroup.style.display = tab ? "" : "none";
541
749
  previewBtn.style.display = tab && tab.kind === "text" && tab.isMd ? "" : "none";
750
+ splitBtn.style.display = tab && tab.kind === "text" && tab.isMd ? "" : "none";
542
751
  copyBtn.style.display = tab && tab.kind === "text" ? "" : "none";
543
752
  saveBtn.style.display = tab && tab.kind === "text" ? "" : "none";
544
- downloadBtn.style.display = tab ? "" : "none";
545
753
  previewBtn.classList.toggle("active", !!(tab && tab.previewMode));
754
+ splitBtn.classList.toggle("active", !!(tab && tab.splitMode));
546
755
  saveBtn.classList.toggle("dirty", !!(tab && tab.dirty));
756
+ refreshOpenIcon();
757
+ }
758
+
759
+ // Swap the main "Open" button icon to the default app's logo (asynchronous;
760
+ // falls back to the generic external-link icon). Guarded by tab id so a
761
+ // slow response never clobbers a newer tab.
762
+ let _openIconTabId = null;
763
+ function refreshOpenIcon() {
764
+ const tab = activeTab();
765
+ if (tab && tab.id === _openIconTabId) return;
766
+ _openIconTabId = tab ? tab.id : null;
767
+ openIconEl.innerHTML = ICON_EXTERNAL;
768
+ openMainBtn.title = t("workspace.openDefault");
769
+ if (!tab) return;
770
+ Workspace.fetchDefaultApp(tab.entry).then((app) => {
771
+ if (!app || _openIconTabId !== tab.id) return;
772
+ const img = document.createElement("img");
773
+ img.alt = "";
774
+ img.src = "/api/app-icon?path=" + encodeURIComponent(app.path);
775
+ img.addEventListener("error", () => {
776
+ if (_openIconTabId === tab.id) openIconEl.innerHTML = ICON_EXTERNAL;
777
+ });
778
+ openIconEl.replaceChildren(img);
779
+ openMainBtn.title = t("workspace.openWith", { app: app.name });
780
+ }).catch(() => { /* keep generic icon */ });
547
781
  }
548
782
 
549
783
  function activate(id) {
@@ -579,6 +813,7 @@ const WorkspaceView = (() => {
579
813
  } else {
580
814
  rebuildTabbar();
581
815
  }
816
+ if (tabs.length === 0) restoreAsideWidth();
582
817
  notify();
583
818
  }
584
819
 
@@ -600,12 +835,24 @@ const WorkspaceView = (() => {
600
835
  }
601
836
  }
602
837
 
603
- function togglePreview(tab) {
604
- tab.previewMode = !tab.previewMode;
605
- tab.el.dataset.mode = tab.previewMode ? "preview" : "edit";
606
- if (tab.previewMode && tab.editor) {
838
+ function applyMode(tab) {
839
+ tab.el.dataset.mode = tab.splitMode ? "split" : (tab.previewMode ? "preview" : "edit");
840
+ if ((tab.previewMode || tab.splitMode) && tab.editor) {
607
841
  tab.previewEl.innerHTML = CodeEditor.renderMarkdown(tab.editor.getContent());
608
842
  }
843
+ }
844
+
845
+ function togglePreview(tab) {
846
+ tab.previewMode = !tab.previewMode;
847
+ tab.splitMode = false;
848
+ applyMode(tab);
849
+ updateActions();
850
+ }
851
+
852
+ function toggleSplit(tab) {
853
+ tab.splitMode = !tab.splitMode;
854
+ tab.previewMode = false;
855
+ applyMode(tab);
609
856
  updateActions();
610
857
  }
611
858
 
@@ -627,6 +874,7 @@ const WorkspaceView = (() => {
627
874
  dirty: false,
628
875
  isMd,
629
876
  previewMode: false,
877
+ splitMode: false,
630
878
  previewEl: null,
631
879
  editor: null,
632
880
  imageUrl: null,
@@ -646,6 +894,9 @@ const WorkspaceView = (() => {
646
894
  tab.dirty = true;
647
895
  syncDirty(tab);
648
896
  }
897
+ if (tab.splitMode && tab.previewEl) {
898
+ tab.previewEl.innerHTML = CodeEditor.renderMarkdown(tab.editor.getContent());
899
+ }
649
900
  },
650
901
  onSave: () => doSave(tab),
651
902
  });
@@ -673,42 +924,147 @@ const WorkspaceView = (() => {
673
924
  };
674
925
  }
675
926
 
927
+ function buildPdfTab(entry, url) {
928
+ const pane = el("div", "wv-pane wv-pane--pdf");
929
+ const frame = document.createElement("iframe");
930
+ frame.title = entry.name;
931
+ frame.src = url;
932
+ pane.appendChild(frame);
933
+ return {
934
+ id: entry.path,
935
+ entry,
936
+ kind: "pdf",
937
+ el: pane,
938
+ dirty: false,
939
+ isMd: false,
940
+ previewMode: false,
941
+ previewEl: null,
942
+ editor: null,
943
+ imageUrl: null,
944
+ };
945
+ }
946
+
947
+ async function buildFallbackTab(entry) {
948
+ const pane = el("div", "wv-pane wv-pane--fallback");
949
+
950
+ const icon = el("div", "wv-fallback-icon");
951
+ icon.innerHTML = fileIconSvg(entry.name);
952
+
953
+ const name = el("div", "wv-fallback-name");
954
+ name.textContent = entry.name;
955
+
956
+ const hint = el("div", "wv-fallback-hint");
957
+ hint.textContent = t("workspace.fallbackHint");
958
+
959
+ const subhint = el("div", "wv-fallback-subhint");
960
+ subhint.textContent = t("workspace.fallbackSubHint");
961
+
962
+ pane.appendChild(icon);
963
+ pane.appendChild(name);
964
+ pane.appendChild(hint);
965
+ pane.appendChild(subhint);
966
+
967
+ return {
968
+ id: entry.path,
969
+ entry,
970
+ kind: "binary",
971
+ el: pane,
972
+ subhintEl: subhint,
973
+ dirty: false,
974
+ isMd: false,
975
+ previewMode: false,
976
+ previewEl: null,
977
+ editor: null,
978
+ imageUrl: null,
979
+ };
980
+ }
981
+
982
+ function buildTableTab(entry, text) {
983
+ const pane = el("div", "wv-pane wv-pane--table");
984
+ const scroller = el("div", "wv-table-scroll");
985
+
986
+ const result = CodeEditor.renderTable(text, entry.name);
987
+ scroller.appendChild(result.table);
988
+ pane.appendChild(scroller);
989
+
990
+ if (result.truncated) {
991
+ const note = el("div", "wv-table-note");
992
+ note.textContent = t("workspace.tableTruncated").replace("%{rows}", result.rows);
993
+ pane.appendChild(note);
994
+ }
995
+
996
+ return {
997
+ id: entry.path,
998
+ entry,
999
+ kind: "table",
1000
+ el: pane,
1001
+ dirty: false,
1002
+ isMd: false,
1003
+ previewMode: false,
1004
+ splitMode: false,
1005
+ previewEl: null,
1006
+ editor: null,
1007
+ imageUrl: null,
1008
+ };
1009
+ }
1010
+
676
1011
  async function openTab(entry) {
677
- ensureAsideWidth();
678
1012
  const existing = tabs.find((x) => x.id === entry.path);
679
1013
  if (existing) {
680
1014
  activate(existing.id);
681
1015
  return;
682
1016
  }
683
1017
  const kind = CodeEditor.fileKind(entry.name);
684
- if (kind === "binary") {
685
- Modal.toast(t("workspace.previewUnsupported"), "info");
686
- return;
687
- }
688
1018
  let tab = null;
1019
+ let handOff = false;
689
1020
  try {
690
1021
  if (kind === "image") {
691
1022
  const blob = await Workspace.fetchFileBlob(entry);
692
1023
  tab = buildImageTab(entry, URL.createObjectURL(blob));
1024
+ } else if (kind === "pdf") {
1025
+ tab = buildPdfTab(entry, "/api/local-image?path=" + encodeURIComponent(Workspace.absPath(entry.path)));
1026
+ } else if (kind === "binary") {
1027
+ // The extension already says the browser cannot render this, so the
1028
+ // OS application the user was going to pick is launched below without
1029
+ // making them click a second time. Files that only look binary once
1030
+ // their bytes are sniffed are left alone: that is a damaged or
1031
+ // mislabelled file, not a format the system has a viewer for.
1032
+ handOff = autoOpenWithSystem(entry.name);
1033
+ tab = await buildFallbackTab(entry);
1034
+ } else if (kind === "table") {
1035
+ const text = await Workspace.fetchFileText(entry);
1036
+ tab = CodeEditor.looksBinary(text) ? await buildFallbackTab(entry) : buildTableTab(entry, text);
693
1037
  } else {
694
1038
  const text = await Workspace.fetchFileText(entry);
695
- tab = buildTextTab(entry, text);
1039
+ tab = CodeEditor.looksBinary(text) ? await buildFallbackTab(entry) : buildTextTab(entry, text);
696
1040
  }
697
1041
  } catch (err) {
698
1042
  console.error("preview failed:", err);
699
- Modal.toast(t("workspace.previewFailed"), "error");
1043
+ Modal.toast(t("workspace.previewFailed") + ": " + err.message, "error");
700
1044
  return;
701
1045
  }
1046
+ if (tabs.length === 0) ensureAsideWidth();
702
1047
  tabs.push(tab);
703
1048
  content.appendChild(tab.el);
704
1049
  activate(tab.id);
705
1050
  notify();
1051
+
1052
+ // Nothing to show in the pane, so skip the extra click and launch the
1053
+ // application the user was going to pick anyway.
1054
+ if (handOff) {
1055
+ const opened = await openWithSystem(tab.entry);
1056
+ if (opened && tab.subhintEl) tab.subhintEl.textContent = t("workspace.openedInSystem");
1057
+ }
706
1058
  }
707
1059
 
708
1060
  previewBtn.addEventListener("click", () => {
709
1061
  const tab = activeTab();
710
1062
  if (tab && tab.isMd) togglePreview(tab);
711
1063
  });
1064
+ splitBtn.addEventListener("click", () => {
1065
+ const tab = activeTab();
1066
+ if (tab && tab.isMd) toggleSplit(tab);
1067
+ });
712
1068
  copyBtn.addEventListener("click", async () => {
713
1069
  const tab = activeTab();
714
1070
  if (!tab || !tab.editor) return;
@@ -719,16 +1075,13 @@ const WorkspaceView = (() => {
719
1075
  Modal.toast(t("workspace.copyFailed"), "error");
720
1076
  }
721
1077
  });
722
- downloadBtn.addEventListener("click", () => {
723
- const tab = activeTab();
724
- if (tab) downloadFile(tab.entry);
725
- });
726
1078
  saveBtn.addEventListener("click", () => {
727
1079
  const tab = activeTab();
728
1080
  if (tab) doSave(tab);
729
1081
  });
730
1082
 
731
1083
  function destroy() {
1084
+ restoreAsideWidth();
732
1085
  tabs.forEach((tab) => {
733
1086
  if (tab.editor) tab.editor.destroy();
734
1087
  if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
@@ -767,6 +1120,7 @@ const WorkspaceView = (() => {
767
1120
  const onUp = () => {
768
1121
  if (!dragging) return;
769
1122
  dragging = false;
1123
+ handle.classList.remove("active");
770
1124
  document.body.style.cursor = "";
771
1125
  document.body.style.userSelect = "";
772
1126
  document.removeEventListener("mousemove", onMove);
@@ -780,6 +1134,7 @@ const WorkspaceView = (() => {
780
1134
  dragging = true;
781
1135
  startX = e.clientX;
782
1136
  startW = treePane.getBoundingClientRect().width;
1137
+ handle.classList.add("active");
783
1138
  document.body.style.cursor = "col-resize";
784
1139
  document.body.style.userSelect = "none";
785
1140
  document.addEventListener("mousemove", onMove);
@@ -833,10 +1188,22 @@ const WorkspaceView = (() => {
833
1188
  });
834
1189
  tree.addEventListener("mouseleave", () => updateIndentGuides(null));
835
1190
 
836
- const viewer = createViewer(({ count, activeId }) => {
837
- root.classList.toggle("has-open-files", count > 0);
838
- setActiveFilePath(activeId);
839
- });
1191
+ const viewer = createViewer(
1192
+ ({ count, activeId }) => {
1193
+ root.classList.toggle("has-open-files", count > 0);
1194
+ if (activeId) root.classList.remove("mobile-tree-visible");
1195
+ if (!count) root.classList.remove("mobile-tree-visible");
1196
+ setActiveFilePath(activeId);
1197
+ },
1198
+ () => {
1199
+ const hidden = root.classList.toggle("tree-hidden");
1200
+ return hidden;
1201
+ },
1202
+ () => {
1203
+ root.classList.add("mobile-tree-visible");
1204
+ }
1205
+ );
1206
+ _activeViewer = viewer;
840
1207
 
841
1208
  // ── File search ─────────────────────────────────────────────────────
842
1209
 
@@ -951,10 +1318,9 @@ const WorkspaceView = (() => {
951
1318
  });
952
1319
 
953
1320
  refresh.addEventListener("click", () => {
954
- treeState.indexPromise = null;
955
1321
  searchInput.value = "";
956
1322
  showTree();
957
- loadRoot(tree, viewer.openTab);
1323
+ reloadTree();
958
1324
  });
959
1325
 
960
1326
  treePane.appendChild(bar);
@@ -969,22 +1335,87 @@ const WorkspaceView = (() => {
969
1335
  root.appendChild(viewer.root);
970
1336
  container.appendChild(root);
971
1337
 
972
- loadRoot(tree, viewer.openTab);
1338
+ // One entry point for (re)loading the root listing. openFile() waits on the
1339
+ // same promise before revealing a path, so a link clicked while the tree is
1340
+ // still loading does not miss its row.
1341
+ function reloadTree() {
1342
+ treeState.indexPromise = null;
1343
+ treeState.loadPromise = loadRoot(tree, viewer.openTab);
1344
+ }
1345
+
1346
+ reloadTree();
1347
+
1348
+ // The tree is otherwise loaded only once per mount. Reload it when the
1349
+ // working directory changes (status-bar dir switch) and when the agent
1350
+ // finishes a task (it may have created/edited files under the dir).
1351
+ if (_activeOff) _activeOff();
1352
+ const offSessionChanged = Workspace.on("workspace:sessionChanged", () => {
1353
+ reloadTree();
1354
+ });
1355
+ const offTaskComplete = Workspace.on("workspace:taskCompleted", (payload) => {
1356
+ if (payload && payload.sessionId === Workspace.state.sessionId) {
1357
+ reloadTree();
1358
+ }
1359
+ });
1360
+ const offAll = () => { offSessionChanged(); offTaskComplete(); };
1361
+ _activeOff = offAll;
1362
+
973
1363
  return {
974
1364
  destroy() {
1365
+ if (_activeOff === offAll) {
1366
+ offAll();
1367
+ _activeOff = null;
1368
+ }
975
1369
  clearTimeout(searchTimer);
976
1370
  treeState.treeEl = null;
977
1371
  treeState.activePath = null;
978
1372
  treeState.indexPromise = null;
1373
+ treeState.loadPromise = null;
979
1374
  lastGuidedRow = null;
1375
+ if (_activeViewer === viewer) _activeViewer = null;
980
1376
  viewer.destroy();
981
1377
  },
982
1378
  };
983
1379
  }
984
1380
 
985
- return { mount };
1381
+ // Convert an absolute path (from a file:// link in chat) into a tree-style
1382
+ // entry. Paths inside the working directory become relative so tab
1383
+ // de-duplication matches tabs opened from the tree; paths outside stay
1384
+ // absolute (the store treats absolute paths as-is).
1385
+ function toEntry(absPath) {
1386
+ const wd = (Workspace.state.workingDir || "").replace(/\/+$/, "");
1387
+ const name = absPath.split("/").pop() || absPath;
1388
+ if (wd && absPath.startsWith(wd + "/")) {
1389
+ return { name, path: absPath.slice(wd.length + 1), type: "file", size: 0 };
1390
+ }
1391
+ return { name, path: absPath, type: "file", size: 0 };
1392
+ }
1393
+
1394
+ // Open a file:// link target in the Files tab of the session aside.
1395
+ // Returns true when the viewer handled it; false lets the caller fall back
1396
+ // to the system open action. HTML files open in the preview panel instead
1397
+ // (rendered as a page, not shown as source).
1398
+ function openFile(absPath) {
1399
+ if (!absPath || !Workspace.state.hasSession()) return false;
1400
+ const entry = toEntry(absPath);
1401
+ if (/\.html?$/i.test(entry.name) && window.Clacky && Clacky.Preview) {
1402
+ return Clacky.Preview.open(entry.path);
1403
+ }
1404
+ if (window.Clacky && Clacky.Aside) Clacky.Aside.open();
1405
+ const tabBtn = document.querySelector('.aside-tab[data-tab="files"]');
1406
+ if (tabBtn) tabBtn.click();
1407
+ if (!_activeViewer) return false;
1408
+ _activeViewer.openTab(entry);
1409
+ revealTreePath(entry.path);
1410
+ return true;
1411
+ }
1412
+
1413
+ // autoOpenWithSystem is exported for the regression harness.
1414
+ return { mount, openFile, autoOpenWithSystem };
986
1415
  })();
987
1416
 
1417
+ if (window.Clacky) Clacky.WorkspaceView = WorkspaceView;
1418
+
988
1419
  // Files is a built-in tab: visible for every session, after ext-studio
989
1420
  // publish (20) and before git (30) / time-machine (40).
990
1421
  if (window.Clacky && Clacky.ext) {