@poncho-ai/cli 0.38.1 → 0.40.0

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.
@@ -31,6 +31,11 @@ export const getWebUiClientScript = (markedSource: string): string => `
31
31
  conversations: [],
32
32
  activeConversationId: null,
33
33
  activeMessages: [],
34
+ // Verbose dev (-v) only: mirror of conversation._harnessMessages plus
35
+ // the current view mode the user toggled to.
36
+ verboseDev: false,
37
+ viewMode: "user", // "user" | "harness"
38
+ harnessMessages: null,
34
39
  isStreaming: false,
35
40
  activeStreamAbortController: null,
36
41
  activeStreamConversationId: null,
@@ -64,6 +69,14 @@ export const getWebUiClientScript = (markedSource: string): string => `
64
69
  abortController: null,
65
70
  pendingFiles: [],
66
71
  },
72
+ sidebarMode: "conversations",
73
+ expandedDirs: new Set(["/"]),
74
+ dirCache: new Map(),
75
+ activeFilePath: null,
76
+ pendingUploads: 0,
77
+ fileExplorerError: null,
78
+ fileExplorerUsage: null,
79
+ confirmDeletePath: null,
67
80
  };
68
81
 
69
82
  const agentInitial = document.body.dataset.agentInitial || "A";
@@ -79,6 +92,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
79
92
  topbarNewChat: $("topbar-new-chat"),
80
93
  messages: $("messages"),
81
94
  chatTitle: $("chat-title"),
95
+ viewToggle: $("view-toggle"),
82
96
  logout: $("logout"),
83
97
  composer: $("composer"),
84
98
  prompt: $("prompt"),
@@ -112,6 +126,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
112
126
  threadAttachmentPreview: $("thread-attachment-preview"),
113
127
  threadPrompt: $("thread-prompt"),
114
128
  threadSend: $("thread-send"),
129
+ fileExplorer: $("file-explorer"),
115
130
  };
116
131
  const sendIconMarkup =
117
132
  '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 12V4M4 7l4-4 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
@@ -163,6 +178,25 @@ export const getWebUiClientScript = (markedSource: string): string => `
163
178
  return match ? decodeURIComponent(match[1]) : null;
164
179
  };
165
180
 
181
+ const pushFileUrl = (filePath) => {
182
+ const target = filePath ? "/f/" + encodeURIComponent(filePath) : "/";
183
+ if (window.location.pathname !== target) {
184
+ history.pushState({ filePath: filePath || null }, "", target);
185
+ }
186
+ };
187
+
188
+ const replaceFileUrl = (filePath) => {
189
+ const target = filePath ? "/f/" + encodeURIComponent(filePath) : "/";
190
+ if (window.location.pathname !== target) {
191
+ history.replaceState({ filePath: filePath || null }, "", target);
192
+ }
193
+ };
194
+
195
+ const getFilePathFromUrl = () => {
196
+ const match = window.location.pathname.match(/^\\/f\\/(.+)/);
197
+ return match ? decodeURIComponent(match[1]) : null;
198
+ };
199
+
166
200
  const mutatingMethods = new Set(["POST", "PATCH", "PUT", "DELETE"]);
167
201
 
168
202
  const api = async (path, options = {}) => {
@@ -831,6 +865,8 @@ export const getWebUiClientScript = (markedSource: string): string => `
831
865
  }
832
866
  var switchingFamily = state.subagentsParentId !== c.conversationId;
833
867
  state.activeConversationId = c.conversationId;
868
+ state.activeFilePath = null;
869
+ if (elements.composer) elements.composer.classList.remove("hidden");
834
870
  state.viewingSubagentId = null;
835
871
  state.parentConversationId = null;
