openclacky 1.5.19 → 1.5.21

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +19 -1
  4. data/README_CN.md +1 -1
  5. data/README_JA.md +1 -1
  6. data/lib/clacky/agent/chunk_index.rb +8 -1
  7. data/lib/clacky/agent/feedback.rb +30 -0
  8. data/lib/clacky/agent/history_navigation.rb +17 -3
  9. data/lib/clacky/agent/hook_manager.rb +6 -1
  10. data/lib/clacky/agent/llm_caller.rb +17 -3
  11. data/lib/clacky/agent/message_compressor_helper.rb +36 -2
  12. data/lib/clacky/agent/session_serializer.rb +45 -4
  13. data/lib/clacky/agent.rb +135 -55
  14. data/lib/clacky/agent_config.rb +82 -4
  15. data/lib/clacky/billing/platform_billing.rb +10 -5
  16. data/lib/clacky/brand_config.rb +1 -1
  17. data/lib/clacky/cli.rb +5 -4
  18. data/lib/clacky/client.rb +12 -5
  19. data/lib/clacky/deepseek_web_tools.rb +156 -0
  20. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  21. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +97 -20
  22. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
  23. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  24. data/lib/clacky/extension/loader.rb +5 -3
  25. data/lib/clacky/locales/en.rb +5 -5
  26. data/lib/clacky/locales/zh.rb +5 -5
  27. data/lib/clacky/message_format/anthropic.rb +20 -0
  28. data/lib/clacky/message_format/open_ai.rb +31 -14
  29. data/lib/clacky/message_history.rb +45 -4
  30. data/lib/clacky/platform_http_client.rb +6 -2
  31. data/lib/clacky/providers.rb +38 -138
  32. data/lib/clacky/search_config.rb +47 -13
  33. data/lib/clacky/server/browser_manager.rb +35 -5
  34. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  35. data/lib/clacky/server/discover.rb +5 -4
  36. data/lib/clacky/server/http_server.rb +285 -53
  37. data/lib/clacky/server/model_prices.rb +10 -5
  38. data/lib/clacky/server/pid_files.rb +25 -0
  39. data/lib/clacky/server/server_master.rb +9 -3
  40. data/lib/clacky/server/session_registry.rb +37 -7
  41. data/lib/clacky/server/web_ui_controller.rb +72 -0
  42. data/lib/clacky/session_manager.rb +28 -6
  43. data/lib/clacky/skill.rb +1 -1
  44. data/lib/clacky/tools/ask_user.rb +4 -1
  45. data/lib/clacky/tools/base.rb +3 -2
  46. data/lib/clacky/tools/browser.rb +18 -2
  47. data/lib/clacky/tools/file_reader.rb +1 -1
  48. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  49. data/lib/clacky/tools/terminal.rb +65 -23
  50. data/lib/clacky/tools/trash_manager.rb +5 -3
  51. data/lib/clacky/tools/web_fetch.rb +81 -22
  52. data/lib/clacky/tools/web_search.rb +45 -4
  53. data/lib/clacky/ui2/ui_controller.rb +1 -1
  54. data/lib/clacky/ui_interface.rb +4 -3
  55. data/lib/clacky/utils/file_processor.rb +51 -0
  56. data/lib/clacky/utils/model_pricing.rb +242 -39
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +1158 -249
  59. data/lib/clacky/web/app.js +82 -13
  60. data/lib/clacky/web/components/chat-navigator.js +8 -1
  61. data/lib/clacky/web/components/code-editor.js +6 -2
  62. data/lib/clacky/web/components/mentions.js +2 -13
  63. data/lib/clacky/web/components/model-picker.js +92 -97
  64. data/lib/clacky/web/components/onboard.js +13 -10
  65. data/lib/clacky/web/components/sidebar.js +6 -2
  66. data/lib/clacky/web/features/archive/store.js +196 -0
  67. data/lib/clacky/web/features/archive/view.js +409 -0
  68. data/lib/clacky/web/features/backup/view.js +17 -7
  69. data/lib/clacky/web/features/billing/view.js +84 -10
  70. data/lib/clacky/web/features/channels/view.js +75 -29
  71. data/lib/clacky/web/features/new-session/store.js +3 -3
  72. data/lib/clacky/web/features/new-session/view.js +1 -0
  73. data/lib/clacky/web/features/trash/store.js +14 -11
  74. data/lib/clacky/web/features/trash/view.js +66 -13
  75. data/lib/clacky/web/features/workspace/view.js +421 -16
  76. data/lib/clacky/web/i18n.js +242 -70
  77. data/lib/clacky/web/index.html +270 -117
  78. data/lib/clacky/web/projects.js +56 -1
  79. data/lib/clacky/web/sessions.js +609 -267
  80. data/lib/clacky/web/settings.js +186 -32
  81. data/lib/clacky/web/ws-dispatcher.js +33 -21
  82. data/lib/clacky.rb +4 -2
  83. metadata +6 -1
