@homescriptone/opencode-viewer-extender 1.2.0 → 1.5.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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ Reads and writes `~/.local/share/opencode/opencode.db`. Everything stays on your
11
11
  bunx @homescriptone/opencode-viewer-extender
12
12
  ```
13
13
 
14
- > On npm: [`@homescriptone/opencode-viewer-extender`](https://www.npmjs.com/package/@homescriptone/opencode-viewer-extender) · Desktop builds (`.dmg`, Linux, Windows) are published on the [releases page](https://github.com/homescriptone/opencode-viewer-extended/releases).
14
+ > On npm: [`@homescriptone/opencode-viewer-extender`](https://www.npmjs.com/package/@homescriptone/opencode-viewer-extender).
15
15
 
16
16
  ## Features
17
17
 
@@ -25,7 +25,7 @@ bunx @homescriptone/opencode-viewer-extender
25
25
 
26
26
  ## Requirements
27
27
 
28
- - [Bun](https://bun.sh) runtime (`curl -fsSL https://bun.sh/install | bash`), or a desktop build
28
+ - [Bun](https://bun.sh) runtime (`curl -fsSL https://bun.sh/install | bash`)
29
29
  - [OpenCode](https://opencode.ai) installed and used at least once
30
30
 
31
31
  ## Usage
@@ -58,7 +58,14 @@ The database is opened **read-only** by default. Writes only become available wh
58
58
  ## Export
59
59
 
60
60
  - **Single session** — the **Export ▾** menu in the message header offers Markdown (`.md`) and JSON (`.json`), served by `GET /api/sessions/:id/export?format=md|json`.
61
- - **Bulk** — turn on **Select**, pick multiple projects/sessions, and choose **Export selected (.zip)**. The archive contains a `project.json` per project, each session as `sessions/<id>.json`, and a `manifest.json`.
61
+ - **Bulk** — turn on **Select**, use **All projects** / **All sessions** (or pick individual items), and choose **Export selected (.zip)**. A progress indicator appears for larger exports. The archive is grouped into folders, and every item is written in three formats (`.json`, `.xml`, `.html`):
62
+
63
+ ```
64
+ manifest.json
65
+ projects/<project>/project.{json,xml,html}
66
+ projects/<project>/sessions/<session>/session.{json,xml,html}
67
+ sessions/<session>/session.{json,xml,html} # directly-selected sessions
68
+ ```
62
69
 
63
70
  ## Plugins
64
71
 
@@ -150,11 +157,15 @@ bun run typecheck
150
157
 
151
158
  ## Releasing
152
159
 
153
- Publishing to npm is automated: pushing to `main`/`master` runs [`.github/workflows/publish.yml`](.github/workflows/publish.yml), which type-checks and publishes the current `package.json` version (skipping if that version already exists). Add an `NPM_TOKEN` repository secret (an npm automation token) for it to publish. Bumping the version is all you need to ship a release.
160
+ Publishing uses npm **trusted publishing (OIDC)** — no tokens or secrets. Push a version tag:
161
+
162
+ ```bash
163
+ git tag v1.3.0 && git push origin v1.3.0
164
+ ```
154
165
 
155
- ## Desktop app
166
+ [`.github/workflows/publish.yml`](.github/workflows/publish.yml) then type-checks and runs `npm publish` with provenance.
156
167
 
157
- A Tauri wrapper lives in [`src-tauri/`](src-tauri/) and bundles the Bun server as a sidecar; installers are produced by CI and attached to releases.
168
+ One-time setup on [npmjs.com](https://www.npmjs.com): open the package → **Settings** → **Trusted Publisher** → **GitHub Actions**, and set the repository (`homescriptone/opencode-viewer-extended`) and workflow filename (`publish.yml`). No `NPM_TOKEN` secret is required.
158
169
 
159
170
  ## Credits
160
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homescriptone/opencode-viewer-extender",
3
- "version": "1.2.0",
3
+ "version": "1.5.0",
4
4
  "description": "Local GUI to browse, edit, and export your OpenCode sessions, messages, and tool calls from the SQLite database, with a plugin API",
5
5
  "bin": {
6
6
  "opencode-viewer": "bin/cli.js"
package/public/index.html CHANGED
@@ -16,6 +16,7 @@
16
16
  <main id="messages-panel" class="panel"></main>
17
17
  </div>
18
18
  <div id="bulk-bar" class="bulk-bar" hidden></div>
19
+ <div id="status-toast" class="status-toast" hidden></div>
19
20
  <div id="modal-root"></div>
20
21
  <script src="plugin-api.js"></script>
21
22
  <script src="script.js"></script>
@@ -1,10 +1,21 @@
1
1
  // Bulk select plugin: select multiple projects and sessions, then export them
2
- // as a single .zip of JSON files or delete them (project deletes cascade).
2
+ // as a single .zip or delete them (project deletes cascade).
3
+ //
4
+ // The archive is grouped into folders and each item is written in three
5
+ // formats: JSON (.json), XML (.xml) and a standalone HTML page (.html).
6
+ //
7
+ // manifest.json
8
+ // projects/<project>/
9
+ // project.json project.xml project.html
10
+ // sessions/<session>/
11
+ // session.json session.xml session.html
12
+ // sessions/<session>/ (directly-selected sessions)
13
+ // session.json session.xml session.html
3
14
 
4
15
  OCV.define({
5
16
  id: "bulk-select",
6
17
  name: "Bulk select · export & delete",
7
- description: "Select multiple projects and sessions to export as a .zip or delete.",
18
+ description: "Select multiple projects and sessions to export as a .zip (json/xml/html) or delete.",
8
19
 
9
20
  setup(api) {
10
21
  api.registerBulkAction({
@@ -15,42 +26,73 @@ OCV.define({
15
26
  const seenSessions = new Set();
16
27
  const projectsById = new Map(host.getProjects().map((project) => [project.id, project]));
17
28
 
18
- const addSession = async (sessionId, folder) => {
19
- if (seenSessions.has(sessionId)) return;
20
- seenSessions.add(sessionId);
21
- const detail = await host.api.getSessionDetail(sessionId);
22
- entries.push({ name: `${folder}/${sessionId}.json`, data: JSON.stringify(detail, null, 2) });
23
- };
29
+ try {
30
+ host.setStatus("Preparing export…");
24
31
 
25
- for (const projectId of projectIds) {
26
- const project = projectsById.get(projectId);
27
- const folderName = sanitize(`${project?.name || projectId}-${projectId}`);
32
+ const tasks = [];
33
+ for (const projectId of projectIds) {
34
+ const project = projectsById.get(projectId) || { id: projectId };
35
+ const folderName = sanitize(`${project.name || projectId}-${projectId}`);
36
+ const base = `projects/${folderName}`;
37
+
38
+ entries.push({ name: `${base}/project.json`, data: JSON.stringify(project, null, 2) });
39
+ entries.push({ name: `${base}/project.xml`, data: toXmlDocument("project", project) });
40
+ entries.push({ name: `${base}/project.html`, data: toProjectHtml(project) });
41
+
42
+ const sessions = await host.api.getProjectSessions(projectId);
43
+ for (const session of sessions) {
44
+ if (seenSessions.has(session.id)) continue;
45
+ seenSessions.add(session.id);
46
+ tasks.push({ sessionId: session.id, folder: `${base}/sessions/${sanitize(session.id)}` });
47
+ }
48
+ }
49
+
50
+ for (const sessionId of sessionIds) {
51
+ if (seenSessions.has(sessionId)) continue;
52
+ seenSessions.add(sessionId);
53
+ tasks.push({ sessionId, folder: `sessions/${sanitize(sessionId)}` });
54
+ }
55
+
56
+ const total = tasks.length;
57
+ let done = 0;
58
+ if (total === 0) {
59
+ host.setStatus("Building archive…");
60
+ } else {
61
+ host.setProgress(0, total, "Collecting sessions…");
62
+ }
63
+
64
+ for (const task of tasks) {
65
+ const detail = await host.api.getSessionDetail(task.sessionId);
66
+ const title = detail?.session?.title || task.sessionId;
67
+ entries.push({ name: `${task.folder}/session.json`, data: JSON.stringify(detail, null, 2) });
68
+ entries.push({ name: `${task.folder}/session.xml`, data: toXmlDocument("export", detail) });
69
+ entries.push({ name: `${task.folder}/session.html`, data: toHtmlDocument(title, detail) });
70
+ done += 1;
71
+ host.setProgress(done, total, "Collecting sessions…");
72
+ }
73
+
74
+ host.setStatus("Building archive…");
28
75
  entries.push({
29
- name: `projects/${folderName}/project.json`,
30
- data: JSON.stringify(project ?? { id: projectId }, null, 2)
76
+ name: "manifest.json",
77
+ data: JSON.stringify(
78
+ {
79
+ exportedAt: new Date().toISOString(),
80
+ formats: ["json", "xml", "html"],
81
+ projectIds,
82
+ sessionIds,
83
+ files: entries.length + 1
84
+ },
85
+ null,
86
+ 2
87
+ )
31
88
  });
32
- const sessions = await host.api.getProjectSessions(projectId);
33
- for (const session of sessions) {
34
- await addSession(session.id, `projects/${folderName}/sessions`);
35
- }
36
- }
37
89
 
38
- for (const sessionId of sessionIds) {
39
- await addSession(sessionId, "sessions");
90
+ const blob = host.createZip(entries);
91
+ host.download(`ocv-export-${Date.now()}.zip`, blob, "application/zip");
92
+ host.notify(`Exported ${entries.length} files (json/xml/html).`);
93
+ } finally {
94
+ host.clearStatus();
40
95
  }
41
-
42
- entries.push({
43
- name: "manifest.json",
44
- data: JSON.stringify(
45
- { exportedAt: new Date().toISOString(), projectIds, sessionIds, files: entries.length + 1 },
46
- null,
47
- 2
48
- )
49
- });
50
-
51
- const blob = host.createZip(entries);
52
- host.download(`ocv-export-${Date.now()}.zip`, blob, "application/zip");
53
- host.notify(`Exported ${entries.length} files.`);
54
96
  }
55
97
  });
56
98
 
@@ -68,14 +110,22 @@ OCV.define({
68
110
  const ok = window.confirm(`Delete ${parts.join(" and ")}? This cannot be undone.`);
69
111
  if (!ok) return;
70
112
 
113
+ const total = projectIds.length + sessionIds.length;
71
114
  let deleted = 0;
72
- for (const projectId of projectIds) {
73
- await host.api.deleteProject(projectId);
74
- deleted += 1;
75
- }
76
- for (const sessionId of sessionIds) {
77
- await host.api.deleteSession(sessionId);
78
- deleted += 1;
115
+ try {
116
+ host.setProgress(0, total, "Deleting…");
117
+ for (const projectId of projectIds) {
118
+ await host.api.deleteProject(projectId);
119
+ deleted += 1;
120
+ host.setProgress(deleted, total, "Deleting…");
121
+ }
122
+ for (const sessionId of sessionIds) {
123
+ await host.api.deleteSession(sessionId);
124
+ deleted += 1;
125
+ host.setProgress(deleted, total, "Deleting…");
126
+ }
127
+ } finally {
128
+ host.clearStatus();
79
129
  }
80
130
 
81
131
  host.clearSelection();
@@ -87,9 +137,152 @@ OCV.define({
87
137
  });
88
138
 
89
139
  function sanitize(value) {
90
- return String(value ?? "item")
91
- .toLowerCase()
92
- .replace(/[^a-z0-9]+/g, "-")
93
- .replace(/^-+|-+$/g, "")
94
- .slice(0, 60) || "item";
140
+ return (
141
+ String(value ?? "item")
142
+ .toLowerCase()
143
+ .replace(/[^a-z0-9]+/g, "-")
144
+ .replace(/^-+|-+$/g, "")
145
+ .slice(0, 60) || "item"
146
+ );
147
+ }
148
+
149
+ function xmlEscape(value) {
150
+ return String(value)
151
+ .replace(/&/g, "&amp;")
152
+ .replace(/</g, "&lt;")
153
+ .replace(/>/g, "&gt;")
154
+ .replace(/"/g, "&quot;");
155
+ }
156
+
157
+ function xmlTag(name) {
158
+ const clean = String(name).replace(/[^a-zA-Z0-9_.-]/g, "_");
159
+ return /^[a-zA-Z_]/.test(clean) ? clean : `_${clean}`;
160
+ }
161
+
162
+ function jsonToXml(name, value, indent) {
163
+ const pad = `${indent} `;
164
+ if (value === null || value === undefined) return `${indent}<${name}/>`;
165
+ if (Array.isArray(value)) {
166
+ if (!value.length) return `${indent}<${name}/>`;
167
+ const inner = value.map((item) => jsonToXml("item", item, pad)).join("\n");
168
+ return `${indent}<${name}>\n${inner}\n${indent}</${name}>`;
169
+ }
170
+ if (typeof value === "object") {
171
+ const keys = Object.keys(value);
172
+ if (!keys.length) return `${indent}<${name}/>`;
173
+ const inner = keys.map((key) => jsonToXml(xmlTag(key), value[key], pad)).join("\n");
174
+ return `${indent}<${name}>\n${inner}\n${indent}</${name}>`;
175
+ }
176
+ return `${indent}<${name}>${xmlEscape(value)}</${name}>`;
177
+ }
178
+
179
+ function toXmlDocument(rootName, value) {
180
+ return `<?xml version="1.0" encoding="UTF-8"?>\n${jsonToXml(rootName, value, "")}\n`;
181
+ }
182
+
183
+ function htmlEscape(value) {
184
+ return String(value ?? "")
185
+ .replace(/&/g, "&amp;")
186
+ .replace(/</g, "&lt;")
187
+ .replace(/>/g, "&gt;")
188
+ .replace(/"/g, "&quot;");
189
+ }
190
+
191
+ function stringify(value) {
192
+ if (value === null || value === undefined) return "";
193
+ if (typeof value === "string") return value;
194
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
195
+ try {
196
+ return JSON.stringify(value, null, 2);
197
+ } catch {
198
+ return String(value);
199
+ }
200
+ }
201
+
202
+ const PAGE_CSS = `
203
+ :root { color-scheme: light; }
204
+ body { font-family: system-ui, -apple-system, sans-serif; margin: 0 auto; max-width: 900px; padding: 32px 20px; color: #1c1b18; background: #fbfaf7; line-height: 1.55; }
205
+ h1 { font-size: 1.6rem; margin: 0 0 4px; }
206
+ h2 { font-size: 1.1rem; margin: 28px 0 8px; padding-top: 12px; border-top: 1px solid #e8e6df; }
207
+ h3 { font-size: 0.95rem; margin: 16px 0 4px; }
208
+ .meta { color: #6f6d66; font-size: 0.85rem; margin: 0 0 8px; }
209
+ .part { margin: 10px 0; }
210
+ pre { background: #f5f4f0; border: 1px solid #e8e6df; border-radius: 8px; padding: 12px; overflow-x: auto; font-size: 0.85rem; white-space: pre-wrap; word-break: break-word; }
211
+ details { border: 1px solid #e8e6df; border-radius: 8px; padding: 8px 12px; }
212
+ summary { cursor: pointer; font-weight: 600; }
213
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
214
+ .role-user h2 { color: oklch(0.38 0.08 185); }
215
+ `;
216
+
217
+ function toProjectHtml(project) {
218
+ const title = project?.name || project?.id || "Project";
219
+ return `<!doctype html>
220
+ <html lang="en"><head><meta charset="utf-8"/>
221
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
222
+ <title>${htmlEscape(title)}</title><style>${PAGE_CSS}</style></head>
223
+ <body>
224
+ <h1>${htmlEscape(title)}</h1>
225
+ <p class="meta">${htmlEscape(project?.worktree || "")}</p>
226
+ <pre>${htmlEscape(JSON.stringify(project, null, 2))}</pre>
227
+ </body></html>
228
+ `;
229
+ }
230
+
231
+ function renderPartHtml(part) {
232
+ const type = String(part?.type ?? "text");
233
+ if (type === "text" || type === "reasoning") {
234
+ const label = type === "reasoning" ? `<h3>Reasoning</h3>` : "";
235
+ return `${label}<div class="part"><pre>${htmlEscape(stringify(part.text).trim())}</pre></div>`;
236
+ }
237
+ if (type === "tool") {
238
+ const output = part.state && part.state.output !== undefined ? stringify(part.state.output) : "";
239
+ return `<div class="part"><details><summary>Tool: ${htmlEscape(String(part.tool ?? "unknown"))}</summary>
240
+ <pre>${htmlEscape(stringify(part.state && part.state.input) || "{}")}</pre>
241
+ ${output ? `<pre>${htmlEscape(output)}</pre>` : ""}
242
+ </details></div>`;
243
+ }
244
+ if (type === "patch") {
245
+ return `<div class="part"><h3>Patch <code>${htmlEscape(String(part.hash ?? "").slice(0, 7))}</code></h3></div>`;
246
+ }
247
+ if (type === "file") {
248
+ return `<div class="part"><h3>File: ${htmlEscape(String(part.filename ?? "attachment"))}</h3></div>`;
249
+ }
250
+ return "";
251
+ }
252
+
253
+ function toHtmlDocument(title, detail) {
254
+ const session = detail?.session ?? {};
255
+ const messages = Array.isArray(detail?.messages) ? detail.messages : [];
256
+ const updated = Number(session.time_updated);
257
+ const meta = [
258
+ `ID: ${session.id ?? ""}`,
259
+ session.directory ? `Directory: ${session.directory}` : "",
260
+ Number.isFinite(updated) && updated > 0 ? `Updated: ${new Date(updated).toISOString()}` : ""
261
+ ]
262
+ .filter(Boolean)
263
+ .map(htmlEscape)
264
+ .join(" · ");
265
+
266
+ const body = messages
267
+ .map((message) => {
268
+ if (message._type === "subagent") {
269
+ return `<h2>Subagent: ${htmlEscape(message.title || message.id)}</h2>`;
270
+ }
271
+ const role = String(message.role ?? "unknown");
272
+ const parts = (message.parts ?? []).map(renderPartHtml).join("\n");
273
+ const model = message.modelID ? `<div class="meta">model: ${htmlEscape(message.modelID)}</div>` : "";
274
+ return `<div class="role-${htmlEscape(role)}"><h2>${htmlEscape(role)}</h2>${model}${parts}</div>`;
275
+ })
276
+ .join("\n");
277
+
278
+ return `<!doctype html>
279
+ <html lang="en"><head><meta charset="utf-8"/>
280
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
281
+ <title>${htmlEscape(title)}</title><style>${PAGE_CSS}</style></head>
282
+ <body>
283
+ <h1>${htmlEscape(title)}</h1>
284
+ <p class="meta">${meta}</p>
285
+ ${body}
286
+ </body></html>
287
+ `;
95
288
  }
package/public/script.js CHANGED
@@ -28,7 +28,8 @@
28
28
  selectedSessionIds: new Set(),
29
29
  pluginsOpen: false,
30
30
  writeEnabled: false,
31
- editMode: false
31
+ editMode: false,
32
+ status: null
32
33
  };
33
34
 
34
35
  const el = {
@@ -36,6 +37,7 @@
36
37
  sessions: document.getElementById("sessions-panel"),
37
38
  messages: document.getElementById("messages-panel"),
38
39
  bulkBar: document.getElementById("bulk-bar"),
40
+ statusToast: document.getElementById("status-toast"),
39
41
  modal: document.getElementById("modal-root")
40
42
  };
41
43
 
@@ -745,15 +747,49 @@
745
747
  renderBulkBar();
746
748
  }
747
749
 
750
+ function renderStatus() {
751
+ if (!el.statusToast) return;
752
+ const status = state.status;
753
+ if (!status || (!status.label && status.current === undefined)) {
754
+ el.statusToast.hidden = true;
755
+ el.statusToast.innerHTML = "";
756
+ return;
757
+ }
758
+
759
+ const { label = "", current, total } = status;
760
+ let progressHtml = "";
761
+ if (typeof current === "number" && typeof total === "number" && total > 0) {
762
+ const pct = Math.max(0, Math.min(100, Math.round((current / total) * 100)));
763
+ progressHtml = `
764
+ <div class="status-progress"><div class="status-progress-fill" style="width:${pct}%"></div></div>
765
+ <div class="status-count">${current} / ${total}</div>
766
+ `;
767
+ }
768
+
769
+ el.statusToast.hidden = false;
770
+ el.statusToast.innerHTML = `
771
+ <span class="status-spinner" aria-hidden="true"></span>
772
+ <div class="status-body">
773
+ <div class="status-label">${escapeHtml(label || "Working…")}</div>
774
+ ${progressHtml}
775
+ </div>
776
+ `;
777
+ }
778
+
748
779
  function renderBulkBar() {
749
780
  if (!el.bulkBar) return;
750
- const count = state.selectedProjectIds.size + state.selectedSessionIds.size;
751
- if (!state.selectMode || count === 0) {
781
+ if (!state.selectMode) {
752
782
  el.bulkBar.hidden = true;
753
783
  el.bulkBar.innerHTML = "";
754
784
  return;
755
785
  }
756
786
 
787
+ const count = state.selectedProjectIds.size + state.selectedSessionIds.size;
788
+ const allProjectsSelected =
789
+ state.projects.length > 0 && state.projects.every((project) => state.selectedProjectIds.has(project.id));
790
+ const allSessionsSelected =
791
+ state.sessions.length > 0 && state.sessions.every((session) => state.selectedSessionIds.has(session.id));
792
+
757
793
  const buttons = (window.OCV?.listBulkActions?.() ?? [])
758
794
  .filter((action) => !action.write || (state.writeEnabled && state.editMode))
759
795
  .map(
@@ -765,8 +801,10 @@
765
801
  el.bulkBar.hidden = false;
766
802
  el.bulkBar.innerHTML = `
767
803
  <span class="bulk-count">${count} selected</span>
768
- ${buttons}
769
- <button class="bulk-btn ghost" data-action="bulk-clear">Clear</button>
804
+ <button class="bulk-btn ${allProjectsSelected ? "active" : ""}" data-action="bulk-select-all-projects"${state.projects.length === 0 ? " disabled" : ""}>All projects</button>
805
+ <button class="bulk-btn ${allSessionsSelected ? "active" : ""}" data-action="bulk-select-all-sessions"${state.sessions.length === 0 ? " disabled" : ""}>All sessions</button>
806
+ <button class="bulk-btn ghost" data-action="bulk-clear"${count === 0 ? " disabled" : ""}>Clear</button>
807
+ ${count > 0 ? buttons : ""}
770
808
  `;
771
809
  }
772
810
 
@@ -1114,7 +1152,12 @@
1114
1152
  )
1115
1153
  .join("");
1116
1154
 
1117
- return `<div class="header-actions">${pluginActions}${exportMenu}</div>`;
1155
+ const editActions =
1156
+ state.writeEnabled && state.editMode
1157
+ ? `<button class="header-btn danger" data-action="delete-session">Delete</button>`
1158
+ : "";
1159
+
1160
+ return `<div class="header-actions">${editActions}${pluginActions}${exportMenu}</div>`;
1118
1161
  }
1119
1162
 
1120
1163
  function renderMessagesPanel() {
@@ -1545,10 +1588,31 @@
1545
1588
  return;
1546
1589
  }
1547
1590
 
1591
+ if (action === "delete-session") {
1592
+ const sessionId = state.selectedSessionId;
1593
+ if (!sessionId) return;
1594
+ const title = state.sessionData?.title || getSelectedSession()?.title || sessionId;
1595
+ const ok = window.confirm(`Delete session "${title}"? This cannot be undone.`);
1596
+ if (!ok) return;
1597
+ try {
1598
+ window.OCV?.host?.setStatus?.("Deleting session…");
1599
+ await window.OCV.host.api.deleteSession(sessionId);
1600
+ clearSelectedSession();
1601
+ await loadSessions({ reset: true });
1602
+ } catch (error) {
1603
+ console.error("Delete session failed:", error);
1604
+ alert(`Delete failed: ${error instanceof Error ? error.message : String(error)}`);
1605
+ } finally {
1606
+ window.OCV?.host?.clearStatus?.();
1607
+ }
1608
+ return;
1609
+ }
1610
+
1548
1611
  if (action === "toggle-edit-mode") {
1549
1612
  state.editMode = !state.editMode;
1550
1613
  renderProjectsPanel();
1551
1614
  renderSessionsPanel();
1615
+ renderMessagesPanel();
1552
1616
  renderBulkBar();
1553
1617
  return;
1554
1618
  }
@@ -1585,6 +1649,32 @@
1585
1649
  return;
1586
1650
  }
1587
1651
 
1652
+ if (action === "bulk-select-all-projects") {
1653
+ const allSelected =
1654
+ state.projects.length > 0 && state.projects.every((project) => state.selectedProjectIds.has(project.id));
1655
+ if (allSelected) {
1656
+ for (const project of state.projects) state.selectedProjectIds.delete(project.id);
1657
+ } else {
1658
+ for (const project of state.projects) state.selectedProjectIds.add(project.id);
1659
+ }
1660
+ renderProjectsPanel();
1661
+ renderBulkBar();
1662
+ return;
1663
+ }
1664
+
1665
+ if (action === "bulk-select-all-sessions") {
1666
+ const allSelected =
1667
+ state.sessions.length > 0 && state.sessions.every((session) => state.selectedSessionIds.has(session.id));
1668
+ if (allSelected) {
1669
+ for (const session of state.sessions) state.selectedSessionIds.delete(session.id);
1670
+ } else {
1671
+ for (const session of state.sessions) state.selectedSessionIds.add(session.id);
1672
+ }
1673
+ renderSessionsPanel();
1674
+ renderBulkBar();
1675
+ return;
1676
+ }
1677
+
1588
1678
  if (action === "bulk-clear") {
1589
1679
  state.selectedProjectIds.clear();
1590
1680
  state.selectedSessionIds.clear();
@@ -1768,6 +1858,7 @@
1768
1858
  renderMessagesPanel();
1769
1859
  renderBulkBar();
1770
1860
  renderPluginsDialog();
1861
+ renderStatus();
1771
1862
  }
1772
1863
 
1773
1864
  if (window.OCV?.host) {
@@ -1788,6 +1879,18 @@
1788
1879
  download: (filename, content, mime) => downloadBlob(content, filename, mime),
1789
1880
  createZip: (entries) => window.OCV.helpers.createZip(entries),
1790
1881
  notify: (message) => console.log("[OCV]", message),
1882
+ setStatus: (label) => {
1883
+ state.status = { label };
1884
+ renderStatus();
1885
+ },
1886
+ setProgress: (current, total, label) => {
1887
+ state.status = { label, current, total };
1888
+ renderStatus();
1889
+ },
1890
+ clearStatus: () => {
1891
+ state.status = null;
1892
+ renderStatus();
1893
+ },
1791
1894
  refresh: () => {
1792
1895
  loadProjects();
1793
1896
  if (state.selectedProjectId) loadSessions({ reset: true });
package/public/styles.css CHANGED
@@ -995,6 +995,16 @@
995
995
  color: var(--accent-ink);
996
996
  }
997
997
 
998
+ .header-btn.danger {
999
+ color: #b23b3b;
1000
+ border-color: #e4bcbc;
1001
+ }
1002
+
1003
+ .header-btn.danger:hover {
1004
+ background: #fbeaea;
1005
+ border-color: #d99a9a;
1006
+ }
1007
+
998
1008
  .project-row.selectable,
999
1009
  .session-card.selectable {
1000
1010
  display: flex;
@@ -1083,6 +1093,22 @@
1083
1093
  background: var(--accent-wash);
1084
1094
  }
1085
1095
 
1096
+ .bulk-btn.active {
1097
+ border-color: var(--accent-hair);
1098
+ background: var(--accent-wash);
1099
+ color: var(--accent-ink);
1100
+ }
1101
+
1102
+ .bulk-btn:disabled {
1103
+ opacity: 0.5;
1104
+ cursor: default;
1105
+ }
1106
+
1107
+ .bulk-btn:disabled:hover {
1108
+ background: none;
1109
+ border-color: var(--hair);
1110
+ }
1111
+
1086
1112
  .bulk-btn.danger {
1087
1113
  color: #b23b3b;
1088
1114
  border-color: #e4bcbc;
@@ -1097,6 +1123,69 @@
1097
1123
  color: var(--muted);
1098
1124
  }
1099
1125
 
1126
+ .status-toast {
1127
+ position: fixed;
1128
+ right: 20px;
1129
+ bottom: 20px;
1130
+ z-index: 450;
1131
+ display: flex;
1132
+ align-items: center;
1133
+ gap: 10px;
1134
+ min-width: 220px;
1135
+ max-width: 340px;
1136
+ padding: 12px 14px;
1137
+ background: var(--surface);
1138
+ border: 1px solid var(--hair-2);
1139
+ border-radius: 12px;
1140
+ box-shadow: 0 10px 30px rgba(20, 20, 20, 0.16);
1141
+ }
1142
+
1143
+ .status-spinner {
1144
+ flex: 0 0 auto;
1145
+ width: 16px;
1146
+ height: 16px;
1147
+ border: 2px solid var(--hair-2);
1148
+ border-top-color: var(--accent);
1149
+ border-radius: 50%;
1150
+ animation: status-spin 0.7s linear infinite;
1151
+ }
1152
+
1153
+ @keyframes status-spin {
1154
+ to { transform: rotate(360deg); }
1155
+ }
1156
+
1157
+ .status-body {
1158
+ flex: 1;
1159
+ min-width: 0;
1160
+ }
1161
+
1162
+ .status-label {
1163
+ font-size: 12.5px;
1164
+ color: var(--ink);
1165
+ }
1166
+
1167
+ .status-progress {
1168
+ margin-top: 8px;
1169
+ height: 6px;
1170
+ border-radius: 999px;
1171
+ background: var(--hair);
1172
+ overflow: hidden;
1173
+ }
1174
+
1175
+ .status-progress-fill {
1176
+ height: 100%;
1177
+ background: var(--accent);
1178
+ border-radius: 999px;
1179
+ transition: width 0.2s ease;
1180
+ }
1181
+
1182
+ .status-count {
1183
+ margin-top: 4px;
1184
+ font-size: 11px;
1185
+ color: var(--muted);
1186
+ font-variant-numeric: tabular-nums;
1187
+ }
1188
+
1100
1189
  .modal-overlay {
1101
1190
  position: fixed;
1102
1191
  inset: 0;