@homescriptone/opencode-viewer-extender 0.0.0-stage → 1.1.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.
@@ -0,0 +1,1877 @@
1
+ const state = {
2
+ projects: [],
3
+ projectsLoading: false,
4
+ projectsError: null,
5
+
6
+ selectedProjectId: null,
7
+ sessions: [],
8
+ sessionsLoading: false,
9
+ sessionsLoadingMore: false,
10
+ sessionsError: null,
11
+ sessionsPage: 1,
12
+ sessionsLimit: 50,
13
+ sessionsHasMore: false,
14
+ sessionsTotal: null,
15
+ sessionsQuery: "",
16
+ sessionsScope: "all",
17
+
18
+ selectedSessionId: null,
19
+ sessionLoading: false,
20
+ sessionError: null,
21
+ sessionData: null,
22
+ messages: [],
23
+ messageOutlineCollapsed: false,
24
+ sessionStack: [],
25
+ exportMenuOpen: false,
26
+ selectMode: false,
27
+ selectedProjectIds: new Set(),
28
+ selectedSessionIds: new Set(),
29
+ pluginsOpen: false,
30
+ writeEnabled: false,
31
+ editMode: false
32
+ };
33
+
34
+ const el = {
35
+ projects: document.getElementById("projects-panel"),
36
+ sessions: document.getElementById("sessions-panel"),
37
+ messages: document.getElementById("messages-panel"),
38
+ bulkBar: document.getElementById("bulk-bar"),
39
+ modal: document.getElementById("modal-root")
40
+ };
41
+
42
+ let projectsRequestId = 0;
43
+ let sessionsRequestId = 0;
44
+ let sessionRequestId = 0;
45
+ let toolResultCounter = 0;
46
+ let sessionsSearchDebounceTimer = null;
47
+
48
+ function escapeHtml(str) {
49
+ return String(str ?? "")
50
+ .replaceAll("&", "&")
51
+ .replaceAll("<", "&lt;")
52
+ .replaceAll(">", "&gt;")
53
+ .replaceAll('"', "&quot;")
54
+ .replaceAll("'", "&#39;");
55
+ }
56
+
57
+ function truncate(str, len) {
58
+ const value = String(str ?? "");
59
+ if (value.length <= len) return value;
60
+ return value.slice(0, Math.max(0, len - 1)) + "…";
61
+ }
62
+
63
+ function timeAgo(timestamp) {
64
+ const ms = parseTimestamp(timestamp);
65
+ if (!ms) return "unknown";
66
+
67
+ const diff = Date.now() - ms;
68
+ if (diff < 0) return "just now";
69
+
70
+ const sec = Math.floor(diff / 1000);
71
+ if (sec < 45) return "just now";
72
+ if (sec < 3600) return Math.floor(sec / 60) + "m ago";
73
+ if (sec < 86400) return Math.floor(sec / 3600) + "h ago";
74
+ if (sec < 604800) return Math.floor(sec / 86400) + "d ago";
75
+ if (sec < 2592000) return Math.floor(sec / 604800) + "w ago";
76
+ if (sec < 31536000) return Math.floor(sec / 2592000) + "mo ago";
77
+ return Math.floor(sec / 31536000) + "y ago";
78
+ }
79
+
80
+ function formatDate(timestamp) {
81
+ const ms = parseTimestamp(timestamp);
82
+ if (!ms) return "Unknown";
83
+ return new Intl.DateTimeFormat("en-US", {
84
+ month: "short",
85
+ day: "numeric",
86
+ year: "numeric",
87
+ hour: "numeric",
88
+ minute: "2-digit"
89
+ }).format(new Date(ms));
90
+ }
91
+
92
+ function shortDate(timestamp) {
93
+ const ms = parseTimestamp(timestamp);
94
+ if (!ms) return "";
95
+ const d = new Date(ms);
96
+ const now = new Date();
97
+ const month = d.toLocaleString("en-US", { month: "short" });
98
+ const day = d.getDate();
99
+ if (d.getFullYear() !== now.getFullYear()) {
100
+ return `${month} ${day}, ${d.getFullYear()}`;
101
+ }
102
+ return `${month} ${day}`;
103
+ }
104
+
105
+ function renderMarkdownLite(text) {
106
+ const raw = String(text ?? "");
107
+ const placeholders = [];
108
+
109
+ function ph(html) {
110
+ const token = `@@PH_${placeholders.length}@@`;
111
+ placeholders.push(html);
112
+ return token;
113
+ }
114
+
115
+ let html = escapeHtml(raw);
116
+
117
+ // 1. Fenced code blocks → placeholders
118
+ html = html.replace(/```([\s\S]*?)```/g, (_, code) => {
119
+ const trimmed = String(code).replace(/^\n+|\n+$/g, "");
120
+ return ph(`<pre><code>${trimmed}</code></pre>`);
121
+ });
122
+
123
+ // 2. Tables → placeholders (before inline formatting)
124
+ const lines = html.split("\n");
125
+ const afterTables = [];
126
+ let i = 0;
127
+
128
+ while (i < lines.length) {
129
+ const line = lines[i];
130
+ if (/^\|(.+)\|$/.test(line.trim())) {
131
+ const tableLines = [];
132
+ let j = i;
133
+ while (j < lines.length && /^\|(.+)\|$/.test(lines[j].trim())) {
134
+ tableLines.push(lines[j].trim());
135
+ j++;
136
+ }
137
+ if (tableLines.length >= 2 && /^\|[\s\-:| ]+\|$/.test(tableLines[1]) && tableLines[1].includes("---")) {
138
+ const sepCells = tableLines[1].split("|").slice(1, -1);
139
+ const aligns = sepCells.map((cell) => {
140
+ const t = cell.trim();
141
+ if (t.startsWith(":") && t.endsWith(":")) return "center";
142
+ if (t.endsWith(":")) return "right";
143
+ return "left";
144
+ });
145
+ const parseRow = (row) => row.split("|").slice(1, -1).map((c) => c.trim());
146
+ const headerCells = parseRow(tableLines[0]);
147
+ let tbl = '<div class="md-table-wrap"><table class="md-table"><thead><tr>';
148
+ headerCells.forEach((cell, ci) => {
149
+ const a = aligns[ci] || "left";
150
+ tbl += `<th${a !== "left" ? ` style="text-align:${a}"` : ""}>${cell}</th>`;
151
+ });
152
+ tbl += "</tr></thead><tbody>";
153
+ for (let b = 2; b < tableLines.length; b++) {
154
+ const cells = parseRow(tableLines[b]);
155
+ tbl += "<tr>";
156
+ cells.forEach((cell, ci) => {
157
+ const a = aligns[ci] || "left";
158
+ tbl += `<td${a !== "left" ? ` style="text-align:${a}"` : ""}>${cell}</td>`;
159
+ });
160
+ tbl += "</tr>";
161
+ }
162
+ tbl += "</tbody></table></div>";
163
+ afterTables.push(ph(tbl));
164
+ i = j;
165
+ continue;
166
+ }
167
+ }
168
+ afterTables.push(line);
169
+ i++;
170
+ }
171
+
172
+ html = afterTables.join("\n");
173
+
174
+ // 3. Inline formatting
175
+ html = html.replace(/`([^`\n]+)`/g, "<code>$1</code>");
176
+ html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
177
+ html = html.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, "<em>$1</em>");
178
+ html = html.replace(/\[([^\]\n]+)\]\(([^)\n]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
179
+
180
+ // 4. Block-level elements
181
+ const lines2 = html.split("\n");
182
+ const result = [];
183
+ let li = 0;
184
+
185
+ while (li < lines2.length) {
186
+ const ln = lines2[li];
187
+
188
+ // Headers
189
+ const hm = ln.match(/^(#{1,6})\s+(.+)$/);
190
+ if (hm) {
191
+ const level = hm[1].length;
192
+ result.push(ph(`<h${level} class="md-h">${hm[2]}</h${level}>`));
193
+ li++;
194
+ continue;
195
+ }
196
+
197
+ // Horizontal rule (standalone --- or *** or ___)
198
+ if (/^(\-{3,}|\*{3,}|_{3,})$/.test(ln.trim()) && !ln.trim().startsWith("|")) {
199
+ result.push(ph('<hr class="md-hr">'));
200
+ li++;
201
+ continue;
202
+ }
203
+
204
+ // Blockquote (escaped > becomes &gt;)
205
+ if (/^&gt;\s?/.test(ln)) {
206
+ let bqHtml = "";
207
+ while (li < lines2.length && /^&gt;\s?/.test(lines2[li])) {
208
+ const content = lines2[li].replace(/^&gt;\s?/, "");
209
+ bqHtml += (bqHtml ? "<br>" : "") + content;
210
+ li++;
211
+ }
212
+ result.push(ph(`<blockquote class="md-blockquote">${bqHtml}</blockquote>`));
213
+ continue;
214
+ }
215
+
216
+ // Unordered list (- or * followed by space)
217
+ if (/^[\-\*]\s+/.test(ln.trim())) {
218
+ let listHtml = '<ul class="md-list">';
219
+ while (li < lines2.length && /^[\-\*]\s+/.test(lines2[li].trim())) {
220
+ const itemText = lines2[li].trim().replace(/^[\-\*]\s+/, "");
221
+ listHtml += `<li>${itemText}</li>`;
222
+ li++;
223
+ }
224
+ listHtml += "</ul>";
225
+ result.push(ph(listHtml));
226
+ continue;
227
+ }
228
+
229
+ // Ordered list (1. 2. etc.)
230
+ if (/^\d+\.\s+/.test(ln.trim())) {
231
+ let listHtml = '<ol class="md-list">';
232
+ while (li < lines2.length && /^\d+\.\s+/.test(lines2[li].trim())) {
233
+ const itemText = lines2[li].trim().replace(/^\d+\.\s+/, "");
234
+ listHtml += `<li>${itemText}</li>`;
235
+ li++;
236
+ }
237
+ listHtml += "</ol>";
238
+ result.push(ph(listHtml));
239
+ continue;
240
+ }
241
+
242
+ result.push(ln);
243
+ li++;
244
+ }
245
+
246
+ html = result.join("\n");
247
+
248
+ // 5. Remaining newlines → <br>
249
+ html = html.replace(/\n/g, "<br>");
250
+
251
+ // 6. Restore placeholders
252
+ html = html.replace(/@@PH_(\d+)@@/g, (_, idx) => placeholders[Number(idx)] || "");
253
+ return html;
254
+ }
255
+
256
+ function parseTimestamp(value) {
257
+ if (value === null || value === undefined || value === "") return null;
258
+ const asNumber = Number(value);
259
+ if (Number.isFinite(asNumber) && asNumber > 0) return asNumber;
260
+
261
+ const asDate = Date.parse(String(value));
262
+ return Number.isNaN(asDate) ? null : asDate;
263
+ }
264
+
265
+ function pathBasename(path) {
266
+ const clean = String(path ?? "").trim();
267
+ if (!clean) return "Unknown";
268
+ const parts = clean.split(/[\\/]/).filter(Boolean);
269
+ return parts[parts.length - 1] || "Unknown";
270
+ }
271
+
272
+ function pathTail(path) {
273
+ const clean = String(path ?? "").trim();
274
+ if (!clean) return "Unknown";
275
+ const parts = clean.split(/[\\/]/).filter(Boolean);
276
+ if (parts.length <= 2) return parts.join("/") || "Unknown";
277
+ return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
278
+ }
279
+
280
+ function toDisplayString(value) {
281
+ if (value === null || value === undefined) return "";
282
+ if (typeof value === "string") return value;
283
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
284
+ try {
285
+ return JSON.stringify(value, null, 2);
286
+ } catch {
287
+ return String(value);
288
+ }
289
+ }
290
+
291
+ function normalizeToolStatus(status) {
292
+ const raw = String(status ?? "").trim().toLowerCase();
293
+ if (!raw) return "pending";
294
+ if (raw === "completed" || raw === "complete" || raw === "success" || raw === "succeeded" || raw === "done") {
295
+ return "completed";
296
+ }
297
+ if (raw === "error" || raw === "failed" || raw === "failure") {
298
+ return "error";
299
+ }
300
+ return "pending";
301
+ }
302
+
303
+ function hasContent(value) {
304
+ if (value === null || value === undefined) return false;
305
+ if (typeof value === "string") return value.trim().length > 0;
306
+ if (Array.isArray(value)) return value.length > 0;
307
+ if (typeof value === "object") return Object.keys(value).length > 0;
308
+ return true;
309
+ }
310
+
311
+ function formatDuration(start, end) {
312
+ const startMs = parseTimestamp(start);
313
+ const endMs = parseTimestamp(end);
314
+ if (startMs === null || endMs === null) return null;
315
+ const durationMs = endMs - startMs;
316
+ if (!Number.isFinite(durationMs) || durationMs < 0) return null;
317
+ if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
318
+
319
+ const seconds = durationMs / 1000;
320
+ const fixed = seconds >= 10 ? seconds.toFixed(1) : seconds.toFixed(2);
321
+ return `${fixed.replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1")}s`;
322
+ }
323
+
324
+ function formatTokenValue(value) {
325
+ const num = Number(value);
326
+ return Number.isFinite(num) ? num.toLocaleString() : "?";
327
+ }
328
+
329
+ function safeColor(color) {
330
+ if (typeof color !== "string") return "#bbb";
331
+ const value = color.trim();
332
+ if (/^#([a-fA-F0-9]{3}|[a-fA-F0-9]{6})$/.test(value)) return value;
333
+ if (/^(rgb|hsl)a?\(/i.test(value)) return value;
334
+ return "#bbb";
335
+ }
336
+
337
+ function firstArray(candidates) {
338
+ for (const candidate of candidates) {
339
+ if (Array.isArray(candidate)) return candidate;
340
+ }
341
+ return [];
342
+ }
343
+
344
+ function extractProjects(payload) {
345
+ return firstArray([
346
+ payload,
347
+ payload?.projects,
348
+ payload?.items,
349
+ payload?.results,
350
+ payload?.data,
351
+ payload?.data?.projects,
352
+ payload?.data?.items,
353
+ payload?.data?.results
354
+ ]);
355
+ }
356
+
357
+ function extractSessions(payload) {
358
+ return firstArray([
359
+ payload,
360
+ payload?.sessions,
361
+ payload?.items,
362
+ payload?.results,
363
+ payload?.data,
364
+ payload?.data?.sessions,
365
+ payload?.data?.items,
366
+ payload?.data?.results
367
+ ]);
368
+ }
369
+
370
+ function extractMessages(payload) {
371
+ return firstArray([
372
+ payload?.messages,
373
+ payload?.session?.messages,
374
+ payload?.data?.messages,
375
+ payload?.data?.session?.messages,
376
+ payload
377
+ ]);
378
+ }
379
+
380
+ function normalizeProject(item, index) {
381
+ const worktree = item?.worktree_path ?? item?.worktreePath ?? item?.worktree ?? item?.path ?? "";
382
+ const id = String(item?.id ?? item?.project_id ?? item?.projectId ?? item?.uuid ?? worktree ?? `project-${index}`);
383
+ return {
384
+ raw: item,
385
+ id,
386
+ name: item?.name ?? item?.project_name ?? item?.projectName ?? item?.title ?? pathBasename(worktree),
387
+ worktree,
388
+ iconColor: item?.icon_color ?? item?.iconColor ?? null,
389
+ updatedAt: parseTimestamp(item?.time_updated ?? item?.updated_at ?? item?.updatedAt ?? item?.last_updated ?? item?.lastUpdate)
390
+ };
391
+ }
392
+
393
+ function normalizeSession(item, index) {
394
+ const id = String(item?.id ?? item?.session_id ?? item?.sessionId ?? item?.uuid ?? `session-${index}`);
395
+ const summaryValue = item?.summary ?? item?.summary_text ?? item?.summaryText ?? item?.has_summary ?? item?.hasSummary;
396
+ const projectId = item?.project_id ?? item?.projectId ?? null;
397
+ const project = state.projects.find((p) => p.id === String(projectId));
398
+ const projectWorktree = item?.project_worktree ?? item?.projectWorktree ?? project?.worktree ?? null;
399
+ return {
400
+ raw: item,
401
+ id,
402
+ projectId: projectId === null || projectId === undefined ? null : String(projectId),
403
+ projectName: item?.project_name ?? item?.projectName ?? project?.name ?? null,
404
+ projectWorktree,
405
+ title: item?.title ?? item?.name ?? item?.session_title ?? item?.sessionTitle ?? "Untitled",
406
+ updatedAt: parseTimestamp(item?.time_updated ?? item?.updated_at ?? item?.updatedAt ?? item?.time_created ?? item?.created_at),
407
+ hasSummary: Boolean(summaryValue)
408
+ };
409
+ }
410
+
411
+ function extractTokenCount(item) {
412
+ const direct = item?.token_count ?? item?.tokenCount ?? item?.total_tokens ?? item?.totalTokens;
413
+ if (typeof direct === "number" && Number.isFinite(direct)) return direct;
414
+
415
+ const tokensObj = item?.tokens;
416
+ if (tokensObj && typeof tokensObj === "object") {
417
+ const total = tokensObj.total ?? tokensObj.total_tokens ?? tokensObj.totalTokens;
418
+ if (typeof total === "number" && Number.isFinite(total)) return total;
419
+
420
+ const input = Number(tokensObj.input ?? tokensObj.input_tokens ?? tokensObj.prompt_tokens ?? NaN);
421
+ const output = Number(tokensObj.output ?? tokensObj.output_tokens ?? tokensObj.completion_tokens ?? NaN);
422
+ if (Number.isFinite(input) || Number.isFinite(output)) {
423
+ return (Number.isFinite(input) ? input : 0) + (Number.isFinite(output) ? output : 0);
424
+ }
425
+ }
426
+
427
+ const inputTokens = Number(item?.input_tokens ?? item?.prompt_tokens ?? NaN);
428
+ const outputTokens = Number(item?.output_tokens ?? item?.completion_tokens ?? NaN);
429
+ if (Number.isFinite(inputTokens) || Number.isFinite(outputTokens)) {
430
+ return (Number.isFinite(inputTokens) ? inputTokens : 0) + (Number.isFinite(outputTokens) ? outputTokens : 0);
431
+ }
432
+
433
+ return null;
434
+ }
435
+
436
+ function normalizeMessageParts(item) {
437
+ const parts = item?.parts ?? item?.content ?? item?.blocks ?? item?.message;
438
+ if (Array.isArray(parts)) {
439
+ return parts.map((part) => {
440
+ if (typeof part === "string") return { type: "text", text: part };
441
+ if (part && typeof part === "object") return part;
442
+ return { type: "text", text: String(part ?? "") };
443
+ });
444
+ }
445
+
446
+ if (typeof parts === "string") return [{ type: "text", text: parts }];
447
+ if (item?.text) return [{ type: "text", text: item.text }];
448
+ if (item?.content && typeof item.content === "object") return [{ type: "text", text: toDisplayString(item.content) }];
449
+ return [];
450
+ }
451
+
452
+ function normalizeMessage(item, index) {
453
+ if (item?._type === "subagent") {
454
+ const id = String(item?.id ?? item?.session_id ?? item?.sessionId ?? `subagent-${index}`);
455
+ const timeCreated = parseTimestamp(item?.time_created ?? item?.created_at ?? item?.createdAt);
456
+ const timeUpdated = parseTimestamp(item?.time_updated ?? item?.updated_at ?? item?.updatedAt);
457
+ return {
458
+ raw: item,
459
+ _type: "subagent",
460
+ id,
461
+ title: item?.title ?? item?.name ?? "Subagent",
462
+ summary: item?.summary ?? null,
463
+ time_created: timeCreated,
464
+ time_updated: timeUpdated,
465
+ timestamp: timeCreated ?? timeUpdated,
466
+ role: null,
467
+ model: null,
468
+ cost: null,
469
+ tokens: null,
470
+ parts: []
471
+ };
472
+ }
473
+
474
+ const role = String(item?.role ?? item?.author ?? "unknown").toLowerCase();
475
+ return {
476
+ raw: item,
477
+ _type: "message",
478
+ id: String(item?.id ?? item?.message_id ?? item?.messageId ?? `message-${index}`),
479
+ role,
480
+ model: item?.model ?? item?.modelID ?? item?.model_name ?? item?.modelName ?? item?.metadata?.model ?? null,
481
+ timestamp: parseTimestamp(item?.time_created ?? item?.created_at ?? item?.createdAt ?? item?.time_updated ?? item?.updated_at),
482
+ cost: item?.cost_usd ?? item?.cost ?? item?.metadata?.cost_usd ?? item?.metadata?.cost ?? null,
483
+ tokens: extractTokenCount(item),
484
+ parts: normalizeMessageParts(item)
485
+ };
486
+ }
487
+
488
+ function formatCost(value) {
489
+ const num = Number(value);
490
+ if (!Number.isFinite(num)) return null;
491
+ return `$${num.toFixed(num < 0.01 ? 5 : 4)}`;
492
+ }
493
+
494
+ async function fetchJson(url) {
495
+ const response = await fetch(url);
496
+ const text = await response.text();
497
+
498
+ let payload = null;
499
+ if (text) {
500
+ try {
501
+ payload = JSON.parse(text);
502
+ } catch {
503
+ payload = text;
504
+ }
505
+ }
506
+
507
+ if (!response.ok) {
508
+ const reason =
509
+ (payload && typeof payload === "object" && (payload.error || payload.message)) ||
510
+ (typeof payload === "string" && payload) ||
511
+ response.statusText ||
512
+ `HTTP ${response.status}`;
513
+ throw new Error(String(reason));
514
+ }
515
+
516
+ return payload;
517
+ }
518
+
519
+ function getSelectedProject() {
520
+ return state.projects.find((p) => p.id === state.selectedProjectId) || null;
521
+ }
522
+
523
+ function getSelectedSession() {
524
+ return state.sessions.find((s) => s.id === state.selectedSessionId) || null;
525
+ }
526
+
527
+ function hasBulkActions() {
528
+ return (window.OCV?.listBulkActions?.() ?? []).length > 0;
529
+ }
530
+
531
+ function renderProjectsPanel() {
532
+ const plugins = window.OCV?.list?.() ?? [];
533
+ const header = `
534
+ <div class="panel-header">
535
+ <div>
536
+ <h2 class="panel-title">Projects</h2>
537
+ <div class="panel-subtitle">${state.projects.length} total</div>
538
+ </div>
539
+ <div class="header-actions">
540
+ ${state.writeEnabled ? `<button class="header-btn ${state.editMode ? "active" : ""}" data-action="toggle-edit-mode">${state.editMode ? "Editing" : "Edit"}</button>` : ""}
541
+ ${hasBulkActions() ? `<button class="header-btn ${state.selectMode ? "active" : ""}" data-action="toggle-select-mode">${state.selectMode ? "Done" : "Select"}</button>` : ""}
542
+ <button class="header-btn ${state.pluginsOpen ? "active" : ""}" data-action="toggle-plugins">Plugins${plugins.length ? ` (${plugins.length})` : ""}</button>
543
+ </div>
544
+ </div>
545
+ `;
546
+
547
+ let body = "";
548
+ if (state.projectsLoading) {
549
+ body = `<div class="state">Loading...</div>`;
550
+ } else if (state.projectsError) {
551
+ body = `<div class="state error">${escapeHtml(state.projectsError)}</div>`;
552
+ } else if (!state.projects.length) {
553
+ body = `<div class="state">No projects found.</div>`;
554
+ } else {
555
+ body = state.projects
556
+ .map((project) => {
557
+ const selected = project.id === state.selectedProjectId ? "selected" : "";
558
+ const titleDate = project.updatedAt ? formatDate(project.updatedAt) : "Unknown";
559
+ const relativeTime = timeAgo(project.updatedAt);
560
+ const compactDate = shortDate(project.updatedAt);
561
+ const displayTime = compactDate ? `${compactDate} · ${relativeTime}` : relativeTime;
562
+ const inner = `
563
+ <div class="project-top">
564
+ <span class="dot" style="background:${safeColor(project.iconColor)}"></span>
565
+ <span class="project-name" title="${escapeHtml(project.name)}">${escapeHtml(project.name)}</span>
566
+ <span class="project-time" title="${escapeHtml(titleDate)}">${escapeHtml(displayTime)}</span>
567
+ </div>
568
+ <div class="project-path" title="${escapeHtml(project.worktree || "")}">${escapeHtml(truncate(project.worktree || "(no worktree path)", 44))}</div>
569
+ `;
570
+
571
+ if (state.selectMode) {
572
+ const checked = state.selectedProjectIds.has(project.id);
573
+ return `
574
+ <div class="project-row selectable ${checked ? "checked" : ""}" data-action="toggle-project-select" data-project-id="${escapeHtml(project.id)}" role="checkbox" aria-checked="${checked}" tabindex="0">
575
+ <span class="checkbox ${checked ? "checked" : ""}"></span>
576
+ <div class="project-row-body">${inner}</div>
577
+ </div>
578
+ `;
579
+ }
580
+
581
+ return `
582
+ <button class="project-row ${selected}" data-action="select-project" data-project-id="${escapeHtml(project.id)}">
583
+ ${inner}
584
+ </button>
585
+ `;
586
+ })
587
+ .join("");
588
+ }
589
+
590
+ el.projects.innerHTML = `${header}${buildSessionsToolbarHtml()}<div class="panel-body">${body}</div>`;
591
+ renderBulkBar();
592
+ }
593
+
594
+ function buildSessionsHeaderHtml(projectName, subtitleText) {
595
+ return `
596
+ <div class="panel-header">
597
+ <div style="flex:1;min-width:0;">
598
+ <h2 class="panel-title">${escapeHtml(projectName)}</h2>
599
+ <div class="panel-subtitle">${escapeHtml(subtitleText)}</div>
600
+ </div>
601
+ ${hasBulkActions() && state.sessions.length ? `<div class="header-actions"><button class="header-btn ${state.selectMode ? "active" : ""}" data-action="toggle-select-mode">${state.selectMode ? "Done" : "Select"}</button></div>` : ""}
602
+ </div>
603
+ `;
604
+ }
605
+
606
+ function buildSessionsToolbarHtml() {
607
+ const scope = state.sessionsScope;
608
+ return `
609
+ <div class="sessions-toolbar">
610
+ <input
611
+ type="search"
612
+ class="sessions-search"
613
+ data-action="search-sessions"
614
+ aria-label="Search sessions across projects by keyword"
615
+ placeholder="Search all sessions..."
616
+ value="${escapeHtml(state.sessionsQuery)}"
617
+ autocomplete="off"
618
+ spellcheck="false" />
619
+ <select class="sessions-scope" data-action="scope-sessions" aria-label="Search scope">
620
+ <option value="all"${scope === "all" ? " selected" : ""}>All messages</option>
621
+ <option value="user"${scope === "user" ? " selected" : ""}>User input</option>
622
+ <option value="assistant"${scope === "assistant" ? " selected" : ""}>Model response</option>
623
+ </select>
624
+ </div>
625
+ `;
626
+ }
627
+
628
+ function buildSessionsBodyHtml() {
629
+ const isSearching = Boolean(state.sessionsQuery);
630
+
631
+ if (!state.selectedProjectId && !isSearching) {
632
+ return `<div class="state">Select a project or search all projects.</div>`;
633
+ }
634
+
635
+ if (state.sessionsLoading && state.sessions.length === 0) {
636
+ return `<div class="state">Loading...</div>`;
637
+ }
638
+
639
+ if (state.sessionsError) {
640
+ return `<div class="state error">${escapeHtml(state.sessionsError)}</div>`;
641
+ }
642
+
643
+ if (!state.sessions.length) {
644
+ if (state.sessionsQuery) {
645
+ const scopeLabel =
646
+ state.sessionsScope === "user"
647
+ ? " in user messages"
648
+ : state.sessionsScope === "assistant"
649
+ ? " in model responses"
650
+ : "";
651
+ return `<div class="state">No sessions match "${escapeHtml(state.sessionsQuery)}"${scopeLabel}.</div>`;
652
+ }
653
+ return `<div class="state">No sessions found.</div>`;
654
+ }
655
+
656
+ let body = state.sessions
657
+ .map((session) => {
658
+ const selected = session.id === state.selectedSessionId ? "selected" : "";
659
+ const time = session.updatedAt ? timeAgo(session.updatedAt) : "unknown";
660
+ const date = shortDate(session.updatedAt);
661
+ const displayTime = date ? `${date} · ${time}` : time;
662
+ const exact = session.updatedAt ? formatDate(session.updatedAt) : "Unknown";
663
+ const projectName = session.projectName || (session.projectWorktree ? pathBasename(session.projectWorktree) : "Unknown project");
664
+ const projectPath = session.projectWorktree || "";
665
+ const projectContext = projectPath
666
+ ? `${projectName} · ${truncate(projectPath, 48)}`
667
+ : projectName;
668
+ const inner = `
669
+ <p class="session-title" title="${escapeHtml(session.title || "Untitled")}">${escapeHtml(session.title || "Untitled")}</p>
670
+ ${isSearching ? `<div class="session-project-context" title="${escapeHtml(projectPath || projectName)}">${escapeHtml(projectContext)}</div>` : ""}
671
+ <div class="session-meta">
672
+ <span title="${escapeHtml(exact)}">${escapeHtml(displayTime)}</span>
673
+ ${session.hasSummary ? '<span class="badge">summarized</span>' : ""}
674
+ </div>
675
+ `;
676
+
677
+ if (state.selectMode) {
678
+ const checked = state.selectedSessionIds.has(session.id);
679
+ return `
680
+ <div class="session-card selectable ${checked ? "checked" : ""}" data-action="toggle-session-select" data-session-id="${escapeHtml(session.id)}" role="checkbox" aria-checked="${checked}" tabindex="0">
681
+ <span class="checkbox ${checked ? "checked" : ""}"></span>
682
+ <div class="session-card-body">${inner}</div>
683
+ </div>
684
+ `;
685
+ }
686
+
687
+ return `
688
+ <button class="session-card ${selected}" data-action="select-session" data-session-id="${escapeHtml(session.id)}">
689
+ ${inner}
690
+ </button>
691
+ `;
692
+ })
693
+ .join("");
694
+
695
+ if (state.sessionsHasMore) {
696
+ body += `
697
+ <div class="load-more-wrap">
698
+ <button class="load-more-btn" data-action="load-more-sessions" ${state.sessionsLoadingMore ? "disabled" : ""}>
699
+ ${state.sessionsLoadingMore ? "Loading..." : "Load more"}
700
+ </button>
701
+ </div>
702
+ `;
703
+ }
704
+
705
+ return body;
706
+ }
707
+
708
+ function renderSessionsPanel() {
709
+ const selectedProject = getSelectedProject();
710
+ const isSearching = Boolean(state.sessionsQuery);
711
+ const projectName = isSearching ? "Search results" : selectedProject ? selectedProject.name : "Sessions";
712
+ const totalLabel = state.sessionsTotal !== null ? ` / ${state.sessionsTotal}` : "";
713
+ const subtitleText = isSearching
714
+ ? `${state.sessions.length}${totalLabel} matching sessions across projects`
715
+ : `${state.sessions.length}${totalLabel} sessions`;
716
+ const existingToolbar = el.sessions.querySelector(".sessions-toolbar");
717
+ const existingBody = el.sessions.querySelector(".panel-body");
718
+ const existingSubtitle = el.sessions.querySelector(".panel-subtitle");
719
+ const existingTitle = el.sessions.querySelector(".panel-title");
720
+ const wantToolbar = false;
721
+ const hasToolbar = Boolean(existingToolbar);
722
+ const renderedProjectId = el.sessions.dataset.projectId || "";
723
+ const currentProjectId = state.selectedProjectId || "";
724
+ const headerSignature = `${state.selectMode}|${state.sessions.length}|${hasBulkActions()}`;
725
+ const renderedHeaderSignature = el.sessions.dataset.headerSignature || "";
726
+
727
+ if (
728
+ renderedProjectId === currentProjectId &&
729
+ hasToolbar === wantToolbar &&
730
+ renderedHeaderSignature === headerSignature &&
731
+ existingBody &&
732
+ existingSubtitle &&
733
+ existingTitle
734
+ ) {
735
+ existingTitle.textContent = projectName;
736
+ existingSubtitle.textContent = subtitleText;
737
+ existingBody.innerHTML = buildSessionsBodyHtml();
738
+ renderBulkBar();
739
+ return;
740
+ }
741
+
742
+ el.sessions.innerHTML = `${buildSessionsHeaderHtml(projectName, subtitleText)}<div class="panel-body">${buildSessionsBodyHtml()}</div>`;
743
+ el.sessions.dataset.projectId = currentProjectId;
744
+ el.sessions.dataset.headerSignature = headerSignature;
745
+ renderBulkBar();
746
+ }
747
+
748
+ function renderBulkBar() {
749
+ if (!el.bulkBar) return;
750
+ const count = state.selectedProjectIds.size + state.selectedSessionIds.size;
751
+ if (!state.selectMode || count === 0) {
752
+ el.bulkBar.hidden = true;
753
+ el.bulkBar.innerHTML = "";
754
+ return;
755
+ }
756
+
757
+ const buttons = (window.OCV?.listBulkActions?.() ?? [])
758
+ .filter((action) => !action.write || (state.writeEnabled && state.editMode))
759
+ .map(
760
+ (action) =>
761
+ `<button class="bulk-btn ${action.danger ? "danger" : ""}" data-action="bulk-run" data-bulk-id="${escapeHtml(action.id)}">${escapeHtml(action.label)}</button>`
762
+ )
763
+ .join("");
764
+
765
+ el.bulkBar.hidden = false;
766
+ el.bulkBar.innerHTML = `
767
+ <span class="bulk-count">${count} selected</span>
768
+ ${buttons}
769
+ <button class="bulk-btn ghost" data-action="bulk-clear">Clear</button>
770
+ `;
771
+ }
772
+
773
+ function renderPluginsDialog() {
774
+ if (!el.modal) return;
775
+ if (!state.pluginsOpen) {
776
+ el.modal.innerHTML = "";
777
+ return;
778
+ }
779
+
780
+ const plugins = window.OCV?.list?.() ?? [];
781
+ const rows = plugins.length
782
+ ? plugins
783
+ .map(
784
+ (plugin) => `
785
+ <label class="plugin-row">
786
+ <input type="checkbox" data-action="toggle-plugin" data-plugin-id="${escapeHtml(plugin.id)}" ${plugin.enabled ? "checked" : ""} />
787
+ <span class="plugin-info">
788
+ <span class="plugin-name">${escapeHtml(plugin.name)}</span>
789
+ ${plugin.description ? `<span class="plugin-desc">${escapeHtml(plugin.description)}</span>` : ""}
790
+ </span>
791
+ </label>
792
+ `
793
+ )
794
+ .join("")
795
+ : `<div class="state">No plugins found in public/plugins/.</div>`;
796
+
797
+ el.modal.innerHTML = `
798
+ <div class="modal-overlay">
799
+ <div class="modal" role="dialog" aria-label="Plugins">
800
+ <div class="modal-header">
801
+ <h3>Plugins</h3>
802
+ <button class="header-btn" data-action="close-plugins">Close</button>
803
+ </div>
804
+ <div class="modal-body">${rows}</div>
805
+ </div>
806
+ </div>
807
+ `;
808
+ }
809
+
810
+ function renderToolResultPart(output) {
811
+ const full = toDisplayString(output);
812
+ if (full.length <= 2000) {
813
+ return `<pre>${escapeHtml(full || "(empty)")}</pre>`;
814
+ }
815
+
816
+ const shortId = `tool-short-${++toolResultCounter}`;
817
+ const fullId = `tool-full-${toolResultCounter}`;
818
+ const shortText = `${full.slice(0, 2000)}\n… (truncated)`;
819
+
820
+ return `
821
+ <pre id="${shortId}">${escapeHtml(shortText)}</pre>
822
+ <pre id="${fullId}" hidden>${escapeHtml(full)}</pre>
823
+ <button class="mini-btn" data-action="toggle-result" data-short-id="${shortId}" data-full-id="${fullId}" data-expanded="false">Show more</button>
824
+ `;
825
+ }
826
+
827
+ function renderMessagePart(part) {
828
+ const type = String(part?.type ?? "text").toLowerCase();
829
+
830
+ const pluginRenderer = window.OCV?.getPartRenderer?.(type);
831
+ if (pluginRenderer) {
832
+ try {
833
+ const html = pluginRenderer(part, { escapeHtml, toDisplayString, renderMarkdownLite });
834
+ if (html) return html;
835
+ } catch (error) {
836
+ console.error("OCV plugin part renderer failed:", error);
837
+ }
838
+ }
839
+
840
+ if (type === "step-start" || type === "snapshot") {
841
+ return "";
842
+ }
843
+
844
+ if (type === "text") {
845
+ const text = part?.text ?? "";
846
+ return `<div class="part">${renderMarkdownLite(toDisplayString(text))}</div>`;
847
+ }
848
+
849
+ if (type === "reasoning") {
850
+ const text = part?.text ?? "";
851
+ return `
852
+ <div class="part">
853
+ <details>
854
+ <summary class="summary-reasoning">Reasoning</summary>
855
+ <pre>${escapeHtml(toDisplayString(text) || "(empty)")}</pre>
856
+ </details>
857
+ </div>
858
+ `;
859
+ }
860
+
861
+ if (type === "tool") {
862
+ const partId = part?.id;
863
+ const toolName = part?.tool ?? "Unknown";
864
+ const statusClass = normalizeToolStatus(part?.state?.status);
865
+ const title = part?.state?.title;
866
+ const input = part?.state?.input ?? {};
867
+ const output = part?.state?.output;
868
+ const duration = formatDuration(part?.state?.time?.start, part?.state?.time?.end);
869
+ let attachmentHtml = "";
870
+
871
+ if (part?.hasAttachments && partId) {
872
+ attachmentHtml = `
873
+ <div style="margin-top:8px;">
874
+ <div class="tiny-muted">Attachment</div>
875
+ <img src="/api/parts/${encodeURIComponent(partId)}/image" loading="lazy" class="part-image" alt="Tool attachment" />
876
+ </div>
877
+ `;
878
+ }
879
+
880
+ return `
881
+ <div class="part">
882
+ <details>
883
+ <summary class="summary-tool">🔧 ${escapeHtml(String(toolName))}<span class="tool-status ${statusClass}">${escapeHtml(statusClass)}</span></summary>
884
+ ${title ? `<div class="tiny-muted" style="margin-top:6px;">${escapeHtml(String(title))}</div>` : ""}
885
+ <div class="tiny-muted" style="margin-top:6px;">Input</div>
886
+ <pre>${escapeHtml(toDisplayString(input) || "{}")}</pre>
887
+ ${hasContent(output) ? `<div class="tiny-muted" style="margin-top:8px;">Output</div>${renderToolResultPart(output)}` : ""}
888
+ ${duration ? `<div class="tiny-muted" style="margin-top:8px;">Execution time: ${escapeHtml(duration)}</div>` : ""}
889
+ ${attachmentHtml}
890
+ </details>
891
+ </div>
892
+ `;
893
+ }
894
+
895
+ if (type === "patch") {
896
+ const hash = String(part?.hash ?? "");
897
+ const shortHash = hash ? hash.slice(0, 7) : "unknown";
898
+ const files = Array.isArray(part?.files) ? part.files : [];
899
+ const fileList = files.map((file) => escapeHtml(pathTail(file))).join("<br>");
900
+
901
+ return `
902
+ <div class="part part-patch">
903
+ <div>📝 <strong>Patch</strong> <code>${escapeHtml(shortHash)}</code></div>
904
+ ${fileList ? `<div class="part-patch-files">${fileList}</div>` : ""}
905
+ </div>
906
+ `;
907
+ }
908
+
909
+ if (type === "file") {
910
+ const filename = part?.filename ?? "Attachment";
911
+ const mime = part?.mime ?? "unknown";
912
+ const hasImage = Boolean(part?.hasImage);
913
+ const isText = typeof mime === "string" && mime.toLowerCase().startsWith("text/");
914
+ const partId = part?.id;
915
+
916
+ let imageHtml = "";
917
+ if (hasImage && partId) {
918
+ imageHtml = `<img src="/api/parts/${encodeURIComponent(partId)}/image" loading="lazy" class="part-image" alt="${escapeHtml(String(filename))}" />`;
919
+ }
920
+
921
+ return `
922
+ <div class="part part-file-wrap">
923
+ <div class="part-file">
924
+ <span>📎</span>
925
+ <div>
926
+ <div>${escapeHtml(String(filename))}</div>
927
+ <div class="tiny-muted">${escapeHtml(String(mime))}${isText ? " · text file" : ""}</div>
928
+ </div>
929
+ </div>
930
+ ${imageHtml}
931
+ </div>
932
+ `;
933
+ }
934
+
935
+ if (type === "agent") {
936
+ const nameRaw = String(part?.name ?? part?.source?.value ?? "agent");
937
+ const name = nameRaw.replace(/^@+/, "") || "agent";
938
+ return `<div class="part"><span class="part-agent">🤖 @${escapeHtml(name)}</span></div>`;
939
+ }
940
+
941
+ if (type === "step-finish") {
942
+ const cost = formatCost(part?.cost);
943
+ const inputTokens = formatTokenValue(part?.tokens?.input);
944
+ const outputTokens = formatTokenValue(part?.tokens?.output);
945
+ return `
946
+ <div class="part part-step-finish">
947
+ <span>Step complete</span>
948
+ ${cost ? `<span>· ${escapeHtml(cost)}</span>` : ""}
949
+ <span>· ${escapeHtml(`${inputTokens}→${outputTokens} tokens`)}</span>
950
+ </div>
951
+ `;
952
+ }
953
+
954
+ if (type === "compaction") {
955
+ return `<div class="part part-compaction">── context compacted ──</div>`;
956
+ }
957
+
958
+ if (type === "retry") {
959
+ return `<div class="part part-retry">⟳ Retried</div>`;
960
+ }
961
+
962
+ const text = part?.text ?? part?.content ?? part?.value;
963
+ if (typeof text === "string" && text.trim()) {
964
+ return `<div class="part">${renderMarkdownLite(toDisplayString(text))}</div>`;
965
+ }
966
+
967
+ return `<div class="part"><pre>${escapeHtml(toDisplayString(part) || "(empty)")}</pre></div>`;
968
+ }
969
+
970
+ function renderSubagentCard(entry) {
971
+ const title = entry.title || entry.id || "Subagent";
972
+ const time = entry.time_created ? formatDate(entry.time_created) : "";
973
+ const ago = entry.time_created ? timeAgo(entry.time_created) : "";
974
+ const timeLabel = [time, ago].filter(Boolean).join(" · ");
975
+
976
+ return `
977
+ <div class="subagent-card" data-action="navigate-subagent" data-session-id="${escapeHtml(entry.id)}">
978
+ <div class="subagent-card-icon">🤖</div>
979
+ <div class="subagent-card-content">
980
+ <div class="subagent-card-title">${escapeHtml(title)}</div>
981
+ <div class="subagent-card-time">${escapeHtml(timeLabel || "Unknown")}</div>
982
+ </div>
983
+ <div class="subagent-card-arrow">→</div>
984
+ </div>
985
+ `;
986
+ }
987
+
988
+ function messageDomId(messageId) {
989
+ return `message-${String(messageId ?? "").replace(/[^a-zA-Z0-9_-]/g, "_")}`;
990
+ }
991
+
992
+ function getMessagePreview(message) {
993
+ for (const part of message?.parts ?? []) {
994
+ const type = String(part?.type ?? "text").toLowerCase();
995
+ if (type !== "text" && type !== "reasoning") continue;
996
+ const text = toDisplayString(part?.text ?? part?.content ?? part?.value).replace(/\s+/g, " ").trim();
997
+ if (text) return truncate(text, 86);
998
+ }
999
+
1000
+ for (const part of message?.parts ?? []) {
1001
+ const text = toDisplayString(part?.text ?? part?.content ?? part?.value).replace(/\s+/g, " ").trim();
1002
+ if (text) return truncate(text, 86);
1003
+ }
1004
+
1005
+ return "(empty user input)";
1006
+ }
1007
+
1008
+ function getUserInputMessages() {
1009
+ return state.messages.filter((message) => message?._type !== "subagent" && message.role === "user");
1010
+ }
1011
+
1012
+ function renderMessageOutline() {
1013
+ const collapsed = state.messageOutlineCollapsed;
1014
+ const userMessages = getUserInputMessages();
1015
+ const title = `${userMessages.length} user input${userMessages.length === 1 ? "" : "s"}`;
1016
+ const items = userMessages.length
1017
+ ? userMessages.map((message, index) => {
1018
+ const label = getMessagePreview(message);
1019
+ const time = message.timestamp ? shortDate(message.timestamp) || timeAgo(message.timestamp) : "";
1020
+ return `
1021
+ <button class="message-outline-item" data-action="scroll-message" data-message-id="${escapeHtml(message.id)}" title="${escapeHtml(label)}">
1022
+ <div class="message-outline-item-title">${escapeHtml(label)}</div>
1023
+ <div class="message-outline-item-time">${escapeHtml(time || `Input ${index + 1}`)}</div>
1024
+ </button>
1025
+ `;
1026
+ }).join("")
1027
+ : `<div class="message-outline-empty">No user inputs in this session.</div>`;
1028
+
1029
+ return `
1030
+ <aside class="message-outline ${collapsed ? "collapsed" : ""}" aria-label="User input navigation">
1031
+ <div class="message-outline-header">
1032
+ <div class="message-outline-title" title="${escapeHtml(title)}">${escapeHtml(title)}</div>
1033
+ <button class="message-outline-toggle" data-action="toggle-message-outline" aria-label="${collapsed ? "Expand user input navigation" : "Collapse user input navigation"}">${collapsed ? "‹" : "›"}</button>
1034
+ </div>
1035
+ <div class="message-outline-list">${items}</div>
1036
+ </aside>
1037
+ `;
1038
+ }
1039
+
1040
+ function renderMessage(message) {
1041
+ if (message?._type === "subagent") {
1042
+ return renderSubagentCard(message);
1043
+ }
1044
+
1045
+ const roleClass =
1046
+ message.role === "user"
1047
+ ? "role-user"
1048
+ : message.role === "assistant"
1049
+ ? "role-assistant"
1050
+ : "role-other";
1051
+
1052
+ const roleLabel = message.role ? message.role.charAt(0).toUpperCase() + message.role.slice(1) : "Unknown";
1053
+ const timeLabel = message.timestamp ? formatDate(message.timestamp) : "Unknown time";
1054
+ const meta = [
1055
+ `<span class="pill">${escapeHtml(roleLabel)}</span>`,
1056
+ `<span>${escapeHtml(timeLabel)}</span>`
1057
+ ];
1058
+
1059
+ if (message.role === "assistant" && message.model) {
1060
+ meta.splice(1, 0, `<span class="pill">${escapeHtml(message.model)}</span>`);
1061
+ }
1062
+
1063
+ const cost = formatCost(message.cost);
1064
+ if (message.role === "assistant" && cost) {
1065
+ meta.push(`<span class="pill">cost ${escapeHtml(cost)}</span>`);
1066
+ }
1067
+
1068
+ if (message.role === "assistant" && typeof message.tokens === "number") {
1069
+ meta.push(`<span class="pill">${message.tokens.toLocaleString()} tokens</span>`);
1070
+ }
1071
+
1072
+ const renderedParts = message.parts.map((part) => renderMessagePart(part)).filter(Boolean);
1073
+ const parts = renderedParts.length
1074
+ ? renderedParts.join("")
1075
+ : `<div class="part tiny-muted">(no message content)</div>`;
1076
+
1077
+ return `
1078
+ <article class="message ${roleClass}" id="${escapeHtml(messageDomId(message.id))}" data-message-id="${escapeHtml(message.id)}">
1079
+ <div class="message-meta">${meta.join("")}</div>
1080
+ ${parts}
1081
+ </article>
1082
+ `;
1083
+ }
1084
+
1085
+ function buildHeaderActions() {
1086
+ if (!state.selectedSessionId || state.sessionLoading || state.sessionError) return "";
1087
+
1088
+ const formats = [
1089
+ { id: "md", label: "Markdown (.md)" },
1090
+ { id: "json", label: "JSON (.json)" }
1091
+ ];
1092
+ for (const format of window.OCV?.listExportFormats?.() ?? []) {
1093
+ formats.push({ id: format.id, label: format.label || format.id, plugin: true });
1094
+ }
1095
+
1096
+ const formatButtons = formats
1097
+ .map((format) => {
1098
+ const pluginFlag = format.plugin ? ' data-plugin="true"' : "";
1099
+ return `<button class="export-menu-item" data-action="export-session" data-format="${escapeHtml(format.id)}"${pluginFlag}>${escapeHtml(format.label)}</button>`;
1100
+ })
1101
+ .join("");
1102
+
1103
+ const exportMenu = `
1104
+ <div class="export-menu-wrap">
1105
+ <button class="header-btn" data-action="toggle-export-menu" aria-haspopup="true" aria-expanded="${state.exportMenuOpen}">Export ▾</button>
1106
+ ${state.exportMenuOpen ? `<div class="export-menu">${formatButtons}</div>` : ""}
1107
+ </div>
1108
+ `;
1109
+
1110
+ const pluginActions = (window.OCV?.listSessionActions?.() ?? [])
1111
+ .map(
1112
+ (action) =>
1113
+ `<button class="header-btn" data-action="plugin-session-action" data-action-id="${escapeHtml(action.id)}">${escapeHtml(action.label)}</button>`
1114
+ )
1115
+ .join("");
1116
+
1117
+ return `<div class="header-actions">${pluginActions}${exportMenu}</div>`;
1118
+ }
1119
+
1120
+ function renderMessagesPanel() {
1121
+ const fallbackTitle = getSelectedSession()?.title || "Session";
1122
+ const title = state.sessionData?.title ?? fallbackTitle;
1123
+ const sessionUpdatedAt = state.sessionData?.updatedAt ?? getSelectedSession()?.updatedAt ?? null;
1124
+ const subtitle = state.selectedSessionId
1125
+ ? `${state.messages.length} messages${sessionUpdatedAt ? ` · updated ${timeAgo(sessionUpdatedAt)}` : ""}`
1126
+ : "";
1127
+
1128
+ const backBtn = state.sessionStack.length > 0
1129
+ ? '<button class="back-btn" data-action="navigate-back">← Back</button>'
1130
+ : "";
1131
+
1132
+ const header = `
1133
+ <div class="panel-header">
1134
+ <div style="flex:1;min-width:0;">
1135
+ <h2 class="panel-title">${escapeHtml(title || "Session")}</h2>
1136
+ <div class="panel-subtitle">${escapeHtml(subtitle)}</div>
1137
+ </div>
1138
+ ${buildHeaderActions()}
1139
+ ${backBtn}
1140
+ </div>
1141
+ `;
1142
+
1143
+ let body = "";
1144
+ if (!state.selectedSessionId) {
1145
+ body = `<div class="state">Select a session.</div>`;
1146
+ } else if (state.sessionLoading) {
1147
+ body = `<div class="state">Loading...</div>`;
1148
+ } else if (state.sessionError) {
1149
+ body = `<div class="state error">${escapeHtml(state.sessionError)}</div>`;
1150
+ } else {
1151
+ const messagesHtml = state.messages.length
1152
+ ? state.messages
1153
+ .map((message) => (message?._type === "subagent" ? renderSubagentCard(message) : renderMessage(message)))
1154
+ .join("")
1155
+ : `<div class="state">No messages for this session.</div>`;
1156
+ body = `<div class="messages">${messagesHtml}</div>`;
1157
+ }
1158
+
1159
+ const outline = state.selectedSessionId && !state.sessionLoading && !state.sessionError ? renderMessageOutline() : "";
1160
+ el.messages.innerHTML = `${header}<div class="messages-shell"><div class="panel-body">${body}</div>${outline}</div>`;
1161
+ }
1162
+
1163
+ function extractTotal(payload) {
1164
+ const raw = payload?.total ?? payload?.count ?? payload?.pagination?.total ?? payload?.meta?.total;
1165
+ const num = Number(raw);
1166
+ return Number.isFinite(num) ? num : null;
1167
+ }
1168
+
1169
+ function extractHasMore(payload, loadedCount, pageSize, receivedCount, totalCount) {
1170
+ const explicit =
1171
+ payload?.has_more ??
1172
+ payload?.hasMore ??
1173
+ payload?.pagination?.has_more ??
1174
+ payload?.pagination?.hasMore ??
1175
+ payload?.meta?.has_more ??
1176
+ payload?.meta?.hasMore;
1177
+
1178
+ if (typeof explicit === "boolean") return explicit;
1179
+ if (totalCount !== null) return loadedCount < totalCount;
1180
+ return receivedCount === pageSize;
1181
+ }
1182
+
1183
+ async function loadProjects() {
1184
+ const requestId = ++projectsRequestId;
1185
+ state.projectsLoading = true;
1186
+ state.projectsError = null;
1187
+ renderProjectsPanel();
1188
+
1189
+ try {
1190
+ const payload = await fetchJson("/api/projects");
1191
+ if (requestId !== projectsRequestId) return;
1192
+
1193
+ const projects = extractProjects(payload).map((item, index) => normalizeProject(item, index));
1194
+ state.projects = projects;
1195
+ } catch (error) {
1196
+ if (requestId !== projectsRequestId) return;
1197
+ state.projectsError = error instanceof Error ? error.message : String(error);
1198
+ } finally {
1199
+ if (requestId !== projectsRequestId) return;
1200
+ state.projectsLoading = false;
1201
+ renderProjectsPanel();
1202
+ }
1203
+ }
1204
+
1205
+ async function loadSessions({ reset = false } = {}) {
1206
+ const requestQuery = state.sessionsQuery;
1207
+ const requestScope = state.sessionsScope;
1208
+ const isGlobalSearch = Boolean(requestQuery);
1209
+ if (!state.selectedProjectId && !isGlobalSearch) {
1210
+ sessionsRequestId++;
1211
+ if (reset) {
1212
+ state.sessions = [];
1213
+ state.sessionsLoading = false;
1214
+ state.sessionsLoadingMore = false;
1215
+ state.sessionsError = null;
1216
+ state.sessionsPage = 1;
1217
+ state.sessionsHasMore = false;
1218
+ state.sessionsTotal = null;
1219
+ renderSessionsPanel();
1220
+ }
1221
+ return;
1222
+ }
1223
+
1224
+ if (reset) {
1225
+ state.sessionsLoading = true;
1226
+ state.sessionsError = null;
1227
+ state.sessionsPage = 1;
1228
+ state.sessionsHasMore = false;
1229
+ state.sessionsTotal = null;
1230
+ } else {
1231
+ if (state.sessionsLoadingMore || !state.sessionsHasMore) return;
1232
+ state.sessionsLoadingMore = true;
1233
+ }
1234
+
1235
+ renderSessionsPanel();
1236
+
1237
+ const projectId = state.selectedProjectId;
1238
+ const nextPage = reset ? 1 : state.sessionsPage + 1;
1239
+ const requestId = ++sessionsRequestId;
1240
+
1241
+ try {
1242
+ const params = new URLSearchParams({
1243
+ page: String(nextPage),
1244
+ limit: String(state.sessionsLimit)
1245
+ });
1246
+ if (requestQuery) params.set("q", requestQuery);
1247
+ if (requestScope && requestScope !== "all") {
1248
+ params.set("scope", requestScope);
1249
+ }
1250
+ const url = isGlobalSearch
1251
+ ? `/api/sessions/search?${params.toString()}`
1252
+ : `/api/projects/${encodeURIComponent(projectId)}/sessions?${params.toString()}`;
1253
+ const payload = await fetchJson(url);
1254
+ if (
1255
+ requestId !== sessionsRequestId ||
1256
+ projectId !== state.selectedProjectId ||
1257
+ requestQuery !== state.sessionsQuery ||
1258
+ requestScope !== state.sessionsScope
1259
+ ) return;
1260
+
1261
+ const incoming = extractSessions(payload).map((item, index) => normalizeSession(item, index));
1262
+
1263
+ if (reset) {
1264
+ state.sessions = incoming;
1265
+ } else {
1266
+ const byId = new Map(state.sessions.map((session) => [session.id, session]));
1267
+ for (const item of incoming) byId.set(item.id, item);
1268
+ state.sessions = Array.from(byId.values());
1269
+ }
1270
+
1271
+ state.sessionsTotal = extractTotal(payload);
1272
+ state.sessionsHasMore = extractHasMore(
1273
+ payload,
1274
+ state.sessions.length,
1275
+ state.sessionsLimit,
1276
+ incoming.length,
1277
+ state.sessionsTotal
1278
+ );
1279
+ state.sessionsPage = nextPage;
1280
+ } catch (error) {
1281
+ if (
1282
+ requestId !== sessionsRequestId ||
1283
+ projectId !== state.selectedProjectId ||
1284
+ requestQuery !== state.sessionsQuery ||
1285
+ requestScope !== state.sessionsScope
1286
+ ) return;
1287
+ state.sessionsError = error instanceof Error ? error.message : String(error);
1288
+ } finally {
1289
+ if (
1290
+ requestId !== sessionsRequestId ||
1291
+ projectId !== state.selectedProjectId ||
1292
+ requestQuery !== state.sessionsQuery ||
1293
+ requestScope !== state.sessionsScope
1294
+ ) return;
1295
+ state.sessionsLoading = false;
1296
+ state.sessionsLoadingMore = false;
1297
+ renderSessionsPanel();
1298
+ }
1299
+ }
1300
+
1301
+ async function loadSession(sessionId) {
1302
+ state.selectedSessionId = sessionId;
1303
+ state.sessionLoading = true;
1304
+ state.sessionError = null;
1305
+ state.sessionData = null;
1306
+ state.messages = [];
1307
+ renderSessionsPanel();
1308
+ renderMessagesPanel();
1309
+
1310
+ const requestId = ++sessionRequestId;
1311
+
1312
+ try {
1313
+ const sessionPayload = await fetchJson(`/api/sessions/${encodeURIComponent(sessionId)}`);
1314
+ if (requestId !== sessionRequestId || state.selectedSessionId !== sessionId) return;
1315
+
1316
+ const sessionObject = sessionPayload?.session ?? sessionPayload?.data?.session ?? sessionPayload;
1317
+ const selectedSession = getSelectedSession();
1318
+ state.sessionData = {
1319
+ id: String(sessionObject?.id ?? sessionObject?.session_id ?? sessionId),
1320
+ title: sessionObject?.title ?? sessionObject?.name ?? selectedSession?.title ?? "Untitled",
1321
+ parentId: sessionObject?.parent_id ?? sessionObject?.parentId ?? null,
1322
+ updatedAt: parseTimestamp(
1323
+ sessionObject?.time_updated ?? sessionObject?.updated_at ?? sessionObject?.time_created ?? sessionObject?.created_at
1324
+ )
1325
+ };
1326
+
1327
+ state.messages = extractMessages(sessionPayload).map((item, index) => normalizeMessage(item, index));
1328
+ } catch (error) {
1329
+ if (requestId !== sessionRequestId || state.selectedSessionId !== sessionId) return;
1330
+ state.sessionError = error instanceof Error ? error.message : String(error);
1331
+ } finally {
1332
+ if (requestId !== sessionRequestId || state.selectedSessionId !== sessionId) return;
1333
+ state.sessionLoading = false;
1334
+ renderMessagesPanel();
1335
+ }
1336
+ }
1337
+
1338
+ function downloadBlob(content, filename, mime) {
1339
+ const blob = content instanceof Blob ? content : new Blob([content], { type: mime || "text/plain" });
1340
+ const url = URL.createObjectURL(blob);
1341
+ const anchor = document.createElement("a");
1342
+ anchor.href = url;
1343
+ anchor.download = filename;
1344
+ document.body.appendChild(anchor);
1345
+ anchor.click();
1346
+ anchor.remove();
1347
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1348
+ }
1349
+
1350
+ function sessionExportBaseName() {
1351
+ const raw = state.sessionData?.title || getSelectedSession()?.title || "session";
1352
+ const slug = String(raw)
1353
+ .toLowerCase()
1354
+ .replace(/[^a-z0-9]+/g, "-")
1355
+ .replace(/^-+|-+$/g, "")
1356
+ .slice(0, 60);
1357
+ return slug || "session";
1358
+ }
1359
+
1360
+ async function exportSession(format, isPlugin) {
1361
+ if (!state.selectedSessionId || !format) return;
1362
+ const base = sessionExportBaseName();
1363
+
1364
+ try {
1365
+ if (isPlugin) {
1366
+ const plugin = (window.OCV?.listExportFormats?.() ?? []).find((item) => item.id === format);
1367
+ if (!plugin) return;
1368
+ const content = plugin.build({ session: state.sessionData, messages: state.messages, state });
1369
+ downloadBlob(content, `${base}.${plugin.extension || "txt"}`, plugin.mime || "text/plain");
1370
+ return;
1371
+ }
1372
+
1373
+ const url = `/api/sessions/${encodeURIComponent(state.selectedSessionId)}/export?format=${encodeURIComponent(format)}`;
1374
+ const response = await fetch(url);
1375
+ if (!response.ok) throw new Error(`Export failed (HTTP ${response.status})`);
1376
+ const blob = await response.blob();
1377
+ const extension = format === "json" ? "json" : "md";
1378
+ downloadBlob(blob, `${base}.${extension}`, blob.type);
1379
+ } catch (error) {
1380
+ console.error("Export failed:", error);
1381
+ alert(`Export failed: ${error instanceof Error ? error.message : String(error)}`);
1382
+ } finally {
1383
+ if (state.exportMenuOpen) {
1384
+ state.exportMenuOpen = false;
1385
+ renderMessagesPanel();
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ function runSessionAction(actionId) {
1391
+ const action = (window.OCV?.listSessionActions?.() ?? []).find((item) => item.id === actionId);
1392
+ if (!action) return;
1393
+ try {
1394
+ action.handler({ session: state.sessionData, messages: state.messages, state });
1395
+ } catch (error) {
1396
+ console.error("OCV session action failed:", error);
1397
+ }
1398
+ }
1399
+
1400
+ function clearSelectedSession() {
1401
+ state.selectedSessionId = null;
1402
+ state.sessionLoading = false;
1403
+ state.sessionError = null;
1404
+ state.sessionData = null;
1405
+ state.messages = [];
1406
+ state.sessionStack = [];
1407
+ renderSessionsPanel();
1408
+ renderMessagesPanel();
1409
+ }
1410
+
1411
+ function clearSelectedProject() {
1412
+ if (sessionsSearchDebounceTimer) {
1413
+ clearTimeout(sessionsSearchDebounceTimer);
1414
+ sessionsSearchDebounceTimer = null;
1415
+ }
1416
+ state.selectedProjectId = null;
1417
+ state.sessions = [];
1418
+ state.sessionsLoading = false;
1419
+ state.sessionsLoadingMore = false;
1420
+ state.sessionsError = null;
1421
+ state.sessionsPage = 1;
1422
+ state.sessionsHasMore = false;
1423
+ state.sessionsTotal = null;
1424
+ state.sessionsQuery = "";
1425
+ state.sessionsScope = "all";
1426
+ clearSelectedSession();
1427
+ renderProjectsPanel();
1428
+ renderSessionsPanel();
1429
+ }
1430
+
1431
+ function getMessagesScrollTop() {
1432
+ const panelBody = el.messages.querySelector(".panel-body");
1433
+ return panelBody ? panelBody.scrollTop : 0;
1434
+ }
1435
+
1436
+ function restoreMessagesScrollTop(scrollTop) {
1437
+ requestAnimationFrame(() => {
1438
+ const panelBody = el.messages.querySelector(".panel-body");
1439
+ if (!panelBody) return;
1440
+ panelBody.scrollTop = Number.isFinite(scrollTop) ? scrollTop : 0;
1441
+ });
1442
+ }
1443
+
1444
+ async function navigateBack() {
1445
+ const previous = state.sessionStack.pop();
1446
+ if (!previous?.sessionId) return;
1447
+ await loadSession(previous.sessionId);
1448
+ restoreMessagesScrollTop(previous.scrollTop);
1449
+ }
1450
+
1451
+ async function onAction(action, target) {
1452
+ if (action === "select-project") {
1453
+ const projectId = target.dataset.projectId;
1454
+ if (!projectId || projectId === state.selectedProjectId) return;
1455
+
1456
+ if (sessionsSearchDebounceTimer) {
1457
+ clearTimeout(sessionsSearchDebounceTimer);
1458
+ sessionsSearchDebounceTimer = null;
1459
+ }
1460
+ state.selectedProjectId = projectId;
1461
+ state.sessions = [];
1462
+ state.sessionsError = null;
1463
+ state.sessionsHasMore = false;
1464
+ state.sessionsQuery = "";
1465
+ state.sessionsScope = "all";
1466
+ clearSelectedSession();
1467
+ renderProjectsPanel();
1468
+ renderSessionsPanel();
1469
+ loadSessions({ reset: true });
1470
+ return;
1471
+ }
1472
+
1473
+ if (action === "select-session") {
1474
+ const sessionId = target.dataset.sessionId;
1475
+ if (!sessionId) return;
1476
+ state.sessionStack = [];
1477
+ await loadSession(sessionId);
1478
+ return;
1479
+ }
1480
+
1481
+ if (action === "navigate-subagent") {
1482
+ const sessionId = target.dataset.sessionId;
1483
+ if (!sessionId || !state.selectedSessionId || sessionId === state.selectedSessionId) return;
1484
+
1485
+ state.sessionStack.push({
1486
+ sessionId: state.selectedSessionId,
1487
+ scrollTop: getMessagesScrollTop()
1488
+ });
1489
+
1490
+ await loadSession(sessionId);
1491
+ return;
1492
+ }
1493
+
1494
+ if (action === "navigate-back") {
1495
+ if (!state.sessionStack.length) return;
1496
+ await navigateBack();
1497
+ return;
1498
+ }
1499
+
1500
+ if (action === "load-more-sessions") {
1501
+ loadSessions({ reset: false });
1502
+ return;
1503
+ }
1504
+
1505
+ if (action === "toggle-result") {
1506
+ const shortEl = document.getElementById(target.dataset.shortId || "");
1507
+ const fullEl = document.getElementById(target.dataset.fullId || "");
1508
+ if (!shortEl || !fullEl) return;
1509
+
1510
+ const expanded = target.dataset.expanded === "true";
1511
+ if (expanded) {
1512
+ shortEl.hidden = false;
1513
+ fullEl.hidden = true;
1514
+ target.dataset.expanded = "false";
1515
+ target.textContent = "Show more";
1516
+ } else {
1517
+ shortEl.hidden = true;
1518
+ fullEl.hidden = false;
1519
+ target.dataset.expanded = "true";
1520
+ target.textContent = "Show less";
1521
+ }
1522
+ return;
1523
+ }
1524
+
1525
+ if (action === "toggle-message-outline") {
1526
+ state.messageOutlineCollapsed = !state.messageOutlineCollapsed;
1527
+ renderMessagesPanel();
1528
+ return;
1529
+ }
1530
+
1531
+ if (action === "toggle-export-menu") {
1532
+ state.exportMenuOpen = !state.exportMenuOpen;
1533
+ renderMessagesPanel();
1534
+ return;
1535
+ }
1536
+
1537
+ if (action === "export-session") {
1538
+ state.exportMenuOpen = false;
1539
+ await exportSession(target.dataset.format, target.dataset.plugin === "true");
1540
+ return;
1541
+ }
1542
+
1543
+ if (action === "plugin-session-action") {
1544
+ runSessionAction(target.dataset.actionId);
1545
+ return;
1546
+ }
1547
+
1548
+ if (action === "toggle-edit-mode") {
1549
+ state.editMode = !state.editMode;
1550
+ renderProjectsPanel();
1551
+ renderSessionsPanel();
1552
+ renderBulkBar();
1553
+ return;
1554
+ }
1555
+
1556
+ if (action === "toggle-select-mode") {
1557
+ state.selectMode = !state.selectMode;
1558
+ if (!state.selectMode) {
1559
+ state.selectedProjectIds.clear();
1560
+ state.selectedSessionIds.clear();
1561
+ }
1562
+ renderProjectsPanel();
1563
+ renderSessionsPanel();
1564
+ renderBulkBar();
1565
+ return;
1566
+ }
1567
+
1568
+ if (action === "toggle-project-select") {
1569
+ const projectId = target.dataset.projectId;
1570
+ if (!projectId) return;
1571
+ if (state.selectedProjectIds.has(projectId)) state.selectedProjectIds.delete(projectId);
1572
+ else state.selectedProjectIds.add(projectId);
1573
+ renderProjectsPanel();
1574
+ renderBulkBar();
1575
+ return;
1576
+ }
1577
+
1578
+ if (action === "toggle-session-select") {
1579
+ const sessionId = target.dataset.sessionId;
1580
+ if (!sessionId) return;
1581
+ if (state.selectedSessionIds.has(sessionId)) state.selectedSessionIds.delete(sessionId);
1582
+ else state.selectedSessionIds.add(sessionId);
1583
+ renderSessionsPanel();
1584
+ renderBulkBar();
1585
+ return;
1586
+ }
1587
+
1588
+ if (action === "bulk-clear") {
1589
+ state.selectedProjectIds.clear();
1590
+ state.selectedSessionIds.clear();
1591
+ renderProjectsPanel();
1592
+ renderSessionsPanel();
1593
+ renderBulkBar();
1594
+ return;
1595
+ }
1596
+
1597
+ if (action === "bulk-run") {
1598
+ const actionDef = (window.OCV?.listBulkActions?.() ?? []).find((item) => item.id === target.dataset.bulkId);
1599
+ if (!actionDef) return;
1600
+ try {
1601
+ await actionDef.handler({
1602
+ projectIds: [...state.selectedProjectIds],
1603
+ sessionIds: [...state.selectedSessionIds],
1604
+ host: window.OCV?.host
1605
+ });
1606
+ } catch (error) {
1607
+ console.error("Bulk action failed:", error);
1608
+ alert(`Action failed: ${error instanceof Error ? error.message : String(error)}`);
1609
+ }
1610
+ return;
1611
+ }
1612
+
1613
+ if (action === "toggle-plugins") {
1614
+ state.pluginsOpen = !state.pluginsOpen;
1615
+ renderPluginsDialog();
1616
+ renderProjectsPanel();
1617
+ return;
1618
+ }
1619
+
1620
+ if (action === "close-plugins") {
1621
+ state.pluginsOpen = false;
1622
+ renderPluginsDialog();
1623
+ renderProjectsPanel();
1624
+ return;
1625
+ }
1626
+
1627
+ if (action === "scroll-message") {
1628
+ const messageId = target.dataset.messageId;
1629
+ if (!messageId) return;
1630
+
1631
+ const messageEl = document.getElementById(messageDomId(messageId));
1632
+ if (!messageEl) return;
1633
+ messageEl.scrollIntoView({ behavior: "smooth", block: "start" });
1634
+ }
1635
+ }
1636
+
1637
+ function handleSessionsSearchInput(target) {
1638
+ const trimmed = (target.value ?? "").trim();
1639
+ if (target.value !== trimmed) target.value = trimmed;
1640
+ state.sessionsQuery = trimmed;
1641
+ if (sessionsSearchDebounceTimer) clearTimeout(sessionsSearchDebounceTimer);
1642
+ if (!trimmed) {
1643
+ sessionsSearchDebounceTimer = null;
1644
+ loadSessions({ reset: true });
1645
+ return;
1646
+ }
1647
+ sessionsSearchDebounceTimer = setTimeout(() => {
1648
+ sessionsSearchDebounceTimer = null;
1649
+ loadSessions({ reset: true });
1650
+ }, 300);
1651
+ }
1652
+
1653
+ document.addEventListener("input", (event) => {
1654
+ const target = event.target;
1655
+ if (!target || target.dataset?.action !== "search-sessions") return;
1656
+ handleSessionsSearchInput(target);
1657
+ });
1658
+
1659
+ document.addEventListener("search", (event) => {
1660
+ const target = event.target;
1661
+ if (!target || target.dataset?.action !== "search-sessions") return;
1662
+ handleSessionsSearchInput(target);
1663
+ }, true);
1664
+
1665
+ document.addEventListener("change", (event) => {
1666
+ const target = event.target;
1667
+ if (!target) return;
1668
+
1669
+ if (target.dataset?.action === "toggle-plugin") {
1670
+ window.OCV?.setEnabled?.(target.dataset.pluginId, target.checked);
1671
+ return;
1672
+ }
1673
+
1674
+ if (target.dataset?.action !== "scope-sessions") return;
1675
+
1676
+ const value = target.value;
1677
+ state.sessionsScope = value === "user" || value === "assistant" ? value : "all";
1678
+ if (sessionsSearchDebounceTimer) {
1679
+ clearTimeout(sessionsSearchDebounceTimer);
1680
+ sessionsSearchDebounceTimer = null;
1681
+ }
1682
+ loadSessions({ reset: true });
1683
+ });
1684
+
1685
+ document.addEventListener("click", (event) => {
1686
+ if (event.target.classList?.contains("part-image")) {
1687
+ event.preventDefault();
1688
+ const src = event.target.src;
1689
+ if (!src) return;
1690
+
1691
+ const overlay = document.createElement("div");
1692
+ overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:20px;";
1693
+ const img = document.createElement("img");
1694
+ img.src = src;
1695
+ img.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;border-radius:8px;";
1696
+ overlay.appendChild(img);
1697
+ overlay.addEventListener("click", () => overlay.remove());
1698
+ document.body.appendChild(overlay);
1699
+ return;
1700
+ }
1701
+
1702
+ if (event.target.classList?.contains("modal-overlay")) {
1703
+ state.pluginsOpen = false;
1704
+ renderPluginsDialog();
1705
+ renderProjectsPanel();
1706
+ return;
1707
+ }
1708
+
1709
+ const target = event.target.closest("[data-action]");
1710
+ if (!target) return;
1711
+ if (
1712
+ target.dataset.action === "search-sessions" ||
1713
+ target.dataset.action === "scope-sessions" ||
1714
+ target.dataset.action === "toggle-plugin"
1715
+ ) {
1716
+ return;
1717
+ }
1718
+ event.preventDefault();
1719
+ void onAction(target.dataset.action, target);
1720
+ });
1721
+
1722
+ document.addEventListener("keydown", async (event) => {
1723
+ if (event.key !== "Escape") return;
1724
+
1725
+ const overlay = document.querySelector("div[style*='z-index:9999']");
1726
+ if (overlay) {
1727
+ overlay.remove();
1728
+ return;
1729
+ }
1730
+
1731
+ const activeEl = document.activeElement;
1732
+ if (activeEl && activeEl.dataset?.action === "search-sessions") {
1733
+ event.preventDefault();
1734
+ if (state.sessionsQuery) {
1735
+ state.sessionsQuery = "";
1736
+ activeEl.value = "";
1737
+ if (sessionsSearchDebounceTimer) {
1738
+ clearTimeout(sessionsSearchDebounceTimer);
1739
+ sessionsSearchDebounceTimer = null;
1740
+ }
1741
+ loadSessions({ reset: true });
1742
+ return;
1743
+ }
1744
+
1745
+ activeEl.blur();
1746
+ return;
1747
+ }
1748
+
1749
+ if (state.sessionStack.length > 0) {
1750
+ event.preventDefault();
1751
+ await navigateBack();
1752
+ return;
1753
+ }
1754
+
1755
+ if (state.selectedSessionId) {
1756
+ clearSelectedSession();
1757
+ return;
1758
+ }
1759
+
1760
+ if (state.selectedProjectId) {
1761
+ clearSelectedProject();
1762
+ }
1763
+ });
1764
+
1765
+ function initialRender() {
1766
+ renderProjectsPanel();
1767
+ renderSessionsPanel();
1768
+ renderMessagesPanel();
1769
+ renderBulkBar();
1770
+ renderPluginsDialog();
1771
+ }
1772
+
1773
+ if (window.OCV?.host) {
1774
+ Object.assign(window.OCV.host, {
1775
+ getProjects: () => state.projects.map((project) => ({ id: project.id, name: project.name, worktree: project.worktree })),
1776
+ getSessions: () => state.sessions.map((session) => ({ id: session.id, title: session.title, projectId: session.projectId })),
1777
+ getSelection: () => ({
1778
+ projectIds: [...state.selectedProjectIds],
1779
+ sessionIds: [...state.selectedSessionIds]
1780
+ }),
1781
+ clearSelection: () => {
1782
+ state.selectedProjectIds.clear();
1783
+ state.selectedSessionIds.clear();
1784
+ renderProjectsPanel();
1785
+ renderSessionsPanel();
1786
+ renderBulkBar();
1787
+ },
1788
+ download: (filename, content, mime) => downloadBlob(content, filename, mime),
1789
+ createZip: (entries) => window.OCV.helpers.createZip(entries),
1790
+ notify: (message) => console.log("[OCV]", message),
1791
+ refresh: () => {
1792
+ loadProjects();
1793
+ if (state.selectedProjectId) loadSessions({ reset: true });
1794
+ },
1795
+ render: () => {
1796
+ renderProjectsPanel();
1797
+ renderSessionsPanel();
1798
+ renderMessagesPanel();
1799
+ renderBulkBar();
1800
+ },
1801
+ onPluginsChanged: () => {
1802
+ if (!hasBulkActions()) {
1803
+ state.selectMode = false;
1804
+ state.selectedProjectIds.clear();
1805
+ state.selectedSessionIds.clear();
1806
+ }
1807
+ renderProjectsPanel();
1808
+ renderSessionsPanel();
1809
+ renderMessagesPanel();
1810
+ renderBulkBar();
1811
+ renderPluginsDialog();
1812
+ },
1813
+ api: {
1814
+ getProjectSessions: async (projectId) => {
1815
+ const sessions = [];
1816
+ let page = 1;
1817
+ const limit = 200;
1818
+ while (true) {
1819
+ const payload = await fetchJson(`/api/projects/${encodeURIComponent(projectId)}/sessions?page=${page}&limit=${limit}`);
1820
+ const items = Array.isArray(payload?.sessions) ? payload.sessions : [];
1821
+ sessions.push(...items);
1822
+ const total = Number(payload?.total);
1823
+ if (!Number.isFinite(total) || sessions.length >= total || items.length === 0 || page > 100) break;
1824
+ page += 1;
1825
+ }
1826
+ return sessions;
1827
+ },
1828
+ getSessionDetail: (sessionId) => fetchJson(`/api/sessions/${encodeURIComponent(sessionId)}`),
1829
+ deleteProject: async (id) => {
1830
+ const response = await fetch(`/api/projects/${encodeURIComponent(id)}`, { method: "DELETE" });
1831
+ if (!response.ok) throw new Error(`Delete project failed (HTTP ${response.status})`);
1832
+ return response.json();
1833
+ },
1834
+ deleteSession: async (id) => {
1835
+ const response = await fetch(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE" });
1836
+ if (!response.ok) throw new Error(`Delete session failed (HTTP ${response.status})`);
1837
+ return response.json();
1838
+ }
1839
+ }
1840
+ });
1841
+ }
1842
+
1843
+ async function loadPlugins() {
1844
+ try {
1845
+ const payload = await fetchJson("/api/plugins");
1846
+ const files = Array.isArray(payload?.plugins) ? payload.plugins : [];
1847
+ for (const file of files) {
1848
+ await new Promise((resolve) => {
1849
+ const script = document.createElement("script");
1850
+ script.src = `/plugins/${encodeURIComponent(file)}`;
1851
+ script.onload = resolve;
1852
+ script.onerror = resolve;
1853
+ document.head.appendChild(script);
1854
+ });
1855
+ }
1856
+ window.OCV?._activateAll?.();
1857
+ window.OCV?._markReady?.();
1858
+ } catch (error) {
1859
+ console.warn("Failed to load plugins:", error);
1860
+ }
1861
+ }
1862
+
1863
+ async function loadConfig() {
1864
+ try {
1865
+ const config = await fetchJson("/api/config");
1866
+ state.writeEnabled = Boolean(config?.write);
1867
+ } catch (error) {
1868
+ state.writeEnabled = false;
1869
+ }
1870
+ }
1871
+
1872
+ (async () => {
1873
+ await loadConfig();
1874
+ await loadPlugins();
1875
+ initialRender();
1876
+ loadProjects();
1877
+ })();