@@ -33,6 +33,14 @@ const WorkspaceView = (() => {
33
33
  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>';
34
34
  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>';
35
35
  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>';
36
+ const ICON_COPY_REL_PATH = '<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="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/><path d="M12 19l3-3"/></svg>';
37
+ const ICON_CLOSE_MD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>';
38
+ const ICON_CLOSE_OTHERS = '<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="M3 7h8"/><path d="M3 17h8"/><path d="m20 8-5 5"/><path d="m15 8 5 5"/></svg>';
39
+ const ICON_CLOSE_RIGHT = '<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="M4 4v16"/><path d="m20 8-6 6"/><path d="m14 8 6 6"/></svg>';
40
+ const ICON_CLOSE_ALL = '<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"/><path d="m9 9 6 6"/><path d="m15 9-6 6"/></svg>';
41
+ const ICON_ZOOM_IN = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/><path d="M11 8v6"/><path d="M8 11h6"/></svg>';
42
+ const ICON_ZOOM_OUT = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/><path d="M8 11h6"/></svg>';
43
+ const ICON_ZOOM_FIT = '<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="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M16 3h3a2 2 0 0 1 2 2v3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/><path d="M8 21H5a2 2 0 0 1-2-2v-3"/></svg>';
36
44
 
37
45
  function el(tag, cls) {
38
46
  const d = document.createElement(tag);
@@ -135,6 +143,15 @@ const WorkspaceView = (() => {
135
143
  '<path fill="#c3ccd4" d="M9.7 1.5v3.3H13z"/>'
136
144
  );
137
145
 
146
+ const ICON_VIDEO = svgWrap(
147
+ '<rect x="1.5" y="3" width="13" height="10" rx="2" fill="#5c8ee6"/>' +
148
+ '<path d="m6.5 5.8 4 2.2-4 2.2z" fill="#fff"/>'
149
+ );
150
+
151
+ const ICON_AUDIO = svgWrap(
152
+ '<path d="M6.5 3.5v7.2a2.4 2.4 0 1 1-1.2-2.1V5l6-1.5v5.7a2.4 2.4 0 1 1-1.2-2.1V2z" fill="#38a6a5"/>'
153
+ );
154
+
138
155
  function extOf(name) {
139
156
  const n = name.toLowerCase();
140
157
  if (n.startsWith(".git")) return "gitfile";
@@ -167,6 +184,8 @@ const WorkspaceView = (() => {
167
184
  svg: imgIcon("#ffb13b"),
168
185
  png: imgIcon("#a074c4"), jpg: imgIcon("#a074c4"), jpeg: imgIcon("#a074c4"),
169
186
  gif: imgIcon("#a074c4"), webp: imgIcon("#a074c4"), ico: imgIcon("#a074c4"), bmp: imgIcon("#a074c4"),
187
+ mp4: ICON_VIDEO, webm: ICON_VIDEO, mov: ICON_VIDEO,
188
+ mp3: ICON_AUDIO, wav: ICON_AUDIO, ogg: ICON_AUDIO, aac: ICON_AUDIO, flac: ICON_AUDIO, m4a: ICON_AUDIO,
170
189
  pdf: boxIcon("P", "#e04b4b"),
171
190
  zip: boxIcon("Z", "#dba15d"), tar: boxIcon("Z", "#dba15d"), gz: boxIcon("Z", "#dba15d"),
172
191
  "7z": boxIcon("Z", "#dba15d"), rar: boxIcon("Z", "#dba15d"), dmg: boxIcon("Z", "#dba15d"), iso: boxIcon("Z", "#dba15d"),
@@ -307,8 +326,6 @@ const WorkspaceView = (() => {
307
326
  function showContextMenu(e, entry) {
308
327
  closeContextMenu();
309
328
 
310
- const iconRelPath = '<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="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/><path d="M12 19l3-3"/></svg>';
311
-
312
329
  const downloadItem = entry.type === "file" ? `
313
330
  <div class="session-actions-menu-item" data-action="download">
314
331
  <span class="session-actions-menu-icon">${ICON_DOWNLOAD}</span>
@@ -334,7 +351,7 @@ const WorkspaceView = (() => {
334
351
  <span class="session-actions-menu-label">${t("workspace.copyPath")}</span>
335
352
  </div>
336
353
  <div class="session-actions-menu-item" data-action="copyrelpath">
337
- <span class="session-actions-menu-icon">${iconRelPath}</span>
354
+ <span class="session-actions-menu-icon">${ICON_COPY_REL_PATH}</span>
338
355
  <span class="session-actions-menu-label">${t("workspace.copyRelPath")}</span>
339
356
  </div>
340
357
  ${downloadItem}
@@ -663,6 +680,52 @@ const WorkspaceView = (() => {
663
680
 
664
681
  const VIEWER_ASIDE_WIDTH = 700;
665
682
 
683
+ // Image zoom is expressed against the picture's own pixels, so 100% always
684
+ // means "one screen pixel per image pixel" whatever the pane is doing.
685
+ const IMAGE_MIN_ZOOM = 0.1;
686
+ const IMAGE_MAX_ZOOM = 8;
687
+ const IMAGE_ZOOM_STEP = 1.25;
688
+
689
+ // Open files outlive a reload: the strip is rebuilt from a per-session record
690
+ // in localStorage, so refreshing the page (or coming back to a session) lands
691
+ // on the same files. Paths only — every tab body is fetched from disk again.
692
+ const OPEN_TABS_KEY = "clacky.workspace.openTabs";
693
+ const OPEN_TABS_MAX_SESSIONS = 12;
694
+
695
+ function readOpenTabs() {
696
+ try {
697
+ const raw = localStorage.getItem(OPEN_TABS_KEY);
698
+ const data = raw ? JSON.parse(raw) : null;
699
+ return data && typeof data === "object" ? data : {};
700
+ } catch (_e) {
701
+ return {};
702
+ }
703
+ }
704
+
705
+ // Least-recently-used sessions are dropped so a long-lived browser does not
706
+ // keep a list for every session it has ever opened.
707
+ function writeOpenTabs(data) {
708
+ const ids = Object.keys(data);
709
+ if (ids.length > OPEN_TABS_MAX_SESSIONS) {
710
+ ids.sort((a, b) => ((data[b] && data[b].ts) || 0) - ((data[a] && data[a].ts) || 0));
711
+ ids.slice(OPEN_TABS_MAX_SESSIONS).forEach((id) => { delete data[id]; });
712
+ }
713
+ try { localStorage.setItem(OPEN_TABS_KEY, JSON.stringify(data)); } catch (_e) { /* quota / private mode */ }
714
+ }
715
+
716
+ // Reopen what this session had on screen before the reload. A path that no
717
+ // longer opens is skipped without a message: the record is a convenience, not
718
+ // a promise that the file is still there.
719
+ async function restoreOpenTabs(viewer) {
720
+ const sessionId = Workspace.state.sessionId;
721
+ const saved = sessionId ? readOpenTabs()[sessionId] : null;
722
+ if (!saved || !Array.isArray(saved.tabs)) return;
723
+ for (const path of saved.tabs) {
724
+ if (typeof path === "string" && path) await viewer.openTab(toEntry(path), { silent: true });
725
+ }
726
+ if (saved.active) viewer.activate(saved.active);
727
+ }
728
+
666
729
  // Active viewer instance (one per mounted Files tab). Kept at module level
667
730
  // so openFile() — called from chat file:// link clicks — can reach the
668
731
  // viewer without waiting for a DOM query or a custom event round-trip.
@@ -695,7 +758,32 @@ const WorkspaceView = (() => {
695
758
  const tabs = [];
696
759
  let activeId = null;
697
760
  let releaseAsideWidth = null;
698
- const notify = () => { if (onTabsChanged) onTabsChanged({ count: tabs.length, activeId }); };
761
+ const notify = () => {
762
+ if (onTabsChanged) onTabsChanged({ count: tabs.length, activeId });
763
+ persistTabs();
764
+ };
765
+
766
+ // Derived from the strip rather than patched in place, so closing the last
767
+ // tab forgets the session instead of leaving a stale list behind. Binary
768
+ // tabs stay out: reopening one is a request to launch an external
769
+ // application, which a page reload must not do on its own.
770
+ function persistTabs() {
771
+ const sessionId = Workspace.state.sessionId;
772
+ if (!sessionId) return;
773
+ const data = readOpenTabs();
774
+ const paths = tabs.filter((x) => x.kind !== "binary").map((x) => x.entry.path);
775
+ if (!paths.length) {
776
+ delete data[sessionId];
777
+ } else {
778
+ const active = tabs.find((x) => x.id === activeId);
779
+ data[sessionId] = {
780
+ tabs: paths,
781
+ active: active && active.kind !== "binary" ? active.entry.path : paths[paths.length - 1],
782
+ ts: Date.now(),
783
+ };
784
+ }
785
+ writeOpenTabs(data);
786
+ }
699
787
 
700
788
  function ensureAsideWidth() {
701
789
  if (releaseAsideWidth || !window.Clacky || !Clacky.Aside || !Clacky.Aside.requestWidth) return;
@@ -833,6 +921,10 @@ const WorkspaceView = (() => {
833
921
  btn.addEventListener("auxclick", (e) => {
834
922
  if (e.button === 1) closeTab(tab.id);
835
923
  });
924
+ btn.addEventListener("contextmenu", (e) => {
925
+ e.preventDefault();
926
+ showTabContextMenu(e, tab);
927
+ });
836
928
 
837
929
  tabsEl.appendChild(btn);
838
930
  });
@@ -876,8 +968,15 @@ const WorkspaceView = (() => {
876
968
  }
877
969
 
878
970
  function activate(id) {
971
+ // A restored record can name a file that never opened; dropping the id
972
+ // keeps the strip from going fully inactive.
973
+ if (id != null && !tabs.some((x) => x.id === id)) return;
879
974
  activeId = id;
880
- tabs.forEach((tab) => tab.el.classList.toggle("active", tab.id === id));
975
+ tabs.forEach((tab) => {
976
+ const active = tab.id === id;
977
+ tab.el.classList.toggle("active", active);
978
+ if (!active && tab.mediaEl) tab.mediaEl.pause();
979
+ });
881
980
  rebuildTabbar();
882
981
  updateActions();
883
982
  notify();
@@ -890,6 +989,18 @@ const WorkspaceView = (() => {
890
989
  }
891
990
  }
892
991
 
992
+ function disposeTab(tab) {
993
+ if (tab.editor) tab.editor.destroy();
994
+ if (tab.zoomObserver) tab.zoomObserver.disconnect();
995
+ if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
996
+ if (tab.mediaEl) {
997
+ tab.mediaEl.pause();
998
+ tab.mediaEl.removeAttribute("src");
999
+ tab.mediaEl.load();
1000
+ }
1001
+ tab.el.remove();
1002
+ }
1003
+
893
1004
  async function closeTab(id) {
894
1005
  const idx = tabs.findIndex((x) => x.id === id);
895
1006
  if (idx < 0) return;
@@ -898,9 +1009,7 @@ const WorkspaceView = (() => {
898
1009
  const ok = await Modal.confirm(t("workspace.dirtyCloseConfirm"));
899
1010
  if (!ok) return;
900
1011
  }
901
- if (tab.editor) tab.editor.destroy();
902
- if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
903
- tab.el.remove();
1012
+ disposeTab(tab);
904
1013
  tabs.splice(idx, 1);
905
1014
  if (activeId === id) {
906
1015
  const next = tabs[Math.min(idx, tabs.length - 1)];
@@ -912,6 +1021,125 @@ const WorkspaceView = (() => {
912
1021
  notify();
913
1022
  }
914
1023
 
1024
+ // Closing several tabs at once is one gesture, so ask once for the whole
1025
+ // batch instead of once per unsaved file. The active tab only moves when it
1026
+ // was part of the batch.
1027
+ async function closeTabs(doomed) {
1028
+ if (!doomed.length) return;
1029
+ const dirtyCount = doomed.filter((x) => x.dirty).length;
1030
+ if (dirtyCount > 0) {
1031
+ const ok = await Modal.confirm(t("workspace.dirtyCloseBatchConfirm", { count: dirtyCount }));
1032
+ if (!ok) return;
1033
+ }
1034
+ const doomedIds = doomed.map((x) => x.id);
1035
+ doomed.forEach(disposeTab);
1036
+ for (let i = tabs.length - 1; i >= 0; i--) {
1037
+ if (doomedIds.indexOf(tabs[i].id) >= 0) tabs.splice(i, 1);
1038
+ }
1039
+ const nextId = doomedIds.indexOf(activeId) >= 0
1040
+ ? (tabs.length ? tabs[tabs.length - 1].id : null)
1041
+ : activeId;
1042
+ activate(nextId);
1043
+ if (tabs.length === 0) restoreAsideWidth();
1044
+ }
1045
+
1046
+ function closeAllTabs() {
1047
+ return closeTabs(tabs.slice());
1048
+ }
1049
+
1050
+ function closeOtherTabs(tab) {
1051
+ return closeTabs(tabs.filter((x) => x.id !== tab.id));
1052
+ }
1053
+
1054
+ function closeTabsToTheRight(tab) {
1055
+ return closeTabs(tabs.slice(tabs.findIndex((x) => x.id === tab.id) + 1));
1056
+ }
1057
+
1058
+ // Right-click on a tab. The file actions are the ones the tree already
1059
+ // offers for the same entry; closing is the tab's own, so the menu is the
1060
+ // only place it can also act on tabs the pointer is not hovering.
1061
+ function showTabContextMenu(e, tab) {
1062
+ closeContextMenu();
1063
+ const entry = tab.entry;
1064
+ // "Close to the Right" has nothing to act on when the tab is the last one;
1065
+ // keep the entry in place so the menu does not shift, but grey it out.
1066
+ const hasTabsToTheRight = tabs.indexOf(tab) < tabs.length - 1;
1067
+
1068
+ const menu = document.createElement("div");
1069
+ menu.className = "wt-context-menu session-context-menu";
1070
+ menu.innerHTML = `
1071
+ <div class="session-actions-menu-item" data-action="closetab">
1072
+ <span class="session-actions-menu-icon">${ICON_CLOSE_MD}</span>
1073
+ <span class="session-actions-menu-label">${t("workspace.closeTab")}</span>
1074
+ </div>
1075
+ <div class="session-actions-menu-item" data-action="closeothers">
1076
+ <span class="session-actions-menu-icon">${ICON_CLOSE_OTHERS}</span>
1077
+ <span class="session-actions-menu-label">${t("workspace.closeOthers")}</span>
1078
+ </div>
1079
+ <div class="session-actions-menu-item${hasTabsToTheRight ? "" : " session-actions-menu-item--disabled"}" data-action="closeright">
1080
+ <span class="session-actions-menu-icon">${ICON_CLOSE_RIGHT}</span>
1081
+ <span class="session-actions-menu-label">${t("workspace.closeToTheRight")}</span>
1082
+ </div>
1083
+ <div class="session-actions-menu-item" data-action="closealltabs">
1084
+ <span class="session-actions-menu-icon">${ICON_CLOSE_ALL}</span>
1085
+ <span class="session-actions-menu-label">${t("workspace.closeAllTabs")}</span>
1086
+ </div>
1087
+ <div class="wv-menu-separator"></div>
1088
+ <div class="session-actions-menu-item" data-action="addtochat">
1089
+ <span class="session-actions-menu-icon">${ICON_ADD_TO_CHAT}</span>
1090
+ <span class="session-actions-menu-label">${t("workspace.addToChat")}</span>
1091
+ </div>
1092
+ <div class="session-actions-menu-item" data-action="reveal">
1093
+ <span class="session-actions-menu-icon">${ICON_FOLDER}</span>
1094
+ <span class="session-actions-menu-label">${t("workspace.revealInFinder")}</span>
1095
+ </div>
1096
+ <div class="session-actions-menu-item" data-action="copypath">
1097
+ <span class="session-actions-menu-icon">${ICON_COPY}</span>
1098
+ <span class="session-actions-menu-label">${t("workspace.copyPath")}</span>
1099
+ </div>
1100
+ <div class="session-actions-menu-item" data-action="copyrelpath">
1101
+ <span class="session-actions-menu-icon">${ICON_COPY_REL_PATH}</span>
1102
+ <span class="session-actions-menu-label">${t("workspace.copyRelPath")}</span>
1103
+ </div>
1104
+ <div class="session-actions-menu-item" data-action="download">
1105
+ <span class="session-actions-menu-icon">${ICON_DOWNLOAD}</span>
1106
+ <span class="session-actions-menu-label">${t("workspace.download")}</span>
1107
+ </div>
1108
+ `;
1109
+
1110
+ document.body.appendChild(menu);
1111
+ menu.addEventListener("contextmenu", (ev) => ev.preventDefault());
1112
+ menu.style.position = "fixed";
1113
+ menu.style.top = e.clientY + "px";
1114
+ menu.style.left = e.clientX + "px";
1115
+ requestAnimationFrame(() => {
1116
+ const r = menu.getBoundingClientRect();
1117
+ if (r.right > window.innerWidth) menu.style.left = (window.innerWidth - r.width - 8) + "px";
1118
+ if (r.bottom > window.innerHeight) menu.style.top = (window.innerHeight - r.height - 8) + "px";
1119
+ });
1120
+
1121
+ menu.addEventListener("click", async (ev) => {
1122
+ const item = ev.target.closest(".session-actions-menu-item");
1123
+ if (!item) return;
1124
+ closeContextMenu();
1125
+ if (item.classList.contains("session-actions-menu-item--disabled")) return;
1126
+ const action = item.dataset.action;
1127
+ if (action === "closetab") await closeTab(tab.id);
1128
+ if (action === "closeothers") await closeOtherTabs(tab);
1129
+ if (action === "closeright") await closeTabsToTheRight(tab);
1130
+ if (action === "closealltabs") await closeAllTabs();
1131
+ if (action === "reveal") await revealFile(entry);
1132
+ if (action === "download") await downloadFile(entry);
1133
+ if (action === "copypath") copyPath(entry);
1134
+ if (action === "copyrelpath") copyRelPath(entry);
1135
+ if (action === "addtochat") addToChat(entry);
1136
+ });
1137
+
1138
+ setTimeout(() => {
1139
+ document.addEventListener("click", closeContextMenu, { once: true });
1140
+ }, 0);
1141
+ }
1142
+
915
1143
  function syncDirty(tab) {
916
1144
  rebuildTabbar();
917
1145
  updateActions();
@@ -1001,10 +1229,148 @@ const WorkspaceView = (() => {
1001
1229
 
1002
1230
  function buildImageTab(entry, url) {
1003
1231
  const pane = el("div", "wv-pane wv-pane--image");
1232
+ const viewport = el("div", "wv-zoom");
1004
1233
  const img = document.createElement("img");
1005
1234
  img.alt = entry.name;
1006
1235
  img.src = url;
1007
- pane.appendChild(img);
1236
+ img.draggable = false;
1237
+ viewport.appendChild(img);
1238
+
1239
+ const bar = el("div", "wv-zoom-bar");
1240
+ const outBtn = actionBtn(ICON_ZOOM_OUT, t("workspace.zoomOut"), "wv-zoom-btn");
1241
+ const level = el("span", "wv-zoom-level");
1242
+ const inBtn = actionBtn(ICON_ZOOM_IN, t("workspace.zoomIn"), "wv-zoom-btn");
1243
+ const fitBtn = actionBtn(ICON_ZOOM_FIT, t("workspace.zoomFit"), "wv-zoom-btn");
1244
+ bar.append(outBtn, level, inBtn, fitBtn);
1245
+ pane.append(viewport, bar);
1246
+
1247
+ // The picture keeps its natural size and is scaled by transform, so the
1248
+ // number in the bar is a pixel ratio rather than a share of the pane.
1249
+ // `fit` is the scale that shows all of it; the buttons, the wheel and the
1250
+ // double click all move away from there.
1251
+ let scale = 1;
1252
+ let fit = 1;
1253
+ let x = 0;
1254
+ let y = 0;
1255
+
1256
+ function fitScale() {
1257
+ const vw = viewport.clientWidth;
1258
+ const vh = viewport.clientHeight;
1259
+ // A pane inside a hidden tab measures as zero, and a zero ratio would
1260
+ // park the picture at an invisible size — stay neutral until it is real.
1261
+ if (!vw || !vh) return 1;
1262
+ const nw = img.naturalWidth || vw;
1263
+ const nh = img.naturalHeight || vh;
1264
+ return Math.min(1, vw / nw, vh / nh);
1265
+ }
1266
+
1267
+ function pan(offsetX, offsetY) {
1268
+ // Nothing to reveal beyond the edges, so an image narrower than the
1269
+ // pane stays centred instead of being dragged off into a corner.
1270
+ const maxX = Math.max(0, (img.naturalWidth * scale - viewport.clientWidth) / 2);
1271
+ const maxY = Math.max(0, (img.naturalHeight * scale - viewport.clientHeight) / 2);
1272
+ x = Math.max(-maxX, Math.min(maxX, offsetX));
1273
+ y = Math.max(-maxY, Math.min(maxY, offsetY));
1274
+ }
1275
+
1276
+ function render() {
1277
+ pan(x, y);
1278
+ img.style.transform = "translate(" + x + "px, " + y + "px) scale(" + scale + ")";
1279
+ level.textContent = Math.round(scale * 100) + "%";
1280
+ outBtn.disabled = scale <= IMAGE_MIN_ZOOM + 1e-6;
1281
+ inBtn.disabled = scale >= IMAGE_MAX_ZOOM - 1e-6;
1282
+ }
1283
+
1284
+ // Zoom around a point (pane centre by default) so the pixels under the
1285
+ // cursor stay put instead of the picture drifting toward a corner.
1286
+ function zoomAt(target, px, py) {
1287
+ const next = Math.max(IMAGE_MIN_ZOOM, Math.min(IMAGE_MAX_ZOOM, target));
1288
+ if (next === scale) return;
1289
+ const k = next / scale;
1290
+ x = px - k * (px - x);
1291
+ y = py - k * (py - y);
1292
+ scale = next;
1293
+ render();
1294
+ }
1295
+
1296
+ function anchorOf(ev) {
1297
+ const rect = viewport.getBoundingClientRect();
1298
+ return [ev.clientX - (rect.left + rect.width / 2), ev.clientY - (rect.top + rect.height / 2)];
1299
+ }
1300
+
1301
+ function reset() {
1302
+ fit = fitScale();
1303
+ scale = fit;
1304
+ x = 0;
1305
+ y = 0;
1306
+ render();
1307
+ }
1308
+
1309
+ const atFit = () => Math.abs(scale - fit) < 1e-6;
1310
+
1311
+ // A double click is the shortcut for "show me the real pixels" — or, for a
1312
+ // picture that already fits, for "closer than that".
1313
+ const closerTarget = () => (fit < 1 ? 1 : fit * 2);
1314
+
1315
+ inBtn.addEventListener("click", () => zoomAt(scale * IMAGE_ZOOM_STEP, 0, 0));
1316
+ outBtn.addEventListener("click", () => zoomAt(scale / IMAGE_ZOOM_STEP, 0, 0));
1317
+ fitBtn.addEventListener("click", reset);
1318
+
1319
+ // A picture has nothing to scroll, so the wheel belongs to the zoom —
1320
+ // including the pinch gesture a trackpad reports as ctrl+wheel. The
1321
+ // damping keeps one mouse notch (±100) near a quarter turn while the many
1322
+ // small deltas a trackpad sends add up to a glide instead of a jump.
1323
+ viewport.addEventListener("wheel", (ev) => {
1324
+ ev.preventDefault();
1325
+ const anchor = anchorOf(ev);
1326
+ zoomAt(scale * Math.exp(-ev.deltaY * 0.002), anchor[0], anchor[1]);
1327
+ }, { passive: false });
1328
+
1329
+ viewport.addEventListener("dblclick", (ev) => {
1330
+ ev.preventDefault();
1331
+ if (atFit()) {
1332
+ const anchor = anchorOf(ev);
1333
+ zoomAt(closerTarget(), anchor[0], anchor[1]);
1334
+ } else {
1335
+ reset();
1336
+ }
1337
+ });
1338
+
1339
+ let drag = null;
1340
+ viewport.addEventListener("pointerdown", (ev) => {
1341
+ if (ev.button) return;
1342
+ drag = { id: ev.pointerId, x: ev.clientX, y: ev.clientY };
1343
+ viewport.classList.add("is-panning");
1344
+ viewport.setPointerCapture(ev.pointerId);
1345
+ ev.preventDefault();
1346
+ });
1347
+ viewport.addEventListener("pointermove", (ev) => {
1348
+ if (!drag || ev.pointerId !== drag.id) return;
1349
+ pan(x + ev.clientX - drag.x, y + ev.clientY - drag.y);
1350
+ drag.x = ev.clientX;
1351
+ drag.y = ev.clientY;
1352
+ img.style.transform = "translate(" + x + "px, " + y + "px) scale(" + scale + ")";
1353
+ });
1354
+ const endDrag = (ev) => {
1355
+ if (!drag || ev.pointerId !== drag.id) return;
1356
+ drag = null;
1357
+ viewport.classList.remove("is-panning");
1358
+ };
1359
+ viewport.addEventListener("pointerup", endDrag);
1360
+ viewport.addEventListener("pointercancel", endDrag);
1361
+
1362
+ img.addEventListener("load", reset);
1363
+ if (img.complete) reset();
1364
+ render();
1365
+
1366
+ // Dragging the aside or the window changes the fit ratio, and a tab that
1367
+ // was opened in the background only finds its real size once it is shown —
1368
+ // so refit on every new size, but leave a deliberate zoom alone.
1369
+ const observer = typeof ResizeObserver === "function"
1370
+ ? new ResizeObserver(() => { if (atFit()) reset(); })
1371
+ : null;
1372
+ if (observer) observer.observe(viewport);
1373
+
1008
1374
  return {
1009
1375
  id: entry.path,
1010
1376
  entry,
@@ -1016,6 +1382,7 @@ const WorkspaceView = (() => {
1016
1382
  previewEl: null,
1017
1383
  editor: null,
1018
1384
  imageUrl: url,
1385
+ zoomObserver: observer,
1019
1386
  };
1020
1387
  }
1021
1388
 
@@ -1039,6 +1406,36 @@ const WorkspaceView = (() => {
1039
1406
  };
1040
1407
  }
1041
1408
 
1409
+ function buildMediaTab(entry, kind, url) {
1410
+ const pane = el("div", "wv-pane wv-pane--media wv-pane--" + kind);
1411
+ const media = document.createElement(kind);
1412
+ media.className = "wv-media-player wv-media-player--" + kind;
1413
+ media.controls = true;
1414
+ media.preload = "metadata";
1415
+ media.src = url;
1416
+ media.setAttribute("aria-label", entry.name);
1417
+ if (kind === "video") media.playsInline = true;
1418
+
1419
+ const error = el("div", "wv-media-error");
1420
+ error.textContent = t("workspace.previewUnsupported");
1421
+ media.addEventListener("error", () => pane.classList.add("is-error"));
1422
+ pane.append(media, error);
1423
+
1424
+ return {
1425
+ id: entry.path,
1426
+ entry,
1427
+ kind,
1428
+ el: pane,
1429
+ dirty: false,
1430
+ isMd: false,
1431
+ previewMode: false,
1432
+ previewEl: null,
1433
+ editor: null,
1434
+ imageUrl: null,
1435
+ mediaEl: media,
1436
+ };
1437
+ }
1438
+
1042
1439
  async function buildFallbackTab(entry) {
1043
1440
  const pane = el("div", "wv-pane wv-pane--fallback");
1044
1441
 
@@ -1103,7 +1500,8 @@ const WorkspaceView = (() => {
1103
1500
  };
1104
1501
  }
1105
1502
 
1106
- async function openTab(entry) {
1503
+ async function openTab(entry, opts) {
1504
+ const silent = !!(opts && opts.silent);
1107
1505
  const existing = tabs.find((x) => x.id === entry.path);
1108
1506
  if (existing) {
1109
1507
  activate(existing.id);
@@ -1126,6 +1524,8 @@ const WorkspaceView = (() => {
1126
1524
  tab = buildImageTab(entry, URL.createObjectURL(blob));
1127
1525
  } else if (kind === "pdf") {
1128
1526
  tab = buildPdfTab(entry, "/api/local-image?path=" + encodeURIComponent(Workspace.absPath(entry.path)));
1527
+ } else if (kind === "video" || kind === "audio") {
1528
+ tab = buildMediaTab(entry, kind, "/api/local-image?path=" + encodeURIComponent(Workspace.absPath(entry.path)));
1129
1529
  } else if (kind === "binary") {
1130
1530
  // The extension already says the browser cannot render this, so the
1131
1531
  // OS application the user was going to pick is launched below without
@@ -1143,7 +1543,14 @@ const WorkspaceView = (() => {
1143
1543
  }
1144
1544
  } catch (err) {
1145
1545
  console.error("preview failed:", err);
1146
- Modal.toast(t("workspace.previewFailed") + ": " + err.message, "error");
1546
+ if (silent) {
1547
+ // Only a restore asks for silence, and its failures are files that no
1548
+ // longer open — rewriting the record keeps later reloads from
1549
+ // retrying them.
1550
+ persistTabs();
1551
+ } else {
1552
+ Modal.toast(t("workspace.previewFailed") + ": " + err.message, "error");
1553
+ }
1147
1554
  return;
1148
1555
  }
1149
1556
  if (tabs.length === 0) ensureAsideWidth();
@@ -1185,15 +1592,12 @@ const WorkspaceView = (() => {
1185
1592
 
1186
1593
  function destroy() {
1187
1594
  restoreAsideWidth();
1188
- tabs.forEach((tab) => {
1189
- if (tab.editor) tab.editor.destroy();
1190
- if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
1191
- });
1595
+ tabs.forEach(disposeTab);
1192
1596
  tabs.length = 0;
1193
1597
  activeId = null;
1194
1598
  }
1195
1599
 
1196
- return { root, openTab, destroy };
1600
+ return { root, openTab, activate, destroy };
1197
1601
  }
1198
1602
 
1199
1603
  // Draggable split between the file tree and the viewer. The tree width is a
@@ -1451,6 +1855,7 @@ const WorkspaceView = (() => {
1451
1855
  }
1452
1856
 
1453
1857
  reloadTree({ reset: true });
1858
+ restoreOpenTabs(viewer);
1454
1859
 
1455
1860
  // The tree is otherwise loaded only once per mount. Reload it when the
1456
1861
  // working directory changes (status-bar dir switch) and when the agent