@grifhinz/logics-manager 2.8.0 → 2.9.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.
@@ -1,5 +1,59 @@
1
1
  (() => {
2
2
  const stateKey = "logics.localViewer.state";
3
+ const preferenceKey = "logics.localViewer.preferences.v1";
4
+ const lanTokenKey = "logics.lan.token";
5
+
6
+ function captureLanTokenFromUrl() {
7
+ try {
8
+ const url = new URL(window.location.href);
9
+ const queryToken = url.searchParams.get("t");
10
+ if (queryToken) {
11
+ window.sessionStorage.setItem(lanTokenKey, queryToken);
12
+ url.searchParams.delete("t");
13
+ const cleaned = `${url.pathname}${url.search}${url.hash}`;
14
+ window.history.replaceState(null, "", cleaned || "/");
15
+ }
16
+ } catch {
17
+ // sessionStorage / history may be unavailable in some embed contexts.
18
+ }
19
+ }
20
+
21
+ function getLanToken() {
22
+ try {
23
+ return window.sessionStorage.getItem(lanTokenKey) || "";
24
+ } catch {
25
+ return "";
26
+ }
27
+ }
28
+
29
+ captureLanTokenFromUrl();
30
+
31
+ const originalFetch = window.fetch.bind(window);
32
+ window.fetch = (input, init) => {
33
+ const token = getLanToken();
34
+ if (!token) return originalFetch(input, init);
35
+ const next = init ? { ...init } : {};
36
+ const headers = new Headers(next.headers || (input instanceof Request ? input.headers : undefined));
37
+ if (!headers.has("Authorization")) headers.set("Authorization", `Bearer ${token}`);
38
+ next.headers = headers;
39
+ return originalFetch(input, next);
40
+ };
41
+
42
+ if (typeof window.EventSource === "function") {
43
+ const NativeEventSource = window.EventSource;
44
+ window.EventSource = function PatchedEventSource(url, init) {
45
+ const token = getLanToken();
46
+ if (!token || typeof url !== "string") {
47
+ return new NativeEventSource(url, init);
48
+ }
49
+ const separator = url.includes("?") ? "&" : "?";
50
+ const tokenized = `${url}${separator}t=${encodeURIComponent(token)}`;
51
+ return new NativeEventSource(tokenized, init);
52
+ };
53
+ window.EventSource.prototype = NativeEventSource.prototype;
54
+ }
55
+
56
+ const preferenceVersion = 1;
3
57
  const meta = () => document.getElementById("viewer-meta");
4
58
  const documentPanel = () => document.getElementById("viewer-document");
5
59
  const documentTitle = () => document.getElementById("viewer-document-title");
@@ -16,6 +70,8 @@
16
70
  const projectMenu = () => document.getElementById("viewer-project-menu");
17
71
  const repoGithubLink = () => document.getElementById("viewer-repo-github");
18
72
  const repoFolderButton = () => document.getElementById("viewer-repo-folder");
73
+ const workspaceButton = () => document.getElementById("viewer-workspace");
74
+ const workshopButton = () => document.getElementById("viewer-workshop");
19
75
  const ciButton = () => document.getElementById("viewer-ci");
20
76
  const autoRefreshControl = () => document.getElementById("viewer-auto-refresh");
21
77
  const refreshIntervalControl = () => document.getElementById("viewer-refresh-interval");
@@ -72,10 +128,27 @@
72
128
  let latestViewerStateSignature = "";
73
129
  let latestGitStatusSignature = "";
74
130
  let latestCdxStatusSignature = "";
131
+ let latestCdxStatusPayload = null;
75
132
  let latestCiStatusSignature = "";
76
133
  let primaryActionBusyKey = "";
77
134
  let cdxMissionBusyKey = "";
78
135
  let cdxCloseTarget = null;
136
+ let viewerPreferences = readViewerPreferences();
137
+ let autoRefreshIntervalForcedByLaunch = false;
138
+ const cdxStatusColumns = [
139
+ { id: "session", label: "SESSION" },
140
+ { id: "provider", label: "PROV." },
141
+ { id: "status", label: "STATUS" },
142
+ { id: "auth", label: "AUTH" },
143
+ { id: "ok", label: "OK" },
144
+ { id: "remaining5h", label: "5H" },
145
+ { id: "remainingWeek", label: "WEEK" },
146
+ { id: "block", label: "BLOCK", defaultVisible: false },
147
+ { id: "credits", label: "CR", defaultVisible: false },
148
+ { id: "reset5h", label: "RESET 5H" },
149
+ { id: "resetWeek", label: "RESET WEEK" },
150
+ { id: "updated", label: "UPDATED" }
151
+ ];
79
152
 
80
153
  function readStoredState() {
81
154
  try {
@@ -85,6 +158,83 @@
85
158
  }
86
159
  }
87
160
 
161
+ function readViewerPreferences() {
162
+ try {
163
+ const value = JSON.parse(window.localStorage.getItem(preferenceKey) || "null");
164
+ if (!value || typeof value !== "object" || value.version !== preferenceVersion) {
165
+ return { version: preferenceVersion };
166
+ }
167
+ return { ...value, version: preferenceVersion };
168
+ } catch {
169
+ return { version: preferenceVersion };
170
+ }
171
+ }
172
+
173
+ function writeViewerPreferences(nextPreferences) {
174
+ viewerPreferences = { ...nextPreferences, version: preferenceVersion };
175
+ try {
176
+ window.localStorage.setItem(preferenceKey, JSON.stringify(viewerPreferences));
177
+ } catch {
178
+ // Keep the in-memory preference for this session when browser storage is unavailable.
179
+ }
180
+ }
181
+
182
+ function updateViewerPreferences(patch) {
183
+ writeViewerPreferences({ ...viewerPreferences, ...patch });
184
+ }
185
+
186
+ function preferredAutoRefreshIntervalSeconds() {
187
+ const seconds = Number(viewerPreferences.autoRefreshIntervalSeconds);
188
+ return Number.isFinite(seconds) && seconds > 0 ? normalizeAutoRefreshIntervalSeconds(seconds) : null;
189
+ }
190
+
191
+ function cdxColumnVisibilityPreference() {
192
+ const stored = viewerPreferences.cdxStatusColumns;
193
+ const storedVisibility = stored && typeof stored === "object" ? stored.visibility : null;
194
+ const visibility = {};
195
+ cdxStatusColumns.forEach((column) => {
196
+ visibility[column.id] = column.defaultVisible !== false;
197
+ if (storedVisibility && typeof storedVisibility[column.id] === "boolean") {
198
+ visibility[column.id] = storedVisibility[column.id];
199
+ }
200
+ });
201
+ return visibility;
202
+ }
203
+
204
+ function persistCdxColumnVisibility(columnId, visible) {
205
+ const current = cdxColumnVisibilityPreference();
206
+ if (!cdxStatusColumns.some((column) => column.id === columnId)) {
207
+ return;
208
+ }
209
+ updateViewerPreferences({
210
+ cdxStatusColumns: {
211
+ visibility: { ...current, [columnId]: Boolean(visible) }
212
+ }
213
+ });
214
+ }
215
+
216
+ function cdxProviderFilterPreference() {
217
+ const stored = viewerPreferences.cdxStatusProviders;
218
+ if (!stored || typeof stored !== "object" || stored.mode !== "subset") {
219
+ return { mode: "all", selected: [] };
220
+ }
221
+ const selected = Array.isArray(stored.selected)
222
+ ? stored.selected.map((entry) => String(entry || "").trim()).filter(Boolean)
223
+ : [];
224
+ return selected.length ? { mode: "subset", selected: Array.from(new Set(selected)) } : { mode: "all", selected: [] };
225
+ }
226
+
227
+ function persistCdxProviderFilter(nextFilter) {
228
+ const selected = Array.isArray(nextFilter?.selected)
229
+ ? nextFilter.selected.map((entry) => String(entry || "").trim()).filter(Boolean)
230
+ : [];
231
+ updateViewerPreferences({
232
+ cdxStatusProviders: selected.length
233
+ ? { mode: "subset", selected: Array.from(new Set(selected)).sort() }
234
+ : { mode: "all", selected: [] }
235
+ });
236
+ }
237
+
88
238
  function sanitizeViewerFilterState(value) {
89
239
  const nextState = { ...defaultFilterState };
90
240
  if (!value || typeof value !== "object") {
@@ -155,6 +305,7 @@
155
305
  return Array.from(document.querySelectorAll([
156
306
  "#viewer-insights",
157
307
  "#viewer-health",
308
+ "#viewer-workspace",
158
309
  "#viewer-git",
159
310
  "#viewer-ci",
160
311
  "#viewer-cdx",
@@ -452,6 +603,7 @@
452
603
  autoRefreshIntervalMs = normalizeAutoRefreshIntervalSeconds(value) * 1000;
453
604
  if (options.user) {
454
605
  autoRefreshIntervalTouched = true;
606
+ updateViewerPreferences({ autoRefreshIntervalSeconds: Math.round(autoRefreshIntervalMs / 1000) });
455
607
  }
456
608
  updateRefreshIntervalControl();
457
609
  scheduleNextAutoRefresh();
@@ -582,10 +734,64 @@
582
734
  setMeta(created > 0 ? `Logics bootstrapped · ${created} paths created.` : "Logics bootstrap checked.");
583
735
  }
584
736
 
737
+ let latestLanShareUrl = "";
738
+
739
+ async function copyTextToClipboard(text) {
740
+ if (!text) return false;
741
+ try {
742
+ if (navigator.clipboard && window.isSecureContext) {
743
+ await navigator.clipboard.writeText(text);
744
+ return true;
745
+ }
746
+ } catch { /* fall through to legacy path */ }
747
+ try {
748
+ const textarea = document.createElement("textarea");
749
+ textarea.value = text;
750
+ textarea.setAttribute("readonly", "");
751
+ textarea.style.position = "fixed";
752
+ textarea.style.top = "0";
753
+ textarea.style.left = "0";
754
+ textarea.style.opacity = "0";
755
+ textarea.style.pointerEvents = "none";
756
+ document.body.appendChild(textarea);
757
+ textarea.focus();
758
+ textarea.select();
759
+ textarea.setSelectionRange(0, text.length);
760
+ const ok = document.execCommand("copy");
761
+ document.body.removeChild(textarea);
762
+ return ok;
763
+ } catch {
764
+ return false;
765
+ }
766
+ }
767
+
768
+ function applyLanBanner(active, shareUrl) {
769
+ const banner = document.getElementById("viewer-lan-banner");
770
+ if (!(banner instanceof HTMLElement)) return;
771
+ banner.hidden = !active;
772
+ latestLanShareUrl = active ? String(shareUrl || "") : "";
773
+ const urlNode = document.getElementById("viewer-lan-banner-url");
774
+ const copyButton = document.getElementById("viewer-lan-banner-copy");
775
+ if (urlNode instanceof HTMLElement) {
776
+ if (latestLanShareUrl) {
777
+ urlNode.hidden = false;
778
+ urlNode.textContent = latestLanShareUrl;
779
+ } else {
780
+ urlNode.hidden = true;
781
+ urlNode.textContent = "";
782
+ }
783
+ }
784
+ if (copyButton instanceof HTMLButtonElement) {
785
+ copyButton.hidden = !latestLanShareUrl;
786
+ }
787
+ }
788
+
585
789
  function normalizeCapabilities(payload) {
586
790
  const capabilities = payload?.capabilities && typeof payload.capabilities === "object" ? payload.capabilities : {};
587
791
  return {
588
792
  logics: capabilities.logics || { state: "ready", available: true, message: "" },
793
+ workspace: capabilities.workspace || { state: "ready", available: true, message: "" },
794
+ workshop: capabilities.workshop || { state: "missing", available: false, message: "" },
589
795
  git: capabilities.git || { state: "ready", available: true, message: "" },
590
796
  ci: capabilities.ci || { state: "ready", available: true, message: "" },
591
797
  cdx: capabilities.cdx || { state: "ready", available: true, message: "" },
@@ -624,6 +830,29 @@
624
830
  }
625
831
 
626
832
  function updateCapabilityControls() {
833
+ const workspace = workspaceButton();
834
+ if (workspace instanceof HTMLElement) {
835
+ workspace.hidden = !isCapabilityAvailable("workspace");
836
+ if (isCapabilityAvailable("workspace")) {
837
+ setButtonAvailable(workspace, "Show file explorer");
838
+ } else {
839
+ setButtonUnavailable(workspace, capabilityMessage("workspace", "Explorer is not available for this project."));
840
+ }
841
+ }
842
+
843
+ const workshop = workshopButton();
844
+ if (workshop instanceof HTMLElement) {
845
+ const workshopAvailable = isCapabilityAvailable("workshop");
846
+ workshop.hidden = !workshopAvailable;
847
+ if (workshopAvailable) {
848
+ setButtonAvailable(workshop, "Show Workshop (terminals and commands)");
849
+ } else {
850
+ setButtonUnavailable(workshop, capabilityMessage("workshop", "Workshop is not available for this project."));
851
+ }
852
+ updateWorkshopBadges();
853
+ hydrateWorkshopTerminals();
854
+ }
855
+
627
856
  const gitButton = document.getElementById("viewer-git");
628
857
  if (gitButton instanceof HTMLElement) {
629
858
  gitButton.hidden = !isCapabilityAvailable("git");
@@ -733,6 +962,42 @@
733
962
  return html ? `<span class="viewer-git-badges" data-viewer-git-badges="${escapeHtml(scope)}">${html}</span>` : "";
734
963
  }
735
964
 
965
+ let workshopBadgeCounts = { terminals: 0, commands: 0 };
966
+
967
+ function updateWorkshopBadges() {
968
+ const button = document.getElementById("viewer-workshop");
969
+ if (!(button instanceof HTMLElement)) return;
970
+ button.querySelector('[data-viewer-workshop-badges]')?.remove();
971
+ const { terminals, commands } = workshopBadgeCounts;
972
+ if (terminals <= 0 && commands <= 0) return;
973
+ const html = [
974
+ terminals > 0
975
+ ? `<span class="viewer-git-badge viewer-git-badge--commits" title="${escapeHtml(terminals + " terminal session(s) running")}" aria-label="${escapeHtml(terminals + " terminal session(s) running")}">${escapeHtml(String(terminals))}</span>`
976
+ : "",
977
+ commands > 0
978
+ ? `<span class="viewer-git-badge viewer-git-badge--files" title="${escapeHtml(commands + " command(s) running")}" aria-label="${escapeHtml(commands + " command(s) running")}">${escapeHtml(String(commands))}</span>`
979
+ : "",
980
+ ].filter(Boolean).join("");
981
+ if (html) {
982
+ button.insertAdjacentHTML("beforeend", `<span class="viewer-git-badges" data-viewer-workshop-badges>${html}</span>`);
983
+ }
984
+ }
985
+
986
+ function recomputeWorkshopBadges() {
987
+ const isRunning = (state) => state === "running" || state === "starting";
988
+ let terminals = 0;
989
+ for (const entry of workshopTerminalState.sessions.values()) {
990
+ if (isRunning(entry.state)) terminals += 1;
991
+ }
992
+ let commands = 0;
993
+ for (const entry of workshopCommandState.sessions.values()) {
994
+ if (isRunning(entry.state)) commands += 1;
995
+ }
996
+ if (workshopBadgeCounts.terminals === terminals && workshopBadgeCounts.commands === commands) return;
997
+ workshopBadgeCounts = { terminals, commands };
998
+ updateWorkshopBadges();
999
+ }
1000
+
736
1001
  function updateMainGitBadges() {
737
1002
  const button = document.getElementById("viewer-git");
738
1003
  if (!(button instanceof HTMLElement)) {
@@ -1225,11 +1490,18 @@
1225
1490
  latestViewerStateSignature = nextSignature;
1226
1491
  latestItems = updateStoredActivity(Array.isArray(payload.items) ? payload.items : []);
1227
1492
  if (!autoRefreshIntervalTouched) {
1228
- autoRefreshIntervalMs = normalizeAutoRefreshIntervalSeconds(payload.autoRefreshIntervalSeconds) * 1000;
1493
+ const launchSeconds = Number(payload.autoRefreshIntervalSeconds);
1494
+ const preferredSeconds = preferredAutoRefreshIntervalSeconds();
1495
+ autoRefreshIntervalForcedByLaunch = Boolean(payload.autoRefreshIntervalForced);
1496
+ const nextSeconds = autoRefreshIntervalForcedByLaunch || preferredSeconds === null
1497
+ ? launchSeconds
1498
+ : preferredSeconds;
1499
+ autoRefreshIntervalMs = normalizeAutoRefreshIntervalSeconds(nextSeconds) * 1000;
1229
1500
  updateRefreshIntervalControl();
1230
1501
  }
1231
1502
  updateRepositoryIdentity(payload);
1232
1503
  latestCapabilities = normalizeCapabilities(payload);
1504
+ applyLanBanner(Boolean(payload?.lanMode), String(payload?.lanShareUrl || ""));
1233
1505
  updateCapabilityControls();
1234
1506
  const payloadWithActivity = { ...payload, items: latestItems };
1235
1507
  const nextPayload = applyFocusRequest(payloadWithActivity, { silent: Boolean(options.silent) });
@@ -1302,6 +1574,12 @@
1302
1574
  return Boolean(panel && !panel.hidden && title && title.textContent === "Git status");
1303
1575
  }
1304
1576
 
1577
+ function isWorkspaceOpen() {
1578
+ const panel = documentPanel();
1579
+ const title = documentTitle();
1580
+ return Boolean(panel && !panel.hidden && title && title.textContent === "Explorer");
1581
+ }
1582
+
1305
1583
  function isCdxStatusOpen() {
1306
1584
  const panel = documentPanel();
1307
1585
  const title = documentTitle();
@@ -1328,7 +1606,11 @@
1328
1606
 
1329
1607
  async function refreshViewer(method = "POST", options = {}) {
1330
1608
  const changed = await loadItems(method, options);
1331
- if (isGitStatusOpen()) {
1609
+ if (isWorkspaceOpen()) {
1610
+ if (changed || options.force) {
1611
+ await showWorkspace({ silent: Boolean(options.silent) });
1612
+ }
1613
+ } else if (isGitStatusOpen()) {
1332
1614
  await showGitStatus({ preserve: true, silent: Boolean(options.silent), skipUnchanged: !changed && !options.force, force: Boolean(options.force) });
1333
1615
  } else if (isCiStatusOpen()) {
1334
1616
  await showCiStatus({ silent: Boolean(options.silent), skipUnchanged: !changed && !options.force, force: Boolean(options.force) });
@@ -2045,6 +2327,804 @@
2045
2327
  setMeta("Health loaded.");
2046
2328
  }
2047
2329
 
2330
+ function workspaceParentPath(path) {
2331
+ const parts = String(path || "").split("/").filter(Boolean);
2332
+ parts.pop();
2333
+ return parts.join("/");
2334
+ }
2335
+
2336
+ function renderWorkspaceBreadcrumb(currentPath) {
2337
+ const segments = String(currentPath || "").split("/").filter(Boolean);
2338
+ const crumbs = [
2339
+ `<button class="viewer-workspace__crumb" type="button" data-viewer-workspace-tree="" title="Workspace root">/</button>`,
2340
+ ];
2341
+ let accum = "";
2342
+ segments.forEach((segment, idx) => {
2343
+ accum = accum ? `${accum}/${segment}` : segment;
2344
+ const isLast = idx === segments.length - 1;
2345
+ crumbs.push(`<span class="viewer-workspace__crumb-sep" aria-hidden="true">/</span>`);
2346
+ crumbs.push(
2347
+ `<button class="viewer-workspace__crumb${isLast ? " is-current" : ""}" type="button" data-viewer-workspace-tree="${escapeHtml(accum)}" title="${escapeHtml(accum)}"${isLast ? ' aria-current="location"' : ""}>${escapeHtml(segment)}</button>`,
2348
+ );
2349
+ });
2350
+ return `<nav class="viewer-workspace__breadcrumb" aria-label="Workspace breadcrumb">${crumbs.join("")}</nav>`;
2351
+ }
2352
+
2353
+ function workspaceEntryIcon(kind, ignored) {
2354
+ if (kind === "directory") {
2355
+ return ignored
2356
+ ? '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M2 4h4l1 1h7v8H2V4Zm9.5 3.2L9.7 9l1.8 1.8-.7.7L9 9.7l-1.8 1.8-.7-.7L8.3 9 6.5 7.2l.7-.7L9 8.3l1.8-1.8.7.7Z"/></svg>'
2357
+ : '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M2 4h4l1 1h7v8H2V4Z"/></svg>';
2358
+ }
2359
+ return '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M4 2h6l3 3v9H4V2Zm6 0v3h3"/></svg>';
2360
+ }
2361
+
2362
+ function renderWorkspaceTree(treePayload, selectedPath = "") {
2363
+ if (!treePayload || treePayload.state !== "ok") {
2364
+ const message = treePayload?.message || "Workspace tree is unavailable.";
2365
+ const state = treePayload?.state === "unavailable" ? "unavailable" : "empty";
2366
+ return `<div class="viewer-workspace__placeholder viewer-workspace__placeholder--${state}"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">${state === "unavailable" ? "!" : "·"}</span><span>${escapeHtml(message)}</span></div>`;
2367
+ }
2368
+ const currentPath = String(treePayload.path || "");
2369
+ const parentPath = workspaceParentPath(currentPath);
2370
+ const upButton = currentPath
2371
+ ? `<button class="viewer-workspace__item viewer-workspace__item--up" type="button" data-viewer-workspace-tree="${escapeHtml(parentPath)}" title="Parent directory"><span class="viewer-workspace__item-icon" aria-hidden="true"><svg viewBox="0 0 16 16" focusable="false"><path fill="currentColor" d="M8 3 3 8h3v5h4V8h3L8 3Z"/></svg></span><span class="viewer-workspace__item-name">..</span></button>`
2372
+ : "";
2373
+ const rows = (Array.isArray(treePayload.entries) ? treePayload.entries : []).map((entry) => {
2374
+ const path = String(entry.path || "");
2375
+ const kind = String(entry.kind || "file");
2376
+ const ignored = Boolean(entry.ignored);
2377
+ const selected = path === selectedPath;
2378
+ const actionAttr = kind === "directory" && !ignored
2379
+ ? `data-viewer-workspace-tree="${escapeHtml(path)}"`
2380
+ : `data-viewer-workspace-preview="${escapeHtml(path)}"`;
2381
+ const classes = [
2382
+ "viewer-workspace__item",
2383
+ `viewer-workspace__item--${kind === "directory" ? "directory" : "file"}`,
2384
+ ];
2385
+ if (selected) classes.push("is-selected");
2386
+ if (ignored) classes.push("is-muted");
2387
+ return `
2388
+ <button class="${classes.join(" ")}" type="button" ${actionAttr} title="${escapeHtml(path)}"${selected ? ' aria-current="true"' : ""}>
2389
+ <span class="viewer-workspace__item-icon" aria-hidden="true">${workspaceEntryIcon(kind, ignored)}</span>
2390
+ <span class="viewer-workspace__item-name">${escapeHtml(entry.name || path || "/")}</span>
2391
+ </button>
2392
+ `;
2393
+ }).join("");
2394
+ return `
2395
+ <div class="viewer-workspace__tree-header">
2396
+ ${renderWorkspaceBreadcrumb(currentPath)}
2397
+ </div>
2398
+ <div class="viewer-workspace__tree-list" role="list">
2399
+ ${upButton}
2400
+ ${rows || '<div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span><span>Directory is empty.</span></div>'}
2401
+ </div>
2402
+ ${treePayload.truncated ? '<div class="viewer-workspace__placeholder viewer-workspace__placeholder--warn"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">!</span><span>Directory listing truncated.</span></div>' : ""}
2403
+ `;
2404
+ }
2405
+
2406
+ function renderWorkspacePreview(previewPayload) {
2407
+ if (!previewPayload) {
2408
+ return '<div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span><span>Select a file or directory.</span></div>';
2409
+ }
2410
+ const path = previewPayload.path || "/";
2411
+ const name = previewPayload.name || path || "/";
2412
+ const state = previewPayload.state || "unknown";
2413
+ if (state === "ok") {
2414
+ return `
2415
+ <div class="viewer-workspace__preview-header">
2416
+ <div><strong>${escapeHtml(name)}</strong><span>${escapeHtml(path)}</span></div>
2417
+ <em>${escapeHtml(previewPayload.truncated ? "truncated" : `${previewPayload.size || 0} bytes`)}</em>
2418
+ </div>
2419
+ ${previewPayload.truncated ? '<div class="viewer-workspace__placeholder viewer-workspace__placeholder--warn"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">!</span><span>Preview truncated.</span></div>' : ""}
2420
+ <pre class="viewer-workspace__code">${escapeHtml(previewPayload.content || "")}</pre>
2421
+ `;
2422
+ }
2423
+ if (state === "image") {
2424
+ return `
2425
+ <div class="viewer-workspace__preview-header">
2426
+ <div><strong>${escapeHtml(name)}</strong><span>${escapeHtml(path)}</span></div>
2427
+ <em>${escapeHtml(previewPayload.contentType || "image")}</em>
2428
+ </div>
2429
+ <img class="viewer-workspace__image" src="/api/workspace-file?path=${encodeURIComponent(path)}" alt="${escapeHtml(name)}">
2430
+ `;
2431
+ }
2432
+ const placeholderState = state === "unavailable" ? "unavailable" : "empty";
2433
+ const placeholderIcon = placeholderState === "unavailable" ? "!" : "·";
2434
+ return `
2435
+ <div class="viewer-workspace__preview-header">
2436
+ <div><strong>${escapeHtml(name)}</strong><span>${escapeHtml(path)}</span></div>
2437
+ <em>${escapeHtml(state)}</em>
2438
+ </div>
2439
+ <div class="viewer-workspace__placeholder viewer-workspace__placeholder--${placeholderState}"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">${placeholderIcon}</span><span>${escapeHtml(previewPayload.message || "No preview is available.")}</span></div>
2440
+ `;
2441
+ }
2442
+
2443
+ function renderWorkspace(treePayload, previewPayload) {
2444
+ const selectedPath = previewPayload?.path || "";
2445
+ return `
2446
+ <div class="viewer-workspace">
2447
+ <aside class="viewer-workspace__tree" aria-label="Workspace files">
2448
+ ${renderWorkspaceTree(treePayload, selectedPath)}
2449
+ </aside>
2450
+ <section class="viewer-workspace__preview" aria-label="Workspace preview">
2451
+ ${renderWorkspacePreview(previewPayload)}
2452
+ </section>
2453
+ </div>
2454
+ `;
2455
+ }
2456
+
2457
+ async function fetchWorkspaceTree(path = "") {
2458
+ const response = await fetch(`/api/workspace-tree?path=${encodeURIComponent(path)}`);
2459
+ const data = await response.json();
2460
+ if (!response.ok || !data.ok) {
2461
+ throw new Error(data.error || "Unable to load workspace tree.");
2462
+ }
2463
+ return data.payload;
2464
+ }
2465
+
2466
+ async function fetchWorkspacePreview(path = "") {
2467
+ const response = await fetch(`/api/workspace-preview?path=${encodeURIComponent(path)}`);
2468
+ const data = await response.json();
2469
+ if (!response.ok || !data.ok) {
2470
+ throw new Error(data.error || "Unable to load workspace preview.");
2471
+ }
2472
+ return data.payload;
2473
+ }
2474
+
2475
+ async function showWorkspace(options = {}) {
2476
+ if (!isCapabilityAvailable("workspace")) {
2477
+ const message = capabilityMessage("workspace", "Explorer is not available for this project.");
2478
+ setDocument("Explorer", renderWorkspace({ state: "unavailable", message }, { state: "unavailable", message }));
2479
+ setMeta(message);
2480
+ return;
2481
+ }
2482
+ if (!options.silent) {
2483
+ setMeta("Loading workspace...");
2484
+ }
2485
+ const tree = await fetchWorkspaceTree("");
2486
+ const preview = await fetchWorkspacePreview("");
2487
+ setDocument("Explorer", renderWorkspace(tree, preview));
2488
+ setMeta(options.silent ? "Explorer refreshed." : "Explorer loaded.");
2489
+ }
2490
+
2491
+ const workshopTabs = [
2492
+ { id: "terminals", label: "Terminals", title: "In-app PTY terminals" },
2493
+ { id: "commands", label: "Commands", title: "Discovered package and project scripts" },
2494
+ ];
2495
+
2496
+ function preferredWorkshopTab() {
2497
+ const stored = String(viewerPreferences.workshopActiveTab || "");
2498
+ return workshopTabs.some((tab) => tab.id === stored) ? stored : "terminals";
2499
+ }
2500
+
2501
+ function setWorkshopActiveTab(tabId) {
2502
+ const next = workshopTabs.some((tab) => tab.id === tabId) ? tabId : "terminals";
2503
+ if (next === viewerPreferences.workshopActiveTab) return;
2504
+ updateViewerPreferences({ workshopActiveTab: next });
2505
+ }
2506
+
2507
+ function renderWorkshopTabs(activeTab) {
2508
+ const buttons = workshopTabs.map((tab) => {
2509
+ const isActive = tab.id === activeTab;
2510
+ return `<button class="viewer-cdx__mode${isActive ? " is-active" : ""}" type="button" role="tab" aria-selected="${isActive ? "true" : "false"}" data-viewer-workshop-tab="${escapeHtml(tab.id)}" title="${escapeHtml(tab.title)}">${escapeHtml(tab.label)}</button>`;
2511
+ }).join("");
2512
+ return `<div class="viewer-cdx__modes" role="tablist" aria-label="Workshop sub-screens">${buttons}</div>`;
2513
+ }
2514
+
2515
+ function renderWorkshopPanel(tabId) {
2516
+ if (tabId === "commands") {
2517
+ return `
2518
+ <div class="viewer-workshop__panel" role="tabpanel" data-viewer-workshop-panel="commands" data-viewer-workshop-commands>
2519
+ <div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty">
2520
+ <span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span>
2521
+ <span>Discovering commands...</span>
2522
+ </div>
2523
+ </div>
2524
+ `;
2525
+ }
2526
+ const terminalsAvailable = Boolean(capability("workshop").detail?.terminalsAvailable);
2527
+ if (!terminalsAvailable) {
2528
+ return `
2529
+ <div class="viewer-workshop__panel viewer-workshop__panel--terminals" role="tabpanel" data-viewer-workshop-panel="terminals">
2530
+ <div class="viewer-workspace__placeholder viewer-workspace__placeholder--unavailable">
2531
+ <span class="viewer-workspace__placeholder-icon" aria-hidden="true">!</span>
2532
+ <span>In-app terminals require a Unix host with stdlib pty support (macOS or Linux). Use the Commands tab to run discovered scripts in the meantime.</span>
2533
+ </div>
2534
+ </div>
2535
+ `;
2536
+ }
2537
+ return `
2538
+ <div class="viewer-workshop__panel viewer-workshop__panel--terminals-active" role="tabpanel" data-viewer-workshop-panel="terminals">
2539
+ <aside class="viewer-workshop__terminal-list" data-viewer-workshop-terminal-list aria-label="Terminal sessions"></aside>
2540
+ <section class="viewer-workshop__terminal-stage" data-viewer-workshop-terminal-stage>
2541
+ <div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty" data-viewer-workshop-terminal-empty>
2542
+ <span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span>
2543
+ <span>No terminal session yet. Click "New terminal" to spawn one.</span>
2544
+ </div>
2545
+ </section>
2546
+ </div>
2547
+ `;
2548
+ }
2549
+
2550
+ const workshopCommandState = {
2551
+ catalog: null,
2552
+ sessions: new Map(),
2553
+ streams: new Map(),
2554
+ };
2555
+
2556
+ function renderWorkshopCommandRow(entry) {
2557
+ const session = workshopCommandState.sessions.get(entry.id) || null;
2558
+ const state = session?.state || "idle";
2559
+ const running = state === "running" || state === "starting";
2560
+ const exitBadge = session && session.exitCode !== null && session.exitCode !== undefined
2561
+ ? `<span class="viewer-workshop__exit viewer-workshop__exit--${session.exitCode === 0 ? "ok" : "fail"}">exit ${escapeHtml(String(session.exitCode))}</span>`
2562
+ : "";
2563
+ return `
2564
+ <li class="viewer-workshop__command" data-viewer-workshop-command="${escapeHtml(entry.id)}">
2565
+ <div class="viewer-workshop__command-header">
2566
+ <div class="viewer-workshop__command-name">
2567
+ <strong>${escapeHtml(entry.name)}</strong>
2568
+ <span class="viewer-workshop__command-source">${escapeHtml(entry.source)}</span>
2569
+ </div>
2570
+ <div class="viewer-workshop__command-actions">
2571
+ <span class="viewer-workshop__state viewer-workshop__state--${escapeHtml(state)}">${escapeHtml(state)}</span>
2572
+ ${exitBadge}
2573
+ ${running
2574
+ ? `<button class="btn" type="button" data-viewer-workshop-command-stop="${escapeHtml(entry.id)}">Stop</button>`
2575
+ : `<button class="btn" type="button" data-viewer-workshop-command-run="${escapeHtml(entry.id)}">Run</button>`}
2576
+ </div>
2577
+ </div>
2578
+ <div class="viewer-workshop__command-meta"><code>${escapeHtml(entry.command)}</code></div>
2579
+ <pre class="viewer-workshop__log" data-viewer-workshop-command-log="${escapeHtml(entry.id)}" aria-live="polite">${escapeHtml(session?.logText || "")}</pre>
2580
+ </li>
2581
+ `;
2582
+ }
2583
+
2584
+ function renderWorkshopCommandList(catalog) {
2585
+ if (!catalog || catalog.state === "unavailable") {
2586
+ return `<div class="viewer-workspace__placeholder viewer-workspace__placeholder--unavailable"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">!</span><span>${escapeHtml(catalog?.message || "Commands are unavailable.")}</span></div>`;
2587
+ }
2588
+ const commands = Array.isArray(catalog.commands) ? catalog.commands : [];
2589
+ if (commands.length === 0) {
2590
+ return `<div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span><span>${escapeHtml(catalog.message || "No commands discovered.")}</span></div>`;
2591
+ }
2592
+ const groups = new Map();
2593
+ commands.forEach((entry) => {
2594
+ const group = entry.group || "Commands";
2595
+ if (!groups.has(group)) groups.set(group, []);
2596
+ groups.get(group).push(entry);
2597
+ });
2598
+ const sections = [...groups.entries()].map(([group, entries]) => `
2599
+ <section class="viewer-workshop__group">
2600
+ <h3 class="viewer-workshop__group-title">${escapeHtml(group)}</h3>
2601
+ <ul class="viewer-workshop__commands">
2602
+ ${entries.map(renderWorkshopCommandRow).join("")}
2603
+ </ul>
2604
+ </section>
2605
+ `).join("");
2606
+ return sections;
2607
+ }
2608
+
2609
+ function renderWorkshopCommands() {
2610
+ const container = document.querySelector("[data-viewer-workshop-commands]");
2611
+ if (!(container instanceof HTMLElement)) return;
2612
+ container.innerHTML = renderWorkshopCommandList(workshopCommandState.catalog);
2613
+ }
2614
+
2615
+ async function loadWorkshopCommands() {
2616
+ try {
2617
+ const response = await fetch("/api/workshop-commands");
2618
+ const data = await response.json();
2619
+ workshopCommandState.catalog = data?.payload || null;
2620
+ } catch (error) {
2621
+ workshopCommandState.catalog = { state: "unavailable", commands: [], message: String(error?.message || error) };
2622
+ }
2623
+ renderWorkshopCommands();
2624
+ }
2625
+
2626
+ function updateWorkshopCommandSession(commandId, patch) {
2627
+ const previous = workshopCommandState.sessions.get(commandId) || { logText: "" };
2628
+ workshopCommandState.sessions.set(commandId, { ...previous, ...patch });
2629
+ renderWorkshopCommands();
2630
+ recomputeWorkshopBadges();
2631
+ }
2632
+
2633
+ function appendWorkshopCommandLog(commandId, line) {
2634
+ const previous = workshopCommandState.sessions.get(commandId) || { logText: "" };
2635
+ const next = previous.logText ? `${previous.logText}\n${line}` : line;
2636
+ workshopCommandState.sessions.set(commandId, { ...previous, logText: next });
2637
+ const node = document.querySelector(`[data-viewer-workshop-command-log="${commandId}"]`);
2638
+ if (node instanceof HTMLElement) {
2639
+ node.textContent = next;
2640
+ node.scrollTop = node.scrollHeight;
2641
+ }
2642
+ }
2643
+
2644
+ function closeWorkshopCommandStream(commandId) {
2645
+ const stream = workshopCommandState.streams.get(commandId);
2646
+ if (stream) {
2647
+ try { stream.close(); } catch { /* noop */ }
2648
+ workshopCommandState.streams.delete(commandId);
2649
+ }
2650
+ }
2651
+
2652
+ async function startWorkshopCommand(commandId) {
2653
+ try {
2654
+ const response = await fetch("/api/workshop-command-start", {
2655
+ method: "POST",
2656
+ headers: { "Content-Type": "application/json" },
2657
+ body: JSON.stringify({ commandId }),
2658
+ });
2659
+ const data = await response.json();
2660
+ if (!response.ok || !data.ok) {
2661
+ throw new Error(data.error || "Unable to start command.");
2662
+ }
2663
+ const session = data.payload;
2664
+ updateWorkshopCommandSession(commandId, {
2665
+ sessionId: session.id,
2666
+ state: session.state,
2667
+ exitCode: session.exitCode,
2668
+ logText: "",
2669
+ });
2670
+ openWorkshopCommandStream(commandId, session.id);
2671
+ } catch (error) {
2672
+ updateWorkshopCommandSession(commandId, { state: "error", logText: `! ${error?.message || error}` });
2673
+ }
2674
+ }
2675
+
2676
+ function openWorkshopCommandStream(commandId, sessionId) {
2677
+ closeWorkshopCommandStream(commandId);
2678
+ const source = new EventSource(`/api/workshop-session/${encodeURIComponent(sessionId)}/stream`);
2679
+ workshopCommandState.streams.set(commandId, source);
2680
+ source.addEventListener("line", (event) => {
2681
+ try {
2682
+ const payload = JSON.parse(event.data || "{}");
2683
+ appendWorkshopCommandLog(commandId, String(payload.line || ""));
2684
+ } catch { /* noop */ }
2685
+ });
2686
+ source.addEventListener("end", (event) => {
2687
+ try {
2688
+ const payload = JSON.parse(event.data || "{}");
2689
+ updateWorkshopCommandSession(commandId, {
2690
+ state: payload.state,
2691
+ exitCode: payload.exitCode,
2692
+ });
2693
+ } catch { /* noop */ }
2694
+ closeWorkshopCommandStream(commandId);
2695
+ });
2696
+ source.addEventListener("error", () => {
2697
+ closeWorkshopCommandStream(commandId);
2698
+ });
2699
+ }
2700
+
2701
+ async function stopWorkshopCommand(commandId) {
2702
+ const session = workshopCommandState.sessions.get(commandId);
2703
+ if (!session?.sessionId) return;
2704
+ try {
2705
+ await fetch("/api/workshop-command-stop", {
2706
+ method: "POST",
2707
+ headers: { "Content-Type": "application/json" },
2708
+ body: JSON.stringify({ sessionId: session.sessionId }),
2709
+ });
2710
+ } catch { /* noop */ }
2711
+ }
2712
+
2713
+ const workshopTerminalState = {
2714
+ sessions: new Map(),
2715
+ activeId: "",
2716
+ streams: new Map(),
2717
+ hydrated: false,
2718
+ };
2719
+
2720
+ async function hydrateWorkshopTerminals() {
2721
+ if (workshopTerminalState.hydrated) return;
2722
+ if (!isCapabilityAvailable("workshop")) return;
2723
+ if (!capability("workshop").detail?.terminalsAvailable) return;
2724
+ workshopTerminalState.hydrated = true;
2725
+ try {
2726
+ const response = await fetch("/api/workshop-terminals");
2727
+ const data = await response.json();
2728
+ const sessions = Array.isArray(data?.payload?.sessions) ? data.payload.sessions : [];
2729
+ for (const remote of sessions) {
2730
+ const id = String(remote?.id || "");
2731
+ if (!id) continue;
2732
+ if (workshopTerminalState.sessions.has(id)) continue;
2733
+ const state = String(remote?.state || "");
2734
+ // Only restore live sessions; the server reaps stopped/failed ones via TTL.
2735
+ if (state !== "running" && state !== "starting") continue;
2736
+ workshopTerminalState.sessions.set(id, {
2737
+ id,
2738
+ label: String(remote?.label || "shell"),
2739
+ state,
2740
+ bufferedOutput: "",
2741
+ });
2742
+ }
2743
+ if (!workshopTerminalState.activeId) {
2744
+ const next = workshopTerminalState.sessions.keys().next();
2745
+ workshopTerminalState.activeId = next.done ? "" : next.value;
2746
+ }
2747
+ recomputeWorkshopBadges();
2748
+ } catch {
2749
+ workshopTerminalState.hydrated = false;
2750
+ }
2751
+ }
2752
+
2753
+ function workshopTerminalListNode() {
2754
+ return document.querySelector("[data-viewer-workshop-terminal-list]");
2755
+ }
2756
+
2757
+ function workshopTerminalStageNode() {
2758
+ return document.querySelector("[data-viewer-workshop-terminal-stage]");
2759
+ }
2760
+
2761
+ function renderWorkshopTerminalList() {
2762
+ const node = workshopTerminalListNode();
2763
+ if (!(node instanceof HTMLElement)) return;
2764
+ const entries = [...workshopTerminalState.sessions.values()];
2765
+ const header = `<div class="viewer-workshop__terminal-list-header">
2766
+ <span>Terminals</span>
2767
+ <span class="viewer-workshop__terminal-actions">
2768
+ <button class="btn viewer-workshop__terminal-new" type="button" data-viewer-workshop-terminal-new title="Spawn a shell session">+ Shell</button>
2769
+ <button class="btn viewer-workshop__terminal-new" type="button" data-viewer-workshop-terminal-custom title="Spawn a session with a custom command">+ Custom</button>
2770
+ </span>
2771
+ </div>`;
2772
+ if (entries.length === 0) {
2773
+ node.innerHTML = `${header}<div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span><span>No sessions yet.</span></div>`;
2774
+ return;
2775
+ }
2776
+ const rows = entries.map((entry) => {
2777
+ const isActive = entry.id === workshopTerminalState.activeId;
2778
+ const stateBadge = entry.state ? `<span class="viewer-workshop__state viewer-workshop__state--${escapeHtml(entry.state)}">${escapeHtml(entry.state)}</span>` : "";
2779
+ const closing = Boolean(entry.closing);
2780
+ const closeAttrs = closing
2781
+ ? `aria-busy="true" aria-label="Closing session" title="Closing session..."`
2782
+ : `data-viewer-workshop-terminal-close="${escapeHtml(entry.id)}" role="button" tabindex="0" title="Close session" aria-label="Close session"`;
2783
+ const closeGlyph = closing
2784
+ ? `<span class="viewer-workshop__spinner" aria-hidden="true"></span>`
2785
+ : `×`;
2786
+ return `<button class="viewer-workshop__terminal-row${isActive ? " is-active" : ""}${closing ? " is-closing" : ""}" type="button" data-viewer-workshop-terminal-select="${escapeHtml(entry.id)}" title="${escapeHtml(entry.label || entry.id)}">
2787
+ <span class="viewer-workshop__terminal-row-label">${escapeHtml(entry.label || entry.id)}</span>
2788
+ ${stateBadge}
2789
+ <span class="viewer-workshop__terminal-row-close${closing ? " is-closing" : ""}" ${closeAttrs}>${closeGlyph}</span>
2790
+ </button>`;
2791
+ }).join("");
2792
+ node.innerHTML = `${header}<div class="viewer-workshop__terminal-rows">${rows}</div>`;
2793
+ }
2794
+
2795
+ function ensureWorkshopTerminalStage() {
2796
+ const stage = workshopTerminalStageNode();
2797
+ if (!(stage instanceof HTMLElement)) return null;
2798
+ const active = workshopTerminalState.activeId
2799
+ ? workshopTerminalState.sessions.get(workshopTerminalState.activeId)
2800
+ : null;
2801
+ // Clean up placeholder and host elements for sessions that no longer exist.
2802
+ const placeholder = stage.querySelector("[data-viewer-workshop-terminal-empty]");
2803
+ if (placeholder) placeholder.remove();
2804
+ stage.querySelectorAll("[data-viewer-workshop-terminal-host]").forEach((node) => {
2805
+ if (!(node instanceof HTMLElement)) return;
2806
+ const id = node.getAttribute("data-viewer-workshop-terminal-host") || "";
2807
+ if (!workshopTerminalState.sessions.has(id)) {
2808
+ node.remove();
2809
+ }
2810
+ });
2811
+ if (!active) {
2812
+ if (!stage.querySelector("[data-viewer-workshop-terminal-empty]")) {
2813
+ const empty = document.createElement("div");
2814
+ empty.className = "viewer-workspace__placeholder viewer-workspace__placeholder--empty";
2815
+ empty.setAttribute("data-viewer-workshop-terminal-empty", "");
2816
+ empty.innerHTML = '<span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span><span>Select or create a terminal session to start.</span>';
2817
+ stage.appendChild(empty);
2818
+ }
2819
+ // Hide every existing host while no session is active.
2820
+ stage.querySelectorAll("[data-viewer-workshop-terminal-host]").forEach((node) => {
2821
+ if (node instanceof HTMLElement) node.style.display = "none";
2822
+ });
2823
+ return null;
2824
+ }
2825
+ // Toggle visibility: only the active host shows, every other host stays
2826
+ // mounted in the DOM so its xterm.js instance and scrollback survive.
2827
+ let host = stage.querySelector(`[data-viewer-workshop-terminal-host="${active.id}"]`);
2828
+ stage.querySelectorAll("[data-viewer-workshop-terminal-host]").forEach((node) => {
2829
+ if (!(node instanceof HTMLElement)) return;
2830
+ const id = node.getAttribute("data-viewer-workshop-terminal-host") || "";
2831
+ node.style.display = id === active.id ? "" : "none";
2832
+ });
2833
+ if (!(host instanceof HTMLElement)) {
2834
+ host = document.createElement("div");
2835
+ host.className = "viewer-workshop__terminal-host";
2836
+ host.setAttribute("data-viewer-workshop-terminal-host", active.id);
2837
+ stage.appendChild(host);
2838
+ }
2839
+ return host instanceof HTMLElement ? host : null;
2840
+ }
2841
+
2842
+ function ensureWorkshopTerminalHostFor(sessionId) {
2843
+ const stage = workshopTerminalStageNode();
2844
+ if (!(stage instanceof HTMLElement)) return null;
2845
+ const placeholder = stage.querySelector("[data-viewer-workshop-terminal-empty]");
2846
+ if (placeholder) placeholder.remove();
2847
+ let host = stage.querySelector(`[data-viewer-workshop-terminal-host="${sessionId}"]`);
2848
+ if (!(host instanceof HTMLElement)) {
2849
+ host = document.createElement("div");
2850
+ host.className = "viewer-workshop__terminal-host";
2851
+ host.setAttribute("data-viewer-workshop-terminal-host", sessionId);
2852
+ stage.appendChild(host);
2853
+ }
2854
+ return host;
2855
+ }
2856
+
2857
+ function mountWorkshopTerminalEmulator(entry) {
2858
+ if (typeof window.Terminal !== "function") return;
2859
+ if (entry.terminal) return;
2860
+ const host = ensureWorkshopTerminalHostFor(entry.id);
2861
+ if (!host) return;
2862
+ const term = new window.Terminal({
2863
+ fontSize: 12,
2864
+ fontFamily: 'var(--vscode-editor-font-family, "Menlo", "Consolas", monospace)',
2865
+ theme: { background: "#0a0a0a", foreground: "#d4d4d4" },
2866
+ cursorBlink: true,
2867
+ scrollback: 5000,
2868
+ convertEol: true,
2869
+ });
2870
+ const fitAddon = typeof window.FitAddon === "function"
2871
+ ? new window.FitAddon()
2872
+ : (window.FitAddon && typeof window.FitAddon.FitAddon === "function" ? new window.FitAddon.FitAddon() : null);
2873
+ const linksAddon = window.WebLinksAddon && typeof window.WebLinksAddon.WebLinksAddon === "function"
2874
+ ? new window.WebLinksAddon.WebLinksAddon()
2875
+ : null;
2876
+ if (fitAddon) term.loadAddon(fitAddon);
2877
+ if (linksAddon) term.loadAddon(linksAddon);
2878
+ term.open(host);
2879
+ if (fitAddon) {
2880
+ try { fitAddon.fit(); } catch { /* noop */ }
2881
+ }
2882
+ term.onData((data) => {
2883
+ writeWorkshopTerminalInput(entry.id, data);
2884
+ });
2885
+ term.onResize((size) => {
2886
+ resizeWorkshopTerminal(entry.id, size.rows, size.cols);
2887
+ });
2888
+ entry.terminal = term;
2889
+ entry.fitAddon = fitAddon;
2890
+ if (fitAddon) {
2891
+ try {
2892
+ const dim = fitAddon.proposeDimensions();
2893
+ if (dim) resizeWorkshopTerminal(entry.id, dim.rows, dim.cols);
2894
+ } catch { /* noop */ }
2895
+ }
2896
+ openWorkshopTerminalStream(entry.id);
2897
+ if (entry.bufferedOutput) {
2898
+ term.write(entry.bufferedOutput);
2899
+ }
2900
+ }
2901
+
2902
+ function setActiveWorkshopTerminal(sessionId) {
2903
+ workshopTerminalState.activeId = sessionId || "";
2904
+ renderWorkshopTerminalList();
2905
+ const entry = sessionId ? workshopTerminalState.sessions.get(sessionId) : null;
2906
+ ensureWorkshopTerminalStage();
2907
+ if (entry) {
2908
+ mountWorkshopTerminalEmulator(entry);
2909
+ try { entry.terminal?.focus(); } catch { /* noop */ }
2910
+ try { entry.fitAddon?.fit(); } catch { /* noop */ }
2911
+ }
2912
+ }
2913
+
2914
+ async function spawnWorkshopTerminal(options = {}) {
2915
+ try {
2916
+ const body = {};
2917
+ if (Array.isArray(options.command) && options.command.length) body.command = options.command;
2918
+ if (options.label) body.label = String(options.label);
2919
+ const response = await fetch("/api/workshop-terminal-start", {
2920
+ method: "POST",
2921
+ headers: { "Content-Type": "application/json" },
2922
+ body: JSON.stringify(body),
2923
+ });
2924
+ const data = await response.json();
2925
+ if (!response.ok || !data.ok) throw new Error(data.error || "Unable to start terminal.");
2926
+ const session = data.payload;
2927
+ workshopTerminalState.sessions.set(session.id, {
2928
+ id: session.id,
2929
+ label: session.label || "shell",
2930
+ state: session.state,
2931
+ bufferedOutput: "",
2932
+ });
2933
+ recomputeWorkshopBadges();
2934
+ // Ensure the Workshop view is mounted before activating.
2935
+ if (preferredWorkshopTab() !== "terminals") {
2936
+ await showWorkshop({ tab: "terminals" });
2937
+ } else {
2938
+ await showWorkshop({ tab: "terminals" });
2939
+ }
2940
+ setActiveWorkshopTerminal(session.id);
2941
+ return session.id;
2942
+ } catch (error) {
2943
+ setMeta(`Terminal: ${error?.message || error}`);
2944
+ return "";
2945
+ }
2946
+ }
2947
+
2948
+ function spawnCustomWorkshopTerminal() {
2949
+ const raw = window.prompt("Command to run (space-separated, e.g. 'node --version'):", "");
2950
+ if (!raw) return;
2951
+ const command = raw.trim().split(/\s+/).filter(Boolean);
2952
+ if (!command.length) return;
2953
+ const label = command.slice(0, 2).join(" ").slice(0, 32) || "custom";
2954
+ spawnWorkshopTerminal({ command, label });
2955
+ }
2956
+
2957
+ // Public API for CDX / handoff launchers and other callers that want to
2958
+ // open a Workshop terminal pre-running a canonical command.
2959
+ window.logicsViewer = window.logicsViewer || {};
2960
+ window.logicsViewer.launchTerminal = (command, label) => spawnWorkshopTerminal({ command, label });
2961
+
2962
+ function writeWorkshopTerminalInput(sessionId, data) {
2963
+ if (!sessionId || !data) return;
2964
+ fetch("/api/workshop-terminal-input", {
2965
+ method: "POST",
2966
+ headers: { "Content-Type": "application/json" },
2967
+ body: JSON.stringify({ sessionId, data }),
2968
+ }).catch(() => { /* noop */ });
2969
+ }
2970
+
2971
+ function resizeWorkshopTerminal(sessionId, rows, cols) {
2972
+ if (!sessionId || rows <= 0 || cols <= 0) return;
2973
+ fetch("/api/workshop-terminal-resize", {
2974
+ method: "POST",
2975
+ headers: { "Content-Type": "application/json" },
2976
+ body: JSON.stringify({ sessionId, rows, cols }),
2977
+ }).catch(() => { /* noop */ });
2978
+ }
2979
+
2980
+ async function stopWorkshopTerminal(sessionId) {
2981
+ if (!sessionId) return;
2982
+ const pending = workshopTerminalState.sessions.get(sessionId);
2983
+ if (pending?.closing) return;
2984
+ if (pending) {
2985
+ pending.closing = true;
2986
+ renderWorkshopTerminalList();
2987
+ }
2988
+ try {
2989
+ await fetch("/api/workshop-terminal-stop", {
2990
+ method: "POST",
2991
+ headers: { "Content-Type": "application/json" },
2992
+ body: JSON.stringify({ sessionId }),
2993
+ });
2994
+ } catch { /* noop */ }
2995
+ closeWorkshopTerminalStream(sessionId);
2996
+ const entry = workshopTerminalState.sessions.get(sessionId);
2997
+ if (entry?.terminal) {
2998
+ try { entry.terminal.dispose(); } catch { /* noop */ }
2999
+ }
3000
+ workshopTerminalState.sessions.delete(sessionId);
3001
+ if (workshopTerminalState.activeId === sessionId) {
3002
+ const next = workshopTerminalState.sessions.keys().next();
3003
+ setActiveWorkshopTerminal(next.done ? "" : next.value);
3004
+ } else {
3005
+ renderWorkshopTerminalList();
3006
+ }
3007
+ recomputeWorkshopBadges();
3008
+ }
3009
+
3010
+ function closeWorkshopTerminalStream(sessionId) {
3011
+ const stream = workshopTerminalState.streams.get(sessionId);
3012
+ if (stream) {
3013
+ try { stream.close(); } catch { /* noop */ }
3014
+ workshopTerminalState.streams.delete(sessionId);
3015
+ }
3016
+ }
3017
+
3018
+ function openWorkshopTerminalStream(sessionId) {
3019
+ closeWorkshopTerminalStream(sessionId);
3020
+ const source = new EventSource(`/api/workshop-terminal/${encodeURIComponent(sessionId)}/stream`);
3021
+ workshopTerminalState.streams.set(sessionId, source);
3022
+ source.addEventListener("data", (event) => {
3023
+ try {
3024
+ const payload = JSON.parse(event.data || "{}");
3025
+ const chunk = String(payload.data || "");
3026
+ const entry = workshopTerminalState.sessions.get(sessionId);
3027
+ if (!entry) return;
3028
+ if (entry.terminal) {
3029
+ entry.terminal.write(chunk);
3030
+ } else {
3031
+ entry.bufferedOutput = (entry.bufferedOutput || "") + chunk;
3032
+ }
3033
+ } catch { /* noop */ }
3034
+ });
3035
+ source.addEventListener("end", (event) => {
3036
+ try {
3037
+ const payload = JSON.parse(event.data || "{}");
3038
+ const entry = workshopTerminalState.sessions.get(sessionId);
3039
+ if (entry) entry.state = payload.state;
3040
+ renderWorkshopTerminalList();
3041
+ recomputeWorkshopBadges();
3042
+ } catch { /* noop */ }
3043
+ closeWorkshopTerminalStream(sessionId);
3044
+ });
3045
+ source.addEventListener("error", () => {
3046
+ closeWorkshopTerminalStream(sessionId);
3047
+ });
3048
+ }
3049
+
3050
+ function renderWorkshop(activeTab, options = {}) {
3051
+ if (options.unavailable) {
3052
+ return `
3053
+ <div class="viewer-workshop">
3054
+ <div class="viewer-workspace__placeholder viewer-workspace__placeholder--unavailable">
3055
+ <span class="viewer-workspace__placeholder-icon" aria-hidden="true">!</span>
3056
+ <span>${escapeHtml(options.message || "Workshop is not available for this project.")}</span>
3057
+ </div>
3058
+ </div>
3059
+ `;
3060
+ }
3061
+ return `
3062
+ <div class="viewer-workshop">
3063
+ <div class="viewer-workshop__tabs" role="tablist" aria-label="Workshop sub-screens">
3064
+ ${renderWorkshopTabs(activeTab)}
3065
+ </div>
3066
+ ${renderWorkshopPanel(activeTab)}
3067
+ </div>
3068
+ `;
3069
+ }
3070
+
3071
+ async function showWorkshop(options = {}) {
3072
+ if (!isCapabilityAvailable("workshop")) {
3073
+ const message = capabilityMessage("workshop", "Workshop is not available for this project.");
3074
+ setDocument("Workshop", renderWorkshop("terminals", { unavailable: true, message }));
3075
+ setMeta(message);
3076
+ return;
3077
+ }
3078
+ const activeTab = options.tab && workshopTabs.some((tab) => tab.id === options.tab)
3079
+ ? options.tab
3080
+ : preferredWorkshopTab();
3081
+ setWorkshopActiveTab(activeTab);
3082
+ setDocument("Workshop", renderWorkshop(activeTab));
3083
+ setMeta(`Workshop / ${activeTab}`);
3084
+ if (activeTab === "commands") {
3085
+ await loadWorkshopCommands();
3086
+ } else if (activeTab === "terminals") {
3087
+ // The Workshop DOM was just re-rendered, so every prior xterm host /
3088
+ // EventSource is gone. Drop them from the in-memory state too so the
3089
+ // remount path recreates fresh ones and the SSE stream replays the
3090
+ // session buffer.
3091
+ for (const entry of workshopTerminalState.sessions.values()) {
3092
+ if (entry.terminal) {
3093
+ try { entry.terminal.dispose(); } catch { /* noop */ }
3094
+ }
3095
+ entry.terminal = null;
3096
+ entry.fitAddon = null;
3097
+ closeWorkshopTerminalStream(entry.id);
3098
+ }
3099
+ renderWorkshopTerminalList();
3100
+ // Remount every session so switching between rows is instant and
3101
+ // none of the terminals show a black/empty stage.
3102
+ for (const entry of workshopTerminalState.sessions.values()) {
3103
+ mountWorkshopTerminalEmulator(entry);
3104
+ if (entry.id !== workshopTerminalState.activeId) {
3105
+ const host = workshopTerminalStageNode()?.querySelector(`[data-viewer-workshop-terminal-host="${entry.id}"]`);
3106
+ if (host instanceof HTMLElement) host.style.display = "none";
3107
+ }
3108
+ }
3109
+ if (workshopTerminalState.activeId) {
3110
+ setActiveWorkshopTerminal(workshopTerminalState.activeId);
3111
+ }
3112
+ }
3113
+ }
3114
+
3115
+ async function openWorkspaceTree(path) {
3116
+ const [tree, preview] = await Promise.all([fetchWorkspaceTree(path), fetchWorkspacePreview(path)]);
3117
+ setDocument("Explorer", renderWorkspace(tree, preview));
3118
+ setMeta(path ? `Explorer folder ${path}` : "Explorer root.");
3119
+ }
3120
+
3121
+ async function openWorkspacePreview(path) {
3122
+ const treePath = workspaceParentPath(path);
3123
+ const [tree, preview] = await Promise.all([fetchWorkspaceTree(treePath), fetchWorkspacePreview(path)]);
3124
+ setDocument("Explorer", renderWorkspace(tree, preview));
3125
+ setMeta(`Previewing ${path || "workspace root"}.`);
3126
+ }
3127
+
2048
3128
  function objectEntries(value) {
2049
3129
  return value && typeof value === "object" && !Array.isArray(value) ? Object.entries(value) : [];
2050
3130
  }
@@ -2398,31 +3478,108 @@
2398
3478
  return explicit === true ? "YES" : "-";
2399
3479
  }
2400
3480
 
3481
+ function cdxProviderName(item) {
3482
+ return String(cdxField(item, ["provider", "name"], "unknown") || "unknown");
3483
+ }
3484
+
3485
+ function cdxKnownProviders(status, providers, sessions) {
3486
+ const names = new Set();
3487
+ providers.forEach((provider) => {
3488
+ const name = cdxProviderName(provider);
3489
+ if (name) {
3490
+ names.add(name);
3491
+ }
3492
+ });
3493
+ sessions.forEach((session) => {
3494
+ const name = cdxProviderName(session);
3495
+ if (name) {
3496
+ names.add(name);
3497
+ }
3498
+ });
3499
+ pickFirstArray(status, ["providers", "providerStatus", "provider_status"]).forEach((provider) => {
3500
+ const name = cdxProviderName(provider);
3501
+ if (name) {
3502
+ names.add(name);
3503
+ }
3504
+ });
3505
+ return Array.from(names).sort((left, right) => left.localeCompare(right));
3506
+ }
3507
+
3508
+ function filterCdxEntriesByProvider(entries, providerFilter) {
3509
+ if (providerFilter.mode !== "subset" || !providerFilter.selected.length) {
3510
+ return entries;
3511
+ }
3512
+ const selected = new Set(providerFilter.selected);
3513
+ return entries.filter((entry) => selected.has(cdxProviderName(entry)));
3514
+ }
3515
+
3516
+ function renderCdxStatusControls(knownProviders, visibleColumns, providerFilter) {
3517
+ const columnRows = cdxStatusColumns.map((column) => `
3518
+ <label class="viewer-cdx__menu-check">
3519
+ <input type="checkbox" data-viewer-cdx-column="${escapeHtml(column.id)}"${visibleColumns[column.id] ? " checked" : ""}>
3520
+ <span>${escapeHtml(column.label)}</span>
3521
+ </label>
3522
+ `).join("");
3523
+ const selected = new Set(providerFilter.mode === "subset" ? providerFilter.selected : knownProviders);
3524
+ const providerRows = knownProviders.map((provider) => `
3525
+ <label class="viewer-cdx__menu-check">
3526
+ <input type="checkbox" data-viewer-cdx-provider="${escapeHtml(provider)}"${selected.has(provider) ? " checked" : ""}>
3527
+ <span>${escapeHtml(provider)}</span>
3528
+ </label>
3529
+ `).join("");
3530
+ const providerSummary = providerFilter.mode === "subset" && providerFilter.selected.length
3531
+ ? `${providerFilter.selected.length}/${knownProviders.length || providerFilter.selected.length}`
3532
+ : "All";
3533
+ return `
3534
+ <div class="viewer-cdx__controls" aria-label="CDX status table controls">
3535
+ <details class="viewer-cdx__menu">
3536
+ <summary class="viewer-cdx__icon-button" title="Configure status columns" aria-label="Configure status columns">
3537
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2 3.4-.2-.1a1.7 1.7 0 0 0-2 .1 1.7 1.7 0 0 0-.8 1.7v.2H9.2v-.2a1.7 1.7 0 0 0-.8-1.7 1.7 1.7 0 0 0-2-.1l-.2.1-2-3.4.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1.1H3v-3.8h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.9l-.1-.1 2-3.4.2.1a1.7 1.7 0 0 0 2-.1 1.7 1.7 0 0 0 .8-1.7v-.2h5.6v.2a1.7 1.7 0 0 0 .8 1.7 1.7 1.7 0 0 0 2 .1l.2-.1 2 3.4-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.5 1.1h.1v3.8h-.1a1.7 1.7 0 0 0-1.5 1.1Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
3538
+ </summary>
3539
+ <div class="viewer-cdx__menu-panel" role="menu" aria-label="CDX status columns">${columnRows}</div>
3540
+ </details>
3541
+ <details class="viewer-cdx__menu">
3542
+ <summary class="viewer-cdx__icon-button" title="Filter status providers" aria-label="Filter status providers">
3543
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 6h16l-6.5 7.2V19l-3 1.5v-7.3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>
3544
+ <span class="viewer-cdx__icon-count">${escapeHtml(providerSummary)}</span>
3545
+ </summary>
3546
+ <div class="viewer-cdx__menu-panel" role="menu" aria-label="CDX provider filter">
3547
+ <button class="viewer-cdx__menu-action" type="button" data-viewer-cdx-provider-all>All providers</button>
3548
+ ${providerRows || '<div class="viewer-cdx__empty">No providers reported.</div>'}
3549
+ </div>
3550
+ </details>
3551
+ </div>
3552
+ `;
3553
+ }
3554
+
2401
3555
  function renderCdxSessionTable(sessions, emptyText) {
2402
3556
  if (!sessions.length) {
2403
3557
  return `<div class="viewer-cdx__empty">${escapeHtml(emptyText)}</div>`;
2404
3558
  }
3559
+ const visibleColumns = cdxColumnVisibilityPreference();
3560
+ const cellRenderers = {
3561
+ session: (item) => {
3562
+ const name = cdxField(item, ["session_name", "name", "id", "value"]);
3563
+ return `<td class="viewer-cdx__session-name">${escapeHtml(`${name}${item.active ? "*" : ""}`)}</td>`;
3564
+ },
3565
+ provider: (item) => `<td>${escapeHtml(cdxField(item, ["provider"], "-"))}</td>`,
3566
+ status: (item) => `<td>${renderCdxBadge(cdxField(item, ["status", "state"]))}</td>`,
3567
+ auth: (item) => `<td>${escapeHtml(String(cdxField(item, ["auth_status", "authStatus"], "-")).replace("authenticated", "logged"))}</td>`,
3568
+ ok: (item) => `<td>${renderCdxRemainingPill(item) || escapeHtml(cdxPct(cdxField(item, ["available_pct", "availablePct"], NaN)))}</td>`,
3569
+ remaining5h: (item) => `<td>${escapeHtml(cdxPct(cdxField(item, ["remaining_5h_pct", "remaining5hPct"], NaN)))}</td>`,
3570
+ remainingWeek: (item) => `<td>${escapeHtml(cdxPct(cdxField(item, ["remaining_week_pct", "remainingWeekPct"], NaN)))}</td>`,
3571
+ block: (item) => `<td>${escapeHtml(cdxSessionBlock(item))}</td>`,
3572
+ credits: (item) => `<td>${escapeHtml(formatCdxCredits(cdxField(item, ["credits", "cr"], "-")))}</td>`,
3573
+ reset5h: (item) => `<td>${escapeHtml(formatCdxResetAt(cdxField(item, ["reset_5h_at", "reset5hAt", "reset_at", "resetAt"], "")))}</td>`,
3574
+ resetWeek: (item) => `<td>${escapeHtml(formatCdxResetAt(cdxField(item, ["reset_week_at", "resetWeekAt", "reset_at", "resetAt"], "")))}</td>`,
3575
+ updated: (item) => `<td>${escapeHtml(formatCdxResetAt(cdxField(item, ["updated_at", "updatedAt"], "")))}</td>`
3576
+ };
3577
+ const activeColumns = cdxStatusColumns.filter((column) => visibleColumns[column.id]);
2405
3578
  const rows = sessions.slice(0, 24).map((entry) => {
2406
3579
  const item = entry && typeof entry === "object" ? entry : { value: entry };
2407
- const name = cdxField(item, ["session_name", "name", "id", "value"]);
2408
- const sessionName = `${name}${item.active ? "*" : ""}`;
2409
- const status = cdxField(item, ["status", "state"]);
2410
- const auth = String(cdxField(item, ["auth_status", "authStatus"], "-")).replace("authenticated", "logged");
2411
- const block = cdxSessionBlock(item);
2412
3580
  return `
2413
3581
  <tr>
2414
- <td class="viewer-cdx__session-name">${escapeHtml(sessionName)}</td>
2415
- <td>${escapeHtml(cdxField(item, ["provider"], "-"))}</td>
2416
- <td>${renderCdxBadge(status)}</td>
2417
- <td>${escapeHtml(auth)}</td>
2418
- <td>${renderCdxRemainingPill(item) || escapeHtml(cdxPct(cdxField(item, ["available_pct", "availablePct"], NaN)))}</td>
2419
- <td>${escapeHtml(cdxPct(cdxField(item, ["remaining_5h_pct", "remaining5hPct"], NaN)))}</td>
2420
- <td>${escapeHtml(cdxPct(cdxField(item, ["remaining_week_pct", "remainingWeekPct"], NaN)))}</td>
2421
- <td>${escapeHtml(block)}</td>
2422
- <td>${escapeHtml(formatCdxCredits(cdxField(item, ["credits", "cr"], "-")))}</td>
2423
- <td>${escapeHtml(formatCdxResetAt(cdxField(item, ["reset_5h_at", "reset5hAt", "reset_at", "resetAt"], "")))}</td>
2424
- <td>${escapeHtml(formatCdxResetAt(cdxField(item, ["reset_week_at", "resetWeekAt", "reset_at", "resetAt"], "")))}</td>
2425
- <td>${escapeHtml(formatCdxResetAt(cdxField(item, ["updated_at", "updatedAt"], "")))}</td>
3582
+ ${activeColumns.map((column) => cellRenderers[column.id](item)).join("")}
2426
3583
  </tr>
2427
3584
  `;
2428
3585
  }).join("");
@@ -2431,18 +3588,7 @@
2431
3588
  <table class="viewer-cdx__table">
2432
3589
  <thead>
2433
3590
  <tr>
2434
- <th>SESSION</th>
2435
- <th>PROV.</th>
2436
- <th>STATUS</th>
2437
- <th>AUTH</th>
2438
- <th>OK</th>
2439
- <th>5H</th>
2440
- <th>WEEK</th>
2441
- <th>BLOCK</th>
2442
- <th>CR</th>
2443
- <th>RESET 5H</th>
2444
- <th>RESET WEEK</th>
2445
- <th>UPDATED</th>
3591
+ ${activeColumns.map((column) => `<th>${escapeHtml(column.label)}</th>`).join("")}
2446
3592
  </tr>
2447
3593
  </thead>
2448
3594
  <tbody>${rows}</tbody>
@@ -2718,8 +3864,12 @@
2718
3864
  `;
2719
3865
  }
2720
3866
  const status = payload.status || {};
2721
- const providers = cdxProviders(status);
2722
- const sessions = cdxSessions(status);
3867
+ const allProviders = cdxProviders(status);
3868
+ const allSessions = cdxSessions(status);
3869
+ const providerFilter = cdxProviderFilterPreference();
3870
+ const knownProviders = cdxKnownProviders(status, allProviders, allSessions);
3871
+ const providers = filterCdxEntriesByProvider(allProviders, providerFilter);
3872
+ const sessions = filterCdxEntriesByProvider(allSessions, providerFilter);
2723
3873
  const readiness = cdxReadiness(status);
2724
3874
  const commands = pickFirstArray(status, ["nextCommands", "next_commands", "safeCommands", "safe_commands", "commands"])
2725
3875
  .map((entry) => typeof entry === "string" ? entry : (entry.command || entry.value || entry.name || ""))
@@ -2750,6 +3900,7 @@
2750
3900
  <div class="viewer-cdx">
2751
3901
  ${renderCdxModeSwitcher("status")}
2752
3902
  <div class="viewer-cdx__summary">${cards}</div>
3903
+ ${renderCdxStatusControls(knownProviders, cdxColumnVisibilityPreference(), providerFilter)}
2753
3904
  <div class="viewer-cdx__workspace">
2754
3905
  <div class="viewer-cdx__stack">
2755
3906
  <section class="viewer-cdx__section">
@@ -2776,6 +3927,12 @@
2776
3927
  `;
2777
3928
  }
2778
3929
 
3930
+ function rerenderCdxStatusFromPreferences() {
3931
+ if (isCdxStatusOpen() && latestCdxStatusPayload) {
3932
+ setDocument("CDX status", renderCdxStatus(latestCdxStatusPayload));
3933
+ }
3934
+ }
3935
+
2779
3936
  function renderCdxRuns(payload) {
2780
3937
  if (!payload || payload.state !== "ok") {
2781
3938
  return `
@@ -3106,6 +4263,7 @@
3106
4263
  return;
3107
4264
  }
3108
4265
  latestCdxStatusSignature = nextCdxSignature;
4266
+ latestCdxStatusPayload = data.payload;
3109
4267
  updateMainCdxBadge(data.payload);
3110
4268
  setDocument("CDX status", renderCdxStatus(data.payload));
3111
4269
  setMeta(options.silent ? "CDX status refreshed." : "CDX status loaded.");
@@ -3820,6 +4978,12 @@
3820
4978
  withPrimaryAction("insights", "Loading insights", showCorpusInsights);
3821
4979
  });
3822
4980
  });
4981
+ [workspaceButton()].forEach((button) => {
4982
+ button?.addEventListener("click", () => {
4983
+ setRefreshMenuOpen(false);
4984
+ withPrimaryAction("workspace", "Loading Explorer", showWorkspace);
4985
+ });
4986
+ });
3823
4987
  const autoControl = autoRefreshControl();
3824
4988
  if (autoControl instanceof HTMLInputElement) {
3825
4989
  autoControl.addEventListener("change", () => {
@@ -3869,6 +5033,19 @@
3869
5033
  setRefreshMenuOpen(false);
3870
5034
  withPrimaryAction("health", "Checking health", showHealth);
3871
5035
  });
5036
+ document.getElementById("viewer-lan-banner-copy")?.addEventListener("click", async () => {
5037
+ const share = latestLanShareUrl;
5038
+ if (!share) return;
5039
+ const ok = await copyTextToClipboard(share);
5040
+ if (ok) {
5041
+ setMeta("LAN share URL copied to the clipboard.");
5042
+ } else {
5043
+ setMeta(`Copy failed — long-press to select: ${share}`);
5044
+ }
5045
+ });
5046
+ document.getElementById("viewer-workshop")?.addEventListener("click", () => {
5047
+ withPrimaryAction("workshop", "Opening Workshop", () => showWorkshop());
5048
+ });
3872
5049
  document.getElementById("viewer-git")?.addEventListener("click", () => {
3873
5050
  withPrimaryAction("git", "Checking Git status", showGitStatus);
3874
5051
  });
@@ -3914,6 +5091,8 @@
3914
5091
  document.addEventListener("change", (event) => {
3915
5092
  const sessionTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-session]") : null;
3916
5093
  const cdxInputTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-input]") : null;
5094
+ const cdxColumnTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-column]") : null;
5095
+ const cdxProviderTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-provider]") : null;
3917
5096
  if (sessionTarget instanceof HTMLSelectElement) {
3918
5097
  latestCdxMissionState.sessionId = sessionTarget.value || "";
3919
5098
  latestCdxMissionState.planPayload = null;
@@ -3930,6 +5109,25 @@
3930
5109
  latestCdxMissionState.applyPayload = null;
3931
5110
  }
3932
5111
  }
5112
+ if (cdxColumnTarget instanceof HTMLInputElement) {
5113
+ persistCdxColumnVisibility(cdxColumnTarget.getAttribute("data-viewer-cdx-column") || "", cdxColumnTarget.checked);
5114
+ rerenderCdxStatusFromPreferences();
5115
+ }
5116
+ if (cdxProviderTarget instanceof HTMLInputElement) {
5117
+ const provider = cdxProviderTarget.getAttribute("data-viewer-cdx-provider") || "";
5118
+ const status = latestCdxStatusPayload?.status || {};
5119
+ const allProviders = cdxKnownProviders(status, cdxProviders(status), cdxSessions(status));
5120
+ const current = cdxProviderFilterPreference();
5121
+ const selected = new Set(current.mode === "subset" ? current.selected : allProviders);
5122
+ if (cdxProviderTarget.checked) {
5123
+ selected.add(provider);
5124
+ } else {
5125
+ selected.delete(provider);
5126
+ }
5127
+ const nextSelected = Array.from(selected).filter((entry) => allProviders.includes(entry));
5128
+ persistCdxProviderFilter(nextSelected.length === allProviders.length ? { mode: "all", selected: [] } : { mode: "subset", selected: nextSelected });
5129
+ rerenderCdxStatusFromPreferences();
5130
+ }
3933
5131
  });
3934
5132
  document.addEventListener("click", (event) => {
3935
5133
  window.setTimeout(() => applyLocalViewerChrome(), 0);
@@ -3940,12 +5138,22 @@
3940
5138
  const gitHistoryRevealTarget = event.target instanceof Element ? event.target.closest("[data-viewer-git-history-reveal]") : null;
3941
5139
  const gitDomainTarget = event.target instanceof Element ? event.target.closest(".viewer-git__domain[data-viewer-git-domain]") : null;
3942
5140
  const gitFileTarget = event.target instanceof Element ? event.target.closest("[data-viewer-git-file]") : null;
5141
+ const workspaceTreeTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workspace-tree]") : null;
5142
+ const workspacePreviewTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workspace-preview]") : null;
5143
+ const workshopTabTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-tab]") : null;
5144
+ const workshopRunTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-command-run]") : null;
5145
+ const workshopStopTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-command-stop]") : null;
5146
+ const workshopTerminalNewTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-terminal-new]") : null;
5147
+ const workshopTerminalCustomTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-terminal-custom]") : null;
5148
+ const workshopTerminalSelectTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-terminal-select]") : null;
5149
+ const workshopTerminalCloseTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-terminal-close]") : null;
3943
5150
  const projectSwitcherTarget = event.target instanceof Element ? event.target.closest("#viewer-repo-pill") : null;
3944
5151
  const projectTarget = event.target instanceof Element ? event.target.closest("[data-viewer-project-id]") : null;
3945
5152
  const cdxModeTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-mode]") : null;
3946
5153
  const cdxBackRunsTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-back-runs]") : null;
3947
5154
  const cdxReportTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-report]") : null;
3948
5155
  const cdxArtifactTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-artifact-path]") : null;
5156
+ const cdxProviderAllTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-provider-all]") : null;
3949
5157
  const cdxCreateRequestTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-create-request]") : null;
3950
5158
  const cdxMissionTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-mission]") : null;
3951
5159
  const cdxStrengthTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-strength]") : null;
@@ -3981,6 +5189,11 @@
3981
5189
  withCdxMissionAction("cdx-apply-plan", "Applying CDX mission plan", applyCdxMissionPlan);
3982
5190
  return;
3983
5191
  }
5192
+ if (cdxProviderAllTarget instanceof HTMLElement) {
5193
+ persistCdxProviderFilter({ mode: "all", selected: [] });
5194
+ rerenderCdxStatusFromPreferences();
5195
+ return;
5196
+ }
3984
5197
  if (cdxBackRunsTarget instanceof HTMLElement) {
3985
5198
  withPrimaryAction("cdx-runs", "Loading CDX runs", showCdxRuns);
3986
5199
  return;
@@ -4008,6 +5221,62 @@
4008
5221
  }
4009
5222
  return;
4010
5223
  }
5224
+ if (workshopTabTarget instanceof HTMLElement) {
5225
+ event.preventDefault();
5226
+ const tab = workshopTabTarget.getAttribute("data-viewer-workshop-tab") || "terminals";
5227
+ withPrimaryAction("workshop-tab", `Switching to ${tab}`, () => showWorkshop({ tab }));
5228
+ return;
5229
+ }
5230
+ if (workshopTerminalCloseTarget instanceof HTMLElement) {
5231
+ event.preventDefault();
5232
+ event.stopPropagation();
5233
+ const id = workshopTerminalCloseTarget.getAttribute("data-viewer-workshop-terminal-close") || "";
5234
+ if (id) stopWorkshopTerminal(id);
5235
+ return;
5236
+ }
5237
+ if (workshopTerminalNewTarget instanceof HTMLElement) {
5238
+ event.preventDefault();
5239
+ spawnWorkshopTerminal();
5240
+ return;
5241
+ }
5242
+ if (workshopTerminalCustomTarget instanceof HTMLElement) {
5243
+ event.preventDefault();
5244
+ spawnCustomWorkshopTerminal();
5245
+ return;
5246
+ }
5247
+ if (workshopTerminalSelectTarget instanceof HTMLElement) {
5248
+ event.preventDefault();
5249
+ const id = workshopTerminalSelectTarget.getAttribute("data-viewer-workshop-terminal-select") || "";
5250
+ if (id) setActiveWorkshopTerminal(id);
5251
+ return;
5252
+ }
5253
+ if (workshopRunTarget instanceof HTMLElement) {
5254
+ event.preventDefault();
5255
+ const commandId = workshopRunTarget.getAttribute("data-viewer-workshop-command-run") || "";
5256
+ if (commandId) {
5257
+ updateWorkshopCommandSession(commandId, { state: "starting", logText: "" });
5258
+ startWorkshopCommand(commandId);
5259
+ }
5260
+ return;
5261
+ }
5262
+ if (workshopStopTarget instanceof HTMLElement) {
5263
+ event.preventDefault();
5264
+ const commandId = workshopStopTarget.getAttribute("data-viewer-workshop-command-stop") || "";
5265
+ if (commandId) {
5266
+ stopWorkshopCommand(commandId);
5267
+ }
5268
+ return;
5269
+ }
5270
+ if (workspaceTreeTarget instanceof HTMLElement) {
5271
+ event.preventDefault();
5272
+ withPrimaryAction("workspace-tree", "Loading Explorer folder", () => openWorkspaceTree(workspaceTreeTarget.getAttribute("data-viewer-workspace-tree") || ""));
5273
+ return;
5274
+ }
5275
+ if (workspacePreviewTarget instanceof HTMLElement) {
5276
+ event.preventDefault();
5277
+ withPrimaryAction("workspace-preview", "Loading Explorer preview", () => openWorkspacePreview(workspacePreviewTarget.getAttribute("data-viewer-workspace-preview") || ""));
5278
+ return;
5279
+ }
4011
5280
  if (projectSwitcherTarget instanceof HTMLElement) {
4012
5281
  const menu = projectMenu();
4013
5282
  setProjectMenuOpen(Boolean(menu?.hidden));