836
872
  if (switchingFamily) {
@@ -1737,7 +1773,66 @@ export const getWebUiClientScript = (markedSource: string): string => `
1737
1773
  }
1738
1774
  };
1739
1775
 
1776
+ const renderHarnessView = () => {
1777
+ const msgs = Array.isArray(state.harnessMessages) ? state.harnessMessages : [];
1778
+ if (msgs.length === 0) {
1779
+ elements.messages.innerHTML = '<div class="harness-debug-view"><em>No harness messages yet — they appear after the first assistant turn.</em></div>';
1780
+ return;
1781
+ }
1782
+ const rows = msgs.map((m, i) => {
1783
+ const role = String(m.role || "?");
1784
+ const meta = m.metadata && typeof m.metadata === "object" ? m.metadata : null;
1785
+ const metaLine = meta
1786
+ ? "step=" + (meta.step != null ? meta.step : "-") +
1787
+ " runId=" + (meta.runId ? String(meta.runId).slice(0, 16) : "-") +
1788
+ " id=" + (meta.id ? String(meta.id).slice(0, 12) : "-")
1789
+ : "";
1790
+ let content = m.content;
1791
+ if (typeof content !== "string") content = JSON.stringify(content, null, 2);
1792
+ // Pretty-print JSON content where possible (assistant tool calls,
1793
+ // tool result arrays, etc.) so it's actually readable.
1794
+ const trimmed = content.trim();
1795
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
1796
+ try { content = JSON.stringify(JSON.parse(trimmed), null, 2); } catch (_e) { /* leave as-is */ }
1797
+ }
1798
+ return '<div class="hd-msg role-' + escapeHtml(role) + '">' +
1799
+ '<div class="hd-role">#' + i + ' · ' + escapeHtml(role) + '</div>' +
1800
+ (metaLine ? '<div class="hd-meta">' + escapeHtml(metaLine) + '</div>' : '') +
1801
+ '<div>' + escapeHtml(content) + '</div>' +
1802
+ '</div>';
1803
+ }).join("");
1804
+ elements.messages.innerHTML = '<div class="harness-debug-view">' + rows + '</div>';
1805
+ };
1806
+
1807
+ const updateViewToggleVisibility = () => {
1808
+ if (!elements.viewToggle) return;
1809
+ if (!state.verboseDev) {
1810
+ elements.viewToggle.hidden = true;
1811
+ return;
1812
+ }
1813
+ elements.viewToggle.hidden = false;
1814
+ elements.viewToggle.textContent = state.viewMode === "harness" ? "harness view" : "user view";
1815
+ elements.viewToggle.classList.toggle("is-harness", state.viewMode === "harness");
1816
+ };
1817
+
1818
+ if (elements.viewToggle) {
1819
+ elements.viewToggle.addEventListener("click", () => {
1820
+ state.viewMode = state.viewMode === "harness" ? "user" : "harness";
1821
+ updateViewToggleVisibility();
1822
+ if (state.viewMode === "harness") {
1823
+ renderHarnessView();
1824
+ } else {
1825
+ renderMessages(state.activeMessages, state.isStreaming);
1826
+ }
1827
+ });
1828
+ }
1829
+
1740
1830
  const renderMessages = (messages, isStreaming = false, options = {}) => {
1831
+ // In harness debug view, the user-facing renderer is bypassed.
1832
+ if (state.viewMode === "harness") {
1833
+ renderHarnessView();
1834
+ return;
1835
+ }
1741
1836
  const previousScrollTop = elements.messages.scrollTop;
1742
1837
  const shouldStickToBottom =
1743
1838
  options.forceScrollBottom === true || state.isMessagesPinnedToBottom;
@@ -2001,6 +2096,13 @@ export const getWebUiClientScript = (markedSource: string): string => `
2001
2096
  .catch(() => ({ threads: [] }));
2002
2097
  const payload = await conversationPromise;
2003
2098
  elements.chatTitle.textContent = payload.conversation.title;
2099
+ // Verbose dev (-v) only — server includes verboseDev: true and the
2100
+ // raw harness-message stream so we can offer a debug toggle.
2101
+ state.verboseDev = payload.verboseDev === true;
2102
+ state.harnessMessages = state.verboseDev && Array.isArray(payload.conversation._harnessMessages)
2103
+ ? payload.conversation._harnessMessages
2104
+ : null;
2105
+ updateViewToggleVisibility();
2004
2106
  // Merge own pending approvals + subagent pending approvals
2005
2107
  var allPendingApprovals = [].concat(
2006
2108
  payload.conversation.pendingApprovals || payload.pendingApprovals || [],
@@ -2299,6 +2401,12 @@ export const getWebUiClientScript = (markedSource: string): string => `
2299
2401
  if (typeof payload.conversation.contextWindow === "number" && payload.conversation.contextWindow > 0) {
2300
2402
  state.contextWindow = payload.conversation.contextWindow;
2301
2403
  }
2404
+ // Keep harness debug view fresh on refetches in -v mode.
2405
+ state.verboseDev = payload.verboseDev === true;
2406
+ state.harnessMessages = state.verboseDev && Array.isArray(payload.conversation._harnessMessages)
2407
+ ? payload.conversation._harnessMessages
2408
+ : null;
2409
+ updateViewToggleVisibility();
2302
2410
  updateContextRing();
2303
2411
  renderMessages(state.activeMessages, streaming);
2304
2412
  return payload;
@@ -3963,6 +4071,8 @@ export const getWebUiClientScript = (markedSource: string): string => `
3963
4071
  const startNewChat = () => {
3964
4072
  if (window._resetBrowserPanel) window._resetBrowserPanel();
3965
4073
  state.activeConversationId = null;
4074
+ state.activeFilePath = null;
4075
+ if (elements.composer) elements.composer.classList.remove("hidden");
3966
4076
  state.activeMessages = [];
3967
4077
  state.confirmDeleteId = null;
3968
4078
  state.contextTokens = 0;
@@ -4301,13 +4411,17 @@ export const getWebUiClientScript = (markedSource: string): string => `
4301
4411
  });
4302
4412
 
4303
4413
  let dragCounter = 0;
4414
+ const _inFileExplorer = (target) =>
4415
+ elements.fileExplorer && target instanceof Node && elements.fileExplorer.contains(target);
4304
4416
  document.addEventListener("dragenter", (e) => {
4305
4417
  e.preventDefault();
4418
+ if (_inFileExplorer(e.target)) return;
4306
4419
  dragCounter++;
4307
4420
  if (dragCounter === 1) elements.dragOverlay.classList.add("active");
4308
4421
  });
4309
4422
  document.addEventListener("dragleave", (e) => {
4310
4423
  e.preventDefault();
4424
+ if (_inFileExplorer(e.target)) return;
4311
4425
  dragCounter--;
4312
4426
  if (dragCounter <= 0) { dragCounter = 0; elements.dragOverlay.classList.remove("active"); }
4313
4427
  });
@@ -4316,6 +4430,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
4316
4430
  e.preventDefault();
4317
4431
  dragCounter = 0;
4318
4432
  elements.dragOverlay.classList.remove("active");
4433
+ if (_inFileExplorer(e.target)) return;
4319
4434
  if (e.dataTransfer && e.dataTransfer.files.length > 0) {
4320
4435
  addFiles(e.dataTransfer.files);
4321
4436
  }
@@ -4578,6 +4693,8 @@ export const getWebUiClientScript = (markedSource: string): string => `
4578
4693
  });
4579
4694
 
4580
4695
  const navigateToConversation = async (conversationId) => {
4696
+ state.activeFilePath = null;
4697
+ if (elements.composer) elements.composer.classList.remove("hidden");
4581
4698
  if (conversationId) {
4582
4699
  state.activeConversationId = conversationId;
4583
4700
  renderConversationList();
@@ -4604,8 +4721,893 @@ export const getWebUiClientScript = (markedSource: string): string => `
4604
4721
  }
4605
4722
  };
4606
4723
 
4724
+ // ----- File explorer (sidebar Files mode) -----
4725
+
4726
+ const formatBytes = (n) => {
4727
+ if (typeof n !== "number" || !isFinite(n)) return "";
4728
+ if (n < 1024) return n + " B";
4729
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
4730
+ if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
4731
+ return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
4732
+ };
4733
+
4734
+ const joinPath = (parent, name) => {
4735
+ if (parent === "/") return "/" + name;
4736
+ return parent + "/" + name;
4737
+ };
4738
+
4739
+ const parentPath = (p) => {
4740
+ if (!p || p === "/") return "/";
4741
+ const idx = p.lastIndexOf("/");
4742
+ if (idx <= 0) return "/";
4743
+ return p.slice(0, idx);
4744
+ };
4745
+
4746
+ const TEXT_LIKE_MIME_PREFIXES = ["text/"];
4747
+ const TEXT_LIKE_MIME_EXACT = new Set([
4748
+ "application/json",
4749
+ "application/javascript",
4750
+ "application/xml",
4751
+ "application/x-sh",
4752
+ "application/x-yaml",
4753
+ "application/yaml",
4754
+ "application/toml",
4755
+ "application/x-www-form-urlencoded",
4756
+ ]);
4757
+ const TEXT_LIKE_EXTENSIONS = new Set([
4758
+ "md","txt","log","csv","tsv","js","mjs","cjs","jsx","ts","tsx","json","yaml","yml","toml",
4759
+ "xml","html","htm","css","scss","sass","less","sh","bash","zsh","py","rb","go","rs","java",
4760
+ "kt","swift","c","cpp","h","hpp","sql","env","ini","conf","cfg","gitignore","editorconfig",
4761
+ ]);
4762
+
4763
+ const categorizePreview = (mime, name) => {
4764
+ const m = (mime || "").toLowerCase();
4765
+ const ext = (name.split(".").pop() || "").toLowerCase();
4766
+ if (m === "text/html" || ext === "html" || ext === "htm") return "html";
4767
+ if (m.startsWith("image/")) return "image";
4768
+ if (m === "application/pdf") return "pdf";
4769
+ if (m.startsWith("audio/")) return "audio";
4770
+ if (m.startsWith("video/")) return "video";
4771
+ for (const p of TEXT_LIKE_MIME_PREFIXES) if (m.startsWith(p)) return "text";
4772
+ if (TEXT_LIKE_MIME_EXACT.has(m)) return "text";
4773
+ if (TEXT_LIKE_EXTENSIONS.has(ext)) return "text";
4774
+ if (!m && (ext === "" || /^[a-z0-9]+$/i.test(ext))) return "text-maybe";
4775
+ return "binary";
4776
+ };
4777
+
4778
+ const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
4779
+
4780
+ const folderIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 4.5a1 1 0 0 1 1-1h3l1.5 1.5h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-10.5a1 1 0 0 1-1-1v-7.5z"/></svg>';
4781
+ const fileIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 1.5H3.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V6L9 1.5z"/><path d="M9 1.5V6h4.5"/></svg>';
4782
+ const downloadIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v8M5 7l3 3 3-3M2.5 12.5v.5a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-.5"/></svg>';
4783
+ const refreshIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8a6 6 0 0 1 10.5-4M14 8a6 6 0 0 1-10.5 4"/><path d="M12.5 1.5v3h-3M3.5 14.5v-3h3"/></svg>';
4784
+ const closeIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
4785
+ const caretIconSvg = '<svg viewBox="0 0 12 12" fill="none" width="10" height="10"><path d="M4.5 2.75L8 6L4.5 9.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>';
4786
+ const uploadIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3M2.5 12.5v.5a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-.5"/></svg>';
4787
+ const fetchDirEntries = async (dirPath) => {
4788
+ const qs = "?path=" + encodeURIComponent(dirPath);
4789
+ const data = await api("/api/vfs-list" + qs);
4790
+ state.dirCache.set(dirPath, data.entries || []);
4791
+ if (data.usage) state.fileExplorerUsage = data.usage;
4792
+ return data.entries || [];
4793
+ };
4794
+
4795
+ const ensureDirLoaded = async (dirPath) => {
4796
+ if (state.dirCache.has(dirPath)) return;
4797
+ try {
4798
+ await fetchDirEntries(dirPath);
4799
+ state.fileExplorerError = null;
4800
+ } catch (err) {
4801
+ state.fileExplorerError = err.message || "Failed to load directory";
4802
+ }
4803
+ };
4804
+
4805
+ const renderFileExplorer = () => {
4806
+ const root = elements.fileExplorer;
4807
+ if (!root) return;
4808
+ root.innerHTML = "";
4809
+
4810
+ // Pending uploads spinner row
4811
+ if (state.pendingUploads > 0) {
4812
+ const row = document.createElement("div");
4813
+ row.className = "file-upload-row";
4814
+ row.innerHTML = '<span class="file-upload-spinner"></span><span>Uploading ' + state.pendingUploads + (state.pendingUploads === 1 ? ' file…' : ' files…') + '</span>';
4815
+ root.appendChild(row);
4816
+ }
4817
+
4818
+ // Error row
4819
+ if (state.fileExplorerError) {
4820
+ const err = document.createElement("div");
4821
+ err.className = "file-explorer-error";
4822
+ err.textContent = state.fileExplorerError;
4823
+ const retry = document.createElement("button");
4824
+ retry.textContent = "Retry";
4825
+ retry.onclick = async () => {
4826
+ state.fileExplorerError = null;
4827
+ state.dirCache.clear();
4828
+ for (const dir of Array.from(state.expandedDirs)) {
4829
+ try { await fetchDirEntries(dir); } catch {}
4830
+ }
4831
+ renderFileExplorer();
4832
+ };
4833
+ err.appendChild(document.createElement("br"));
4834
+ err.appendChild(retry);
4835
+ root.appendChild(err);
4836
+ }
4837
+
4838
+ // Tree
4839
+ const tree = document.createElement("div");
4840
+ tree.className = "file-children";
4841
+ tree.dataset.dir = "/";
4842
+ renderDirInto(tree, "/", 0);
4843
+ root.appendChild(tree);
4844
+
4845
+ // Footer (status bar with usage + upload)
4846
+ const footer = document.createElement("div");
4847
+ footer.className = "file-explorer-footer";
4848
+ const usageEl = document.createElement("div");
4849
+ usageEl.className = "file-explorer-usage";
4850
+ if (state.fileExplorerUsage) {
4851
+ const u = state.fileExplorerUsage;
4852
+ const countEl = document.createElement("span");
4853
+ countEl.textContent = u.fileCount + " file" + (u.fileCount === 1 ? "" : "s");
4854
+ const sizeEl = document.createElement("span");
4855
+ sizeEl.textContent = formatBytes(u.totalBytes);
4856
+ usageEl.append(countEl, sizeEl);
4857
+ }
4858
+ const refreshBtn = document.createElement("button");
4859
+ refreshBtn.className = "file-explorer-icon-btn";
4860
+ refreshBtn.title = "Refresh";
4861
+ refreshBtn.innerHTML = refreshIconSvg;
4862
+ refreshBtn.onclick = async () => {
4863
+ state.dirCache.clear();
4864
+ for (const dir of Array.from(state.expandedDirs)) {
4865
+ try { await fetchDirEntries(dir); } catch {}
4866
+ }
4867
+ renderFileExplorer();
4868
+ };
4869
+ const uploadBtn = document.createElement("button");
4870
+ uploadBtn.className = "file-explorer-upload";
4871
+ uploadBtn.title = "Upload files";
4872
+ uploadBtn.innerHTML = uploadIconSvg + '<span>Upload</span>';
4873
+ uploadBtn.onclick = () => triggerUploadPicker("/");
4874
+ footer.append(usageEl, refreshBtn, uploadBtn);
4875
+ root.appendChild(footer);
4876
+ };
4877
+
4878
+ const renderDirInto = (container, dirPath, depth) => {
4879
+ const entries = state.dirCache.get(dirPath);
4880
+ if (!entries) {
4881
+ // Lazy fetch for newly expanded dir
4882
+ ensureDirLoaded(dirPath).then(() => renderFileExplorer());
4883
+ if (dirPath !== "/") {
4884
+ const loading = document.createElement("div");
4885
+ loading.className = "file-explorer-empty";
4886
+ loading.style.paddingLeft = (16 + depth * 14) + "px";
4887
+ loading.textContent = "Loading…";
4888
+ container.appendChild(loading);
4889
+ }
4890
+ return;
4891
+ }
4892
+ if (entries.length === 0 && dirPath === "/") {
4893
+ const empty = document.createElement("div");
4894
+ empty.className = "file-explorer-empty";
4895
+ empty.innerHTML = "No files yet. Tools like " + _TK + "write_file" + _TK + " and " + _TK + "bash" + _TK + " will populate this.";
4896
+ container.appendChild(empty);
4897
+ return;
4898
+ }
4899
+ for (const entry of entries) {
4900
+ const childPath = joinPath(dirPath, entry.name);
4901
+ const row = document.createElement("div");
4902
+ row.className = "file-row" + (entry.type === "directory" ? " is-dir" : "") + (state.activeFilePath === childPath ? " active" : "");
4903
+ row.style.paddingLeft = (8 + depth * 14) + "px";
4904
+ row.dataset.path = childPath;
4905
+ row.dataset.type = entry.type;
4906
+
4907
+ const caret = document.createElement("span");
4908
+ caret.className = "file-caret" + (entry.type === "directory" ? "" : " empty") + (state.expandedDirs.has(childPath) ? " open" : "");
4909
+ caret.innerHTML = caretIconSvg;
4910
+ row.appendChild(caret);
4911
+
4912
+ const icon = document.createElement("span");
4913
+ icon.className = "file-icon";
4914
+ icon.innerHTML = entry.type === "directory" ? folderIconSvg : fileIconSvg;
4915
+ row.appendChild(icon);
4916
+
4917
+ const name = document.createElement("span");
4918
+ name.className = "file-name";
4919
+ name.textContent = entry.name;
4920
+ row.appendChild(name);
4921
+
4922
+ if (entry.type === "directory") {
4923
+ row.onclick = (e) => {
4924
+ e.stopPropagation();
4925
+ if (state.confirmDeletePath) { state.confirmDeletePath = null; renderFileExplorer(); return; }
4926
+ if (state.expandedDirs.has(childPath)) {
4927
+ state.expandedDirs.delete(childPath);
4928
+ } else {
4929
+ state.expandedDirs.add(childPath);
4930
+ }
4931
+ renderFileExplorer();
4932
+ };
4933
+ } else {
4934
+ row.onclick = (e) => {
4935
+ e.stopPropagation();
4936
+ if (state.confirmDeletePath) { state.confirmDeletePath = null; renderFileExplorer(); return; }
4937
+ selectFile(childPath);
4938
+ };
4939
+ }
4940
+
4941
+ const isConfirming = state.confirmDeletePath === childPath;
4942
+ const actions = document.createElement("div");
4943
+ actions.className = "file-row-actions" + (isConfirming ? " confirming" : "");
4944
+
4945
+ const dl = document.createElement("a");
4946
+ dl.className = "file-row-action";
4947
+ if (entry.type === "directory") {
4948
+ dl.href = "/api/vfs-archive?path=" + encodeURIComponent(childPath);
4949
+ dl.title = "Download as zip";
4950
+ dl.setAttribute("download", entry.name + ".zip");
4951
+ } else {
4952
+ dl.href = "/api/vfs/" + encodeURI(childPath.replace(/^\\//, ""));
4953
+ dl.title = "Download";
4954
+ dl.setAttribute("download", entry.name);
4955
+ }
4956
+ dl.innerHTML = downloadIconSvg;
4957
+ dl.onclick = (e) => { e.stopPropagation(); };
4958
+ actions.appendChild(dl);
4959
+
4960
+ const del = document.createElement("button");
4961
+ del.className = "file-row-action file-delete" + (isConfirming ? " confirming" : "");
4962
+ if (isConfirming) del.textContent = "sure?";
4963
+ else del.innerHTML = closeIconSvg;
4964
+ del.title = "Delete";
4965
+ del.onclick = async (e) => {
4966
+ e.stopPropagation();
4967
+ if (!isConfirming) {
4968
+ state.confirmDeletePath = childPath;
4969
+ renderFileExplorer();
4970
+ return;
4971
+ }
4972
+ state.confirmDeletePath = null;
4973
+ await deleteEntry(childPath, entry.type, dirPath);
4974
+ };
4975
+ actions.appendChild(del);
4976
+ row.appendChild(actions);
4977
+
4978
+ container.appendChild(row);
4979
+
4980
+ if (entry.type === "directory" && state.expandedDirs.has(childPath)) {
4981
+ const childWrap = document.createElement("div");
4982
+ childWrap.className = "file-children";
4983
+ childWrap.dataset.dir = childPath;
4984
+ renderDirInto(childWrap, childPath, depth + 1);
4985
+ container.appendChild(childWrap);
4986
+ }
4987
+ }
4988
+ };
4989
+
4990
+ const selectFile = async (filePath) => {
4991
+ state.activeFilePath = filePath;
4992
+ state.activeConversationId = null;
4993
+ pushFileUrl(filePath);
4994
+ updateComposerVisibility();
4995
+ renderFileExplorer();
4996
+ await renderFilePreview(filePath);
4997
+ };
4998
+
4999
+ const renderFilePreview = async (filePath) => {
5000
+ const messages = elements.messages;
5001
+ if (!messages) return;
5002
+ const filename = filePath.split("/").pop() || filePath;
5003
+ elements.chatTitle.textContent = filename;
5004
+ messages.innerHTML = '<div class="file-preview"><div class="file-explorer-empty">Loading…</div></div>';
5005
+ let response;
5006
+ try {
5007
+ response = await fetch("/api/vfs/" + encodeURI(filePath.replace(/^\\//, "")), {
5008
+ credentials: state.tenantToken ? "omit" : "include",
5009
+ headers: buildAuthHeaders(),
5010
+ });
5011
+ } catch (err) {
5012
+ renderPreviewError(filePath, "Network error");
5013
+ return;
5014
+ }
5015
+ if (!response.ok) {
5016
+ renderPreviewError(filePath, "HTTP " + response.status);
5017
+ return;
5018
+ }
5019
+ const mime = (response.headers.get("content-type") || "").split(";")[0].trim();
5020
+ const sizeHeader = response.headers.get("content-length");
5021
+ const size = sizeHeader ? parseInt(sizeHeader, 10) : 0;
5022
+ const category = categorizePreview(mime, filename);
5023
+
5024
+ if (category === "text" || category === "text-maybe") {
5025
+ if (size && size > TEXT_PREVIEW_MAX_BYTES) {
5026
+ renderPreviewPlaceholder(filePath, mime, size, "Text file too large to preview inline.");
5027
+ return;
5028
+ }
5029
+ const buf = await response.arrayBuffer();
5030
+ if (buf.byteLength > TEXT_PREVIEW_MAX_BYTES) {
5031
+ renderPreviewPlaceholder(filePath, mime, buf.byteLength, "Text file too large to preview inline.");
5032
+ return;
5033
+ }
5034
+ let text;
5035
+ try {
5036
+ text = new TextDecoder("utf-8", { fatal: category === "text-maybe" }).decode(buf);
5037
+ } catch {
5038
+ renderPreviewPlaceholder(filePath, mime, buf.byteLength, "Not a text file.");
5039
+ return;
5040
+ }
5041
+ renderTextView(filePath, text, mime || "text/plain");
5042
+ return;
5043
+ }
5044
+ if (category === "image") {
5045
+ const blob = await response.blob();
5046
+ const url = URL.createObjectURL(blob);
5047
+ const wrap = document.createElement("div");
5048
+ wrap.className = "file-preview";
5049
+ const inner = document.createElement("div");
5050
+ inner.className = "file-preview-image";
5051
+ const img = document.createElement("img");
5052
+ img.src = url;
5053
+ img.alt = filename;
5054
+ img.onload = () => URL.revokeObjectURL(url);
5055
+ inner.appendChild(img);
5056
+ wrap.append(buildPreviewActions(filePath), inner);
5057
+ messages.innerHTML = "";
5058
+ messages.appendChild(wrap);
5059
+ return;
5060
+ }
5061
+ if (category === "pdf") {
5062
+ const blob = await response.blob();
5063
+ const url = URL.createObjectURL(blob);
5064
+ const wrap = document.createElement("div");
5065
+ wrap.className = "file-preview";
5066
+ const inner = document.createElement("div");
5067
+ inner.className = "file-preview-pdf";
5068
+ const iframe = document.createElement("iframe");
5069
+ iframe.src = url;
5070
+ iframe.title = filename;
5071
+ inner.appendChild(iframe);
5072
+ wrap.append(buildPreviewActions(filePath), inner);
5073
+ messages.innerHTML = "";
5074
+ messages.appendChild(wrap);
5075
+ return;
5076
+ }
5077
+ if (category === "html") {
5078
+ const blob = await response.blob();
5079
+ const url = URL.createObjectURL(blob);
5080
+ const wrap = document.createElement("div");
5081
+ wrap.className = "file-preview";
5082
+ const inner = document.createElement("div");
5083
+ inner.className = "file-preview-pdf";
5084
+ const iframe = document.createElement("iframe");
5085
+ iframe.src = url;
5086
+ iframe.title = filename;
5087
+ iframe.setAttribute("sandbox", "");
5088
+ inner.appendChild(iframe);
5089
+ wrap.append(buildPreviewActions(filePath), inner);
5090
+ messages.innerHTML = "";
5091
+ messages.appendChild(wrap);
5092
+ return;
5093
+ }
5094
+ if (category === "audio" || category === "video") {
5095
+ const blob = await response.blob();
5096
+ const url = URL.createObjectURL(blob);
5097
+ const wrap = document.createElement("div");
5098
+ wrap.className = "file-preview";
5099
+ const inner = document.createElement("div");
5100
+ inner.className = "file-preview-media";
5101
+ const media = document.createElement(category);
5102
+ media.src = url;
5103
+ media.controls = true;
5104
+ media.style.maxWidth = "100%";
5105
+ media.style.maxHeight = "100%";
5106
+ inner.appendChild(media);
5107
+ wrap.append(buildPreviewActions(filePath), inner);
5108
+ messages.innerHTML = "";
5109
+ messages.appendChild(wrap);
5110
+ return;
5111
+ }
5112
+ renderPreviewPlaceholder(filePath, mime, size, "This file type can't be previewed.");
5113
+ };
5114
+
5115
+ const buildDownloadLink = (filePath) => {
5116
+ const filename = filePath.split("/").pop() || "download";
5117
+ const a = document.createElement("a");
5118
+ a.className = "file-preview-action-btn";
5119
+ a.href = "/api/vfs/" + encodeURI(filePath.replace(/^\\//, ""));
5120
+ a.textContent = "Download";
5121
+ a.setAttribute("download", filename);
5122
+ return a;
5123
+ };
5124
+
5125
+ const buildPreviewActions = (filePath, leftExtras = [], rightExtras = []) => {
5126
+ const actions = document.createElement("div");
5127
+ actions.className = "file-preview-actions";
5128
+ const left = document.createElement("div");
5129
+ left.className = "file-preview-actions-group";
5130
+ left.appendChild(buildDownloadLink(filePath));
5131
+ left.appendChild(buildCopyLinkButton(filePath));
5132
+ for (const el of leftExtras) left.appendChild(el);
5133
+ const right = document.createElement("div");
5134
+ right.className = "file-preview-actions-group";
5135
+ for (const el of rightExtras) right.appendChild(el);
5136
+ actions.append(left, right);
5137
+ return actions;
5138
+ };
5139
+
5140
+ const csvDelimiter = (mime, name) => {
5141
+ const m = (mime || "").toLowerCase();
5142
+ const ext = (name.split(".").pop() || "").toLowerCase();
5143
+ if (m === "text/csv" || ext === "csv") return ",";
5144
+ if (m === "text/tab-separated-values" || ext === "tsv") return "\\t";
5145
+ return null;
5146
+ };
5147
+
5148
+ const parseDelimited = (text, delimiter) => {
5149
+ const rows = [];
5150
+ let row = [];
5151
+ let field = "";
5152
+ let inQuotes = false;
5153
+ for (let i = 0; i < text.length; i++) {
5154
+ const c = text[i];
5155
+ if (inQuotes) {
5156
+ if (c === '"') {
5157
+ if (text[i + 1] === '"') { field += '"'; i++; }
5158
+ else inQuotes = false;
5159
+ } else {
5160
+ field += c;
5161
+ }
5162
+ continue;
5163
+ }
5164
+ if (c === '"') { inQuotes = true; continue; }
5165
+ if (c === delimiter) { row.push(field); field = ""; continue; }
5166
+ if (c === "\\n" || c === "\\r") {
5167
+ if (c === "\\r" && text[i + 1] === "\\n") i++;
5168
+ row.push(field);
5169
+ field = "";
5170
+ rows.push(row);
5171
+ row = [];
5172
+ continue;
5173
+ }
5174
+ field += c;
5175
+ }
5176
+ if (field.length > 0 || row.length > 0) {
5177
+ row.push(field);
5178
+ rows.push(row);
5179
+ }
5180
+ return rows;
5181
+ };
5182
+
5183
+ const TABLE_PREVIEW_MAX_ROWS = 5000;
5184
+
5185
+ const isMarkdownFile = (mime, name) => {
5186
+ const m = (mime || "").toLowerCase();
5187
+ if (m.startsWith("text/markdown") || m === "text/x-markdown") return true;
5188
+ const ext = (name.split(".").pop() || "").toLowerCase();
5189
+ return ext === "md" || ext === "markdown" || ext === "mdx";
5190
+ };
5191
+
5192
+ const flashLabel = (btn, label, durationMs) => {
5193
+ const original = btn.dataset.label || btn.textContent;
5194
+ btn.dataset.label = original;
5195
+ btn.textContent = label;
5196
+ if (btn._flashTimer) clearTimeout(btn._flashTimer);
5197
+ btn._flashTimer = setTimeout(() => { btn.textContent = original; }, durationMs);
5198
+ };
5199
+
5200
+ const buildCopyTextButton = (text) => {
5201
+ const btn = document.createElement("button");
5202
+ btn.className = "file-preview-action-btn";
5203
+ btn.textContent = "Copy";
5204
+ btn.onclick = async () => {
5205
+ try {
5206
+ await navigator.clipboard.writeText(text);
5207
+ flashLabel(btn, "Copied", 1500);
5208
+ } catch (err) {
5209
+ window.alert("Failed to copy: " + (err.message || "clipboard unavailable"));
5210
+ }
5211
+ };
5212
+ return btn;
5213
+ };
5214
+
5215
+ const buildCopyLinkButton = (filePath) => {
5216
+ const btn = document.createElement("button");
5217
+ btn.className = "file-preview-action-btn";
5218
+ btn.textContent = "Copy link";
5219
+ btn.onclick = async () => {
5220
+ const url = window.location.origin + "/api/vfs/" + encodeURI(filePath.replace(/^\\//, ""));
5221
+ try {
5222
+ await navigator.clipboard.writeText(url);
5223
+ flashLabel(btn, "Copied", 1500);
5224
+ } catch (err) {
5225
+ window.alert("Failed to copy: " + (err.message || "clipboard unavailable"));
5226
+ }
5227
+ };
5228
+ return btn;
5229
+ };
5230
+
5231
+ const renderTextView = (filePath, text, mime) => {
5232
+ const messages = elements.messages;
5233
+ const filename = filePath.split("/").pop() || filePath;
5234
+ const wrap = document.createElement("div");
5235
+ wrap.className = "file-preview";
5236
+ const copyBtn = buildCopyTextButton(text);
5237
+ const editBtn = document.createElement("button");
5238
+ editBtn.className = "file-preview-action-btn";
5239
+ editBtn.textContent = "Edit";
5240
+ editBtn.onclick = () => renderTextEditor(filePath, text, mime);
5241
+
5242
+ let body;
5243
+ const delimiter = csvDelimiter(mime, filename);
5244
+ if (delimiter) {
5245
+ body = document.createElement("div");
5246
+ body.className = "file-preview-table-wrap";
5247
+ const rows = parseDelimited(text, delimiter);
5248
+ if (rows.length === 0) {
5249
+ const empty = document.createElement("div");
5250
+ empty.className = "file-explorer-empty";
5251
+ empty.textContent = "Empty file";
5252
+ body.appendChild(empty);
5253
+ } else {
5254
+ const truncated = rows.length > TABLE_PREVIEW_MAX_ROWS;
5255
+ const visibleRows = truncated ? rows.slice(0, TABLE_PREVIEW_MAX_ROWS) : rows;
5256
+ const table = document.createElement("table");
5257
+ table.className = "file-preview-table";
5258
+ const thead = document.createElement("thead");
5259
+ const headTr = document.createElement("tr");
5260
+ for (const cell of visibleRows[0]) {
5261
+ const th = document.createElement("th");
5262
+ th.textContent = cell;
5263
+ headTr.appendChild(th);
5264
+ }
5265
+ thead.appendChild(headTr);
5266
+ table.appendChild(thead);
5267
+ const tbody = document.createElement("tbody");
5268
+ for (let r = 1; r < visibleRows.length; r++) {
5269
+ const tr = document.createElement("tr");
5270
+ for (const cell of visibleRows[r]) {
5271
+ const td = document.createElement("td");
5272
+ td.textContent = cell;
5273
+ tr.appendChild(td);
5274
+ }
5275
+ tbody.appendChild(tr);
5276
+ }
5277
+ table.appendChild(tbody);
5278
+ body.appendChild(table);
5279
+ if (truncated) {
5280
+ const note = document.createElement("div");
5281
+ note.className = "file-preview-table-truncated";
5282
+ note.textContent = "Showing first " + TABLE_PREVIEW_MAX_ROWS + " of " + rows.length + " rows. Click Edit to see the raw file.";
5283
+ body.appendChild(note);
5284
+ }
5285
+ }
5286
+ } else if (isMarkdownFile(mime, filename)) {
5287
+ body = document.createElement("div");
5288
+ body.className = "file-preview-markdown";
5289
+ const inner = document.createElement("div");
5290
+ inner.className = "assistant-content";
5291
+ inner.innerHTML = renderAssistantMarkdown(text);
5292
+ body.appendChild(inner);
5293
+ } else {
5294
+ body = document.createElement("pre");
5295
+ body.className = "file-preview-text";
5296
+ body.textContent = text;
5297
+ }
5298
+ wrap.append(buildPreviewActions(filePath, [copyBtn], [editBtn]), body);
5299
+ messages.innerHTML = "";
5300
+ messages.appendChild(wrap);
5301
+ };
5302
+
5303
+ const renderTextEditor = (filePath, originalText, mime) => {
5304
+ const messages = elements.messages;
5305
+ const wrap = document.createElement("div");
5306
+ wrap.className = "file-preview";
5307
+ const actions = document.createElement("div");
5308
+ actions.className = "file-preview-actions";
5309
+ const cancelBtn = document.createElement("button");
5310
+ cancelBtn.className = "file-preview-action-btn";
5311
+ cancelBtn.textContent = "Cancel";
5312
+ const saveBtn = document.createElement("button");
5313
+ saveBtn.className = "file-preview-action-btn primary";
5314
+ saveBtn.textContent = "Save";
5315
+ saveBtn.disabled = true;
5316
+ const leftGroup = document.createElement("div");
5317
+ leftGroup.className = "file-preview-actions-group";
5318
+ const rightGroup = document.createElement("div");
5319
+ rightGroup.className = "file-preview-actions-group";
5320
+ rightGroup.append(cancelBtn, saveBtn);
5321
+ actions.append(leftGroup, rightGroup);
5322
+ const textarea = document.createElement("textarea");
5323
+ textarea.className = "file-edit-textarea";
5324
+ textarea.value = originalText;
5325
+ textarea.spellcheck = false;
5326
+ textarea.oninput = () => { saveBtn.disabled = textarea.value === originalText; };
5327
+ cancelBtn.onclick = () => renderTextView(filePath, originalText, mime);
5328
+ saveBtn.onclick = async () => {
5329
+ const newText = textarea.value;
5330
+ saveBtn.disabled = true;
5331
+ saveBtn.textContent = "Saving…";
5332
+ cancelBtn.disabled = true;
5333
+ try {
5334
+ const url = "/api/vfs/" + encodeURI(filePath.replace(/^\\//, "")) + "?overwrite=1";
5335
+ const response = await fetch(url, {
5336
+ method: "PUT",
5337
+ credentials: state.tenantToken ? "omit" : "include",
5338
+ headers: { ...buildAuthHeaders(), "Content-Type": mime || "text/plain" },
5339
+ body: newText,
5340
+ });
5341
+ if (!response.ok) {
5342
+ let msg = "Save failed (" + response.status + ")";
5343
+ try { const p = await response.json(); if (p && p.message) msg = p.message; } catch {}
5344
+ throw new Error(msg);
5345
+ }
5346
+ } catch (err) {
5347
+ saveBtn.textContent = "Save";
5348
+ saveBtn.disabled = false;
5349
+ cancelBtn.disabled = false;
5350
+ window.alert("Failed to save: " + (err.message || "unknown error"));
5351
+ return;
5352
+ }
5353
+ // Invalidate parent dir cache so file size/mtime refresh on next view
5354
+ state.dirCache.delete(parentPath(filePath));
5355
+ renderTextView(filePath, newText, mime);
5356
+ };
5357
+ wrap.append(actions, textarea);
5358
+ messages.innerHTML = "";
5359
+ messages.appendChild(wrap);
5360
+ textarea.focus();
5361
+ };
5362
+
5363
+ const renderPreviewPlaceholder = (filePath, mime, size, reason) => {
5364
+ const messages = elements.messages;
5365
+ const filename = filePath.split("/").pop() || filePath;
5366
+ const downloadUrl = "/api/vfs/" + encodeURI(filePath.replace(/^\\//, ""));
5367
+ const wrap = document.createElement("div");
5368
+ wrap.className = "file-preview";
5369
+ const inner = document.createElement("div");
5370
+ inner.className = "file-preview-placeholder";
5371
+ const card = document.createElement("div");
5372
+ const nameEl = document.createElement("div");
5373
+ nameEl.className = "file-preview-name";
5374
+ nameEl.textContent = filename;
5375
+ const metaEl = document.createElement("div");
5376
+ metaEl.className = "file-preview-meta";
5377
+ const metaParts = [];
5378
+ if (mime) metaParts.push(mime);
5379
+ if (size) metaParts.push(formatBytes(size));
5380
+ metaEl.textContent = (reason ? reason + " · " : "") + metaParts.join(" · ");
5381
+ const a = document.createElement("a");
5382
+ a.className = "file-preview-download";
5383
+ a.href = downloadUrl;
5384
+ a.textContent = "Download";
5385
+ a.setAttribute("download", filename);
5386
+ card.append(nameEl, metaEl, a);
5387
+ inner.appendChild(card);
5388
+ wrap.appendChild(inner);
5389
+ messages.innerHTML = "";
5390
+ messages.appendChild(wrap);
5391
+ };
5392
+
5393
+ const renderPreviewError = (filePath, message) => {
5394
+ const messages = elements.messages;
5395
+ const filename = filePath.split("/").pop() || filePath;
5396
+ const wrap = document.createElement("div");
5397
+ wrap.className = "file-preview";
5398
+ const inner = document.createElement("div");
5399
+ inner.className = "file-preview-placeholder";
5400
+ const card = document.createElement("div");
5401
+ card.innerHTML = '<div class="file-preview-name">' + escapeHtml(filename) + '</div><div class="file-preview-meta">Failed to load file (' + escapeHtml(message) + ')</div>';
5402
+ inner.appendChild(card);
5403
+ wrap.appendChild(inner);
5404
+ messages.innerHTML = "";
5405
+ messages.appendChild(wrap);
5406
+ };
5407
+
5408
+ const updateComposerVisibility = () => {
5409
+ if (!elements.composer) return;
5410
+ if (state.activeFilePath) elements.composer.classList.add("hidden");
5411
+ else elements.composer.classList.remove("hidden");
5412
+ };
5413
+
5414
+ const deleteEntry = async (path, type, parentDir) => {
5415
+ try {
5416
+ const url = "/api/vfs/" + encodeURI(path.replace(/^\\//, ""));
5417
+ const response = await fetch(url, {
5418
+ method: "DELETE",
5419
+ credentials: state.tenantToken ? "omit" : "include",
5420
+ headers: buildAuthHeaders(),
5421
+ });
5422
+ if (!response.ok) {
5423
+ let msg = "Delete failed (" + response.status + ")";
5424
+ try { const p = await response.json(); if (p && p.message) msg = p.message; } catch {}
5425
+ throw new Error(msg);
5426
+ }
5427
+ } catch (err) {
5428
+ window.alert("Failed to delete: " + (err.message || "unknown error"));
5429
+ renderFileExplorer();
5430
+ return;
5431
+ }
5432
+ // Drop cache for parent and any descendants we cached
5433
+ state.dirCache.delete(parentDir);
5434
+ if (type === "directory") {
5435
+ const prefix = path === "/" ? "/" : path + "/";
5436
+ for (const k of Array.from(state.dirCache.keys())) {
5437
+ if (k === path || k.startsWith(prefix)) state.dirCache.delete(k);
5438
+ }
5439
+ for (const d of Array.from(state.expandedDirs)) {
5440
+ if (d === path || d.startsWith(prefix)) state.expandedDirs.delete(d);
5441
+ }
5442
+ }
5443
+ if (state.activeFilePath === path || (type === "directory" && state.activeFilePath && state.activeFilePath.startsWith(path + "/"))) {
5444
+ state.activeFilePath = null;
5445
+ elements.chatTitle.textContent = "";
5446
+ elements.messages.innerHTML = "";
5447
+ updateComposerVisibility();
5448
+ replaceFileUrl(null);
5449
+ }
5450
+ try { await fetchDirEntries(parentDir); } catch {}
5451
+ renderFileExplorer();
5452
+ };
5453
+
5454
+ const switchSidebarMode = (mode) => {
5455
+ if (mode !== "conversations" && mode !== "files") return;
5456
+ state.sidebarMode = mode;
5457
+ const buttons = document.querySelectorAll(".sidebar-segmented .seg-btn");
5458
+ buttons.forEach((b) => {
5459
+ if (b.dataset.mode === mode) b.classList.add("active");
5460
+ else b.classList.remove("active");
5461
+ });
5462
+ const list = elements.list;
5463
+ const explorer = elements.fileExplorer;
5464
+ if (mode === "files") {
5465
+ list.classList.add("hidden");
5466
+ explorer.classList.remove("hidden");
5467
+ if (!state.dirCache.has("/")) {
5468
+ ensureDirLoaded("/").then(() => renderFileExplorer());
5469
+ }
5470
+ renderFileExplorer();
5471
+ } else {
5472
+ explorer.classList.add("hidden");
5473
+ list.classList.remove("hidden");
5474
+ }
5475
+ };
5476
+
5477
+ // ----- Uploads + mkdir + drag-drop -----
5478
+
5479
+ let _hiddenUploadInput = null;
5480
+ const triggerUploadPicker = (targetDir) => {
5481
+ if (!_hiddenUploadInput) {
5482
+ _hiddenUploadInput = document.createElement("input");
5483
+ _hiddenUploadInput.type = "file";
5484
+ _hiddenUploadInput.multiple = true;
5485
+ _hiddenUploadInput.style.display = "none";
5486
+ document.body.appendChild(_hiddenUploadInput);
5487
+ }
5488
+ _hiddenUploadInput.value = "";
5489
+ _hiddenUploadInput.onchange = () => {
5490
+ const files = Array.from(_hiddenUploadInput.files || []);
5491
+ if (files.length > 0) uploadFiles(files, targetDir);
5492
+ };
5493
+ _hiddenUploadInput.click();
5494
+ };
5495
+
5496
+ const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
5497
+
5498
+ const uploadOne = async (file, targetDir, overwrite) => {
5499
+ if (file.size > MAX_UPLOAD_BYTES) {
5500
+ throw new Error("File too large (limit " + formatBytes(MAX_UPLOAD_BYTES) + ")");
5501
+ }
5502
+ const targetPath = joinPath(targetDir, file.name);
5503
+ const url = "/api/vfs/" + encodeURI(targetPath.replace(/^\\//, "")) + (overwrite ? "?overwrite=1" : "");
5504
+ const response = await fetch(url, {
5505
+ method: "PUT",
5506
+ credentials: state.tenantToken ? "omit" : "include",
5507
+ headers: { ...buildAuthHeaders(), "Content-Type": file.type || "application/octet-stream" },
5508
+ body: file,
5509
+ });
5510
+ if (response.status === 409) {
5511
+ const ok = window.confirm("A file named \\\"" + file.name + "\\\" already exists in " + targetDir + ". Overwrite?");
5512
+ if (!ok) return;
5513
+ return uploadOne(file, targetDir, true);
5514
+ }
5515
+ if (!response.ok) {
5516
+ let msg = "Upload failed (" + response.status + ")";
5517
+ try { const p = await response.json(); if (p && p.message) msg = p.message; } catch {}
5518
+ throw new Error(msg);
5519
+ }
5520
+ };
5521
+
5522
+ const uploadFiles = async (files, targetDir) => {
5523
+ for (const file of files) {
5524
+ state.pendingUploads += 1;
5525
+ renderFileExplorer();
5526
+ try {
5527
+ await uploadOne(file, targetDir, false);
5528
+ } catch (err) {
5529
+ window.alert("Failed to upload " + file.name + ": " + (err.message || "unknown error"));
5530
+ } finally {
5531
+ state.pendingUploads -= 1;
5532
+ }
5533
+ }
5534
+ state.dirCache.delete(targetDir);
5535
+ try { await fetchDirEntries(targetDir); } catch {}
5536
+ state.expandedDirs.add(targetDir);
5537
+ renderFileExplorer();
5538
+ };
5539
+
5540
+ let _dropTargetEl = null;
5541
+ const _setDropTarget = (el) => {
5542
+ if (_dropTargetEl === el) return;
5543
+ if (_dropTargetEl) _dropTargetEl.classList.remove("drop-target");
5544
+ _dropTargetEl = el;
5545
+ if (_dropTargetEl) _dropTargetEl.classList.add("drop-target");
5546
+ };
5547
+ const _resolveDropTarget = (eventTarget) => {
5548
+ if (!(eventTarget instanceof Element)) return null;
5549
+ // A folder header row takes precedence — drop INTO that folder.
5550
+ const folderRow = eventTarget.closest(".file-row.is-dir");
5551
+ if (folderRow && elements.fileExplorer.contains(folderRow)) return folderRow;
5552
+ // Otherwise the deepest .file-children wrap — drop into its parent dir.
5553
+ const childrenWrap = eventTarget.closest(".file-children");
5554
+ if (childrenWrap && elements.fileExplorer.contains(childrenWrap)) return childrenWrap;
5555
+ return null;
5556
+ };
5557
+ const _resolveDropPath = (el) => {
5558
+ if (!el) return "/";
5559
+ if (el.classList.contains("file-row")) return el.dataset.path || "/";
5560
+ if (el.classList.contains("file-children")) return el.dataset.dir || "/";
5561
+ return "/";
5562
+ };
5563
+
5564
+ const attachExplorerDropHandlers = () => {
5565
+ const root = elements.fileExplorer;
5566
+ if (!root) return;
5567
+ root.addEventListener("dragover", (e) => {
5568
+ if (!e.dataTransfer || ![...(e.dataTransfer.types || [])].includes("Files")) return;
5569
+ e.preventDefault();
5570
+ _setDropTarget(_resolveDropTarget(e.target));
5571
+ });
5572
+ root.addEventListener("dragleave", (e) => {
5573
+ if (!root.contains(e.relatedTarget)) _setDropTarget(null);
5574
+ });
5575
+ root.addEventListener("drop", (e) => {
5576
+ if (!e.dataTransfer || !e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
5577
+ e.preventDefault();
5578
+ const target = _dropTargetEl;
5579
+ const dir = _resolveDropPath(target);
5580
+ _setDropTarget(null);
5581
+ const items = Array.from(e.dataTransfer.items || []);
5582
+ const hasDir = items.some((it) => {
5583
+ const fn = it.webkitGetAsEntry && it.webkitGetAsEntry();
5584
+ return fn && fn.isDirectory;
5585
+ });
5586
+ const files = Array.from(e.dataTransfer.files);
5587
+ if (hasDir) {
5588
+ window.alert("Folder uploads aren't supported yet — drop individual files.");
5589
+ }
5590
+ if (files.length > 0) uploadFiles(files, dir);
5591
+ });
5592
+ };
5593
+
5594
+ // Wire segmented control + drop-handlers once the DOM is ready
5595
+ (function wireFileExplorer() {
5596
+ const buttons = document.querySelectorAll(".sidebar-segmented .seg-btn");
5597
+ buttons.forEach((b) => {
5598
+ b.addEventListener("click", () => switchSidebarMode(b.dataset.mode));
5599
+ });
5600
+ attachExplorerDropHandlers();
5601
+ })();
5602
+
4607
5603
  window.addEventListener("popstate", async () => {
4608
5604
  if (state.isStreaming) return;
5605
+ const filePath = getFilePathFromUrl();
5606
+ if (filePath) {
5607
+ switchSidebarMode("files");
5608
+ await selectFile(filePath);
5609
+ return;
5610
+ }
4609
5611
  const conversationId = getConversationIdFromUrl();
4610
5612
  await navigateToConversation(conversationId);
4611
5613
  });
@@ -4616,6 +5618,26 @@ export const getWebUiClientScript = (markedSource: string): string => `
4616
5618
  return;
4617
5619
  }
4618
5620
  await loadConversations();
5621
+ const urlFilePath = getFilePathFromUrl();
5622
+ if (urlFilePath) {
5623
+ switchSidebarMode("files");
5624
+ // Expand ancestors so the file is visible in the tree
5625
+ let p = parentPath(urlFilePath);
5626
+ const ancestors = [];
5627
+ while (p && p !== "/") { ancestors.push(p); p = parentPath(p); }
5628
+ ancestors.push("/");
5629
+ for (const a of ancestors.reverse()) {
5630
+ state.expandedDirs.add(a);
5631
+ try { await fetchDirEntries(a); } catch {}
5632
+ }
5633
+ state.activeFilePath = urlFilePath;
5634
+ updateComposerVisibility();
5635
+ renderFileExplorer();
5636
+ await renderFilePreview(urlFilePath);
5637
+ autoResizePrompt();
5638
+ updateContextRing();
5639
+ return;
5640
+ }
4619
5641
  const urlConversationId = getConversationIdFromUrl();
4620
5642
  if (urlConversationId) {
4621
5643
  state.activeConversationId = urlConversationId;