@virtengine/openfleet 0.25.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.
Files changed (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,783 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * Tab: Logs — system logs, agent log library, git snapshot
3
+ * ────────────────────────────────────────────────────────────── */
4
+ import { h } from "preact";
5
+ import {
6
+ useState,
7
+ useEffect,
8
+ useRef,
9
+ useCallback,
10
+ useMemo,
11
+ } from "preact/hooks";
12
+ import htm from "htm";
13
+
14
+ const html = htm.bind(h);
15
+
16
+ import { haptic, showAlert, getTg, openLink } from "../modules/telegram.js";
17
+ import { apiFetch, sendCommandToChat } from "../modules/api.js";
18
+ import {
19
+ logsData,
20
+ logsLines,
21
+ gitDiff,
22
+ gitBranches,
23
+ agentLogFiles,
24
+ agentLogFile,
25
+ agentLogTail,
26
+ agentLogLines,
27
+ agentLogQuery,
28
+ agentContext,
29
+ agentWorkspaceTarget,
30
+ loadLogs,
31
+ loadAgentLogFileList,
32
+ loadAgentLogTailData,
33
+ loadAgentContextData,
34
+ showToast,
35
+ scheduleRefresh,
36
+ } from "../modules/state.js";
37
+ import { navigateTo } from "../modules/router.js";
38
+ import { ICONS } from "../modules/icons.js";
39
+ import { formatBytes } from "../modules/utils.js";
40
+ import { Card, Badge, EmptyState, SkeletonCard, Modal } from "../components/shared.js";
41
+ import { SearchInput } from "../components/forms.js";
42
+
43
+ /* ─── Log level helpers ─── */
44
+ const LOG_LEVELS = [
45
+ { value: "all", label: "All" },
46
+ { value: "info", label: "Info" },
47
+ { value: "warn", label: "Warn" },
48
+ { value: "error", label: "Error" },
49
+ ];
50
+
51
+ function filterByLevel(text, level) {
52
+ if (!text || level === "all") return text;
53
+ return text
54
+ .split("\n")
55
+ .filter((line) => {
56
+ const lower = line.toLowerCase();
57
+ if (level === "error")
58
+ return (
59
+ lower.includes("error") ||
60
+ lower.includes("err") ||
61
+ lower.includes("fatal")
62
+ );
63
+ if (level === "warn")
64
+ return (
65
+ lower.includes("warn") ||
66
+ lower.includes("warning") ||
67
+ lower.includes("error") ||
68
+ lower.includes("fatal")
69
+ );
70
+ return true;
71
+ })
72
+ .join("\n");
73
+ }
74
+
75
+ /* ─── Helpers ─── */
76
+ function escapeRegex(str) {
77
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
78
+ }
79
+
80
+ function highlightLine(text, search, isRegex) {
81
+ if (!search || !search.trim()) return text;
82
+ let regex;
83
+ try {
84
+ regex = isRegex
85
+ ? new RegExp(search, "gi")
86
+ : new RegExp(escapeRegex(search), "gi");
87
+ } catch {
88
+ return text;
89
+ }
90
+ const parts = [];
91
+ let lastIndex = 0;
92
+ let match;
93
+ regex.lastIndex = 0;
94
+ while ((match = regex.exec(text)) !== null) {
95
+ if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
96
+ parts.push(html`<mark class="log-hl">${match[0]}</mark>`);
97
+ lastIndex = regex.lastIndex;
98
+ if (match[0].length === 0) {
99
+ regex.lastIndex++;
100
+ if (regex.lastIndex > text.length) break;
101
+ }
102
+ }
103
+ if (lastIndex < text.length) parts.push(text.slice(lastIndex));
104
+ return parts.length > 0 ? parts : text;
105
+ }
106
+
107
+ const LINE_HEIGHT = 20;
108
+ const SCROLL_BUFFER = 20;
109
+
110
+ function isMobileViewport() {
111
+ const tg = getTg?.();
112
+ const platform = String(tg?.platform || "").toLowerCase();
113
+ if (platform === "ios" || platform === "android" || platform === "android_x") {
114
+ return true;
115
+ }
116
+ if (typeof globalThis !== "undefined" && globalThis.matchMedia) {
117
+ return globalThis.matchMedia("(max-width: 680px)").matches;
118
+ }
119
+ return false;
120
+ }
121
+
122
+ /* ─── LogsTab ─── */
123
+ export function LogsTab() {
124
+ const logRef = useRef(null);
125
+ const tailRef = useRef(null);
126
+ const isAtBottomRef = useRef(true);
127
+
128
+ const isMobile = useMemo(() => isMobileViewport(), []);
129
+ const [localLogLines, setLocalLogLines] = useState(() => {
130
+ const base = logsLines?.value ?? 200;
131
+ return isMobile ? Math.min(base, 20) : base;
132
+ });
133
+ const [localAgentLines, setLocalAgentLines] = useState(
134
+ agentLogLines?.value ?? 200,
135
+ );
136
+ const [contextQuery, setContextQuery] = useState("");
137
+ const [logLevel, setLogLevel] = useState(isMobile ? "error" : "all");
138
+ const [logSearch, setLogSearch] = useState("");
139
+ const [autoScroll, setAutoScroll] = useState(true);
140
+ const [regexMode, setRegexMode] = useState(false);
141
+ const [logScrollTop, setLogScrollTop] = useState(0);
142
+ const [containerHeight, setContainerHeight] = useState(400);
143
+ const [branchDetail, setBranchDetail] = useState(null);
144
+ const [branchLoading, setBranchLoading] = useState(false);
145
+ const [branchError, setBranchError] = useState(null);
146
+
147
+ useEffect(() => {
148
+ if (!isMobile) return;
149
+ if (logsLines) logsLines.value = 20;
150
+ setLocalLogLines(20);
151
+ setLogLevel("error");
152
+ loadLogs();
153
+ }, [isMobile]);
154
+
155
+ const branchFileDetails = useMemo(() => {
156
+ if (!branchDetail) return [];
157
+ if (Array.isArray(branchDetail.filesChanged) && branchDetail.filesChanged.length) {
158
+ return branchDetail.filesChanged;
159
+ }
160
+ if (Array.isArray(branchDetail.filesDetailed) && branchDetail.filesDetailed.length) {
161
+ return branchDetail.filesDetailed;
162
+ }
163
+ if (Array.isArray(branchDetail.files) && branchDetail.files.length) {
164
+ return branchDetail.files.map((file) => ({ file }));
165
+ }
166
+ return [];
167
+ }, [branchDetail]);
168
+
169
+ const branchCommits = useMemo(() => {
170
+ if (!branchDetail) return [];
171
+ if (Array.isArray(branchDetail.commitList) && branchDetail.commitList.length) {
172
+ return branchDetail.commitList;
173
+ }
174
+ if (Array.isArray(branchDetail.commits) && branchDetail.commits.length) {
175
+ return branchDetail.commits;
176
+ }
177
+ return [];
178
+ }, [branchDetail]);
179
+
180
+ const workspaceLink = useMemo(() => {
181
+ if (!branchDetail) return null;
182
+ return branchDetail.workspaceLink || branchDetail.workspaceTarget || null;
183
+ }, [branchDetail]);
184
+
185
+ /* Raw log text */
186
+ const rawLogText = logsData?.value?.lines
187
+ ? logsData.value.lines.join("\n")
188
+ : "No logs yet.";
189
+
190
+ const rawTailText = agentLogTail?.value?.lines
191
+ ? agentLogTail.value.lines.join("\n")
192
+ : "Select a log file.";
193
+
194
+ /* Filtered log lines (memoized) */
195
+ const { filteredLines, matchCount } = useMemo(() => {
196
+ const leveled = filterByLevel(rawLogText, logLevel);
197
+ const allLines = leveled.split("\n");
198
+ if (!logSearch.trim()) {
199
+ return { filteredLines: allLines, matchCount: 0 };
200
+ }
201
+ let testFn;
202
+ if (regexMode) {
203
+ try {
204
+ const re = new RegExp(logSearch, "i");
205
+ testFn = (line) => re.test(line);
206
+ } catch {
207
+ testFn = (line) =>
208
+ line.toLowerCase().includes(logSearch.toLowerCase());
209
+ }
210
+ } else {
211
+ const q = logSearch.toLowerCase();
212
+ testFn = (line) => line.toLowerCase().includes(q);
213
+ }
214
+ const matched = allLines.filter(testFn);
215
+ if (matched.length === 0) {
216
+ return { filteredLines: ["No matching lines."], matchCount: 0 };
217
+ }
218
+ return { filteredLines: matched, matchCount: matched.length };
219
+ }, [rawLogText, logLevel, logSearch, regexMode]);
220
+
221
+ const filteredLogText = filteredLines.join("\n");
222
+
223
+ /* Virtual scroll calculations */
224
+ const totalLines = filteredLines.length;
225
+ const firstVisible = Math.floor(logScrollTop / LINE_HEIGHT);
226
+ const startIdx = Math.max(0, firstVisible - SCROLL_BUFFER);
227
+ const visibleCount = Math.ceil(containerHeight / LINE_HEIGHT);
228
+ const endIdx = Math.min(totalLines, firstVisible + visibleCount + SCROLL_BUFFER);
229
+ const topSpacer = startIdx * LINE_HEIGHT;
230
+ const bottomSpacer = Math.max(0, (totalLines - endIdx) * LINE_HEIGHT);
231
+ const visibleLines = filteredLines.slice(startIdx, endIdx);
232
+
233
+ /* Scroll handler */
234
+ const handleLogScroll = useCallback((e) => {
235
+ const el = e.target;
236
+ setLogScrollTop(el.scrollTop);
237
+ isAtBottomRef.current =
238
+ el.scrollTop + el.clientHeight >= el.scrollHeight - 30;
239
+ }, []);
240
+
241
+ /* Container height measurement */
242
+ useEffect(() => {
243
+ const el = logRef.current;
244
+ if (!el) return;
245
+ setContainerHeight(el.clientHeight);
246
+ if (typeof ResizeObserver !== "undefined") {
247
+ const ro = new ResizeObserver((entries) => {
248
+ for (const entry of entries)
249
+ setContainerHeight(entry.contentRect.height);
250
+ });
251
+ ro.observe(el);
252
+ return () => ro.disconnect();
253
+ }
254
+ }, []);
255
+
256
+ /* Auto-scroll */
257
+ useEffect(() => {
258
+ if (autoScroll && logRef.current) {
259
+ logRef.current.scrollTop = logRef.current.scrollHeight;
260
+ }
261
+ }, [filteredLines, autoScroll]);
262
+
263
+ useEffect(() => {
264
+ if (autoScroll && tailRef.current) {
265
+ tailRef.current.scrollTop = tailRef.current.scrollHeight;
266
+ }
267
+ }, [rawTailText, autoScroll]);
268
+
269
+ /* ── System log handlers ── */
270
+ const handleLogLinesChange = async (value) => {
271
+ setLocalLogLines(value);
272
+ if (logsLines) logsLines.value = value;
273
+ await loadLogs();
274
+ };
275
+
276
+ /* ── Agent log handlers ── */
277
+ const handleAgentSearch = async () => {
278
+ if (agentLogFile) agentLogFile.value = "";
279
+ await loadAgentLogFileList();
280
+ await loadAgentLogTailData();
281
+ };
282
+
283
+ const normalizeBranchLine = (line) => {
284
+ if (!line) return null;
285
+ const cleaned = line.replace(/^\*\s*/, "").trim();
286
+ const noRemote = cleaned.replace(/^remotes\//, "");
287
+ const short = noRemote.replace(/^origin\//, "");
288
+ return { raw: line, name: noRemote, short };
289
+ };
290
+
291
+ const openBranchDetail = async (line) => {
292
+ const parsed = normalizeBranchLine(line);
293
+ if (!parsed?.name) return;
294
+ setBranchError(null);
295
+ setBranchLoading(true);
296
+ setBranchDetail({ branch: parsed.name });
297
+ try {
298
+ const res = await apiFetch(
299
+ `/api/git/branch-detail?branch=${encodeURIComponent(parsed.name)}`,
300
+ );
301
+ setBranchDetail(res.data || null);
302
+ } catch (err) {
303
+ setBranchError(err.message || "Failed to load branch detail");
304
+ } finally {
305
+ setBranchLoading(false);
306
+ }
307
+ };
308
+
309
+ const openWorkspace = (detail) => {
310
+ if (!detail) return;
311
+ const target =
312
+ detail?.workspaceTarget ||
313
+ {
314
+ taskId: detail?.activeSlot?.taskId || detail?.worktree?.taskKey || null,
315
+ taskTitle: detail?.activeSlot?.taskTitle || detail?.branch || "Workspace",
316
+ branch: detail?.branch || null,
317
+ };
318
+ agentWorkspaceTarget.value = {
319
+ taskId: target.taskId || null,
320
+ taskTitle: target.taskTitle || detail?.branch || "Workspace",
321
+ branch: target.branch || detail?.branch || null,
322
+ };
323
+ navigateTo("agents");
324
+ };
325
+
326
+ const handleAgentOpen = async (name) => {
327
+ haptic();
328
+ if (agentLogFile) agentLogFile.value = name;
329
+ await loadAgentLogTailData();
330
+ };
331
+
332
+ const handleAgentLinesChange = async (value) => {
333
+ setLocalAgentLines(value);
334
+ if (agentLogLines) agentLogLines.value = value;
335
+ await loadAgentLogTailData();
336
+ };
337
+
338
+ /* ── Context handler ── */
339
+ const handleContextLoad = async () => {
340
+ haptic();
341
+ await loadAgentContextData(contextQuery.trim());
342
+ };
343
+
344
+ /* ── Git handler ── */
345
+ const handleGitRefresh = async () => {
346
+ haptic();
347
+ const [branches, diff] = await Promise.all([
348
+ apiFetch("/api/git/branches", { _silent: true }).catch(() => ({
349
+ data: [],
350
+ })),
351
+ apiFetch("/api/git/diff", { _silent: true }).catch(() => ({ data: "" })),
352
+ ]);
353
+ if (gitBranches) gitBranches.value = branches.data || [];
354
+ if (gitDiff) gitDiff.value = diff.data || "";
355
+ };
356
+
357
+ /* ── Copy to clipboard ── */
358
+ const copyToClipboard = async (text, label) => {
359
+ haptic();
360
+ try {
361
+ if (navigator.clipboard) {
362
+ await navigator.clipboard.writeText(text);
363
+ } else {
364
+ const ta = document.createElement("textarea");
365
+ ta.value = text;
366
+ ta.style.position = "fixed";
367
+ ta.style.left = "-9999px";
368
+ document.body.appendChild(ta);
369
+ ta.select();
370
+ document.execCommand("copy");
371
+ document.body.removeChild(ta);
372
+ }
373
+ showToast(`${label} copied`, "success");
374
+ } catch {
375
+ showToast("Copy failed", "error");
376
+ }
377
+ };
378
+
379
+ /* ── Download logs ── */
380
+ const downloadLogs = useCallback(() => {
381
+ haptic();
382
+ const blob = new Blob([filteredLogText], { type: "text/plain" });
383
+ const url = URL.createObjectURL(blob);
384
+ const a = document.createElement("a");
385
+ const d = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
386
+ a.href = url;
387
+ a.download = `openfleet-logs-${d}.log`;
388
+ document.body.appendChild(a);
389
+ a.click();
390
+ document.body.removeChild(a);
391
+ URL.revokeObjectURL(url);
392
+ showToast("Log file downloaded", "success");
393
+ }, [filteredLogText]);
394
+
395
+ return html`
396
+ <style>
397
+ .log-line { display: flex; }
398
+ .log-ln { min-width: 3.5em; text-align: right; padding-right: 8px; opacity: 0.35; user-select: none; font-size: 0.85em; }
399
+ .log-lt { flex: 1; white-space: pre-wrap; word-break: break-all; }
400
+ .log-hl { background: rgba(250,204,21,0.3); border-radius: 2px; padding: 0 1px; }
401
+ </style>
402
+ <!-- Loading skeleton -->
403
+ ${!logsData?.value && !agentLogFiles?.value && html`<${Card} title="Loading Logs…"><${SkeletonCard} /><//>`}
404
+
405
+ <!-- ── System Logs ── -->
406
+ <${Card} title="System Logs">
407
+ <div class="range-row mb-sm">
408
+ <input
409
+ type="range"
410
+ min="20"
411
+ max="800"
412
+ step="20"
413
+ value=${localLogLines}
414
+ onInput=${(e) => setLocalLogLines(Number(e.target.value))}
415
+ onChange=${(e) => handleLogLinesChange(Number(e.target.value))}
416
+ />
417
+ <span class="pill">${localLogLines} lines</span>
418
+ </div>
419
+ <div class="chip-group mb-sm">
420
+ ${[50, 200, 500].map(
421
+ (n) => html`
422
+ <button
423
+ key=${n}
424
+ class="chip ${(logsLines?.value ?? localLogLines) === n
425
+ ? "active"
426
+ : ""}"
427
+ onClick=${() => handleLogLinesChange(n)}
428
+ >
429
+ ${n}
430
+ </button>
431
+ `,
432
+ )}
433
+ </div>
434
+ <div class="chip-group mb-sm">
435
+ ${LOG_LEVELS.map(
436
+ (l) => html`
437
+ <button
438
+ key=${l.value}
439
+ class="chip chip-outline ${logLevel === l.value ? "active" : ""}"
440
+ onClick=${() => {
441
+ haptic();
442
+ setLogLevel(l.value);
443
+ }}
444
+ >
445
+ ${l.label}
446
+ </button>
447
+ `,
448
+ )}
449
+ </div>
450
+ <div class="input-row mb-sm">
451
+ <input
452
+ class="input"
453
+ placeholder=${regexMode ? "Regex pattern…" : "Search/grep logs…"}
454
+ value=${logSearch}
455
+ onInput=${(e) => setLogSearch(e.target.value)}
456
+ />
457
+ <button
458
+ class="btn btn-ghost btn-sm"
459
+ style="font-family:monospace;min-width:2.2em;padding:2px 6px;${regexMode ? "background:var(--accent);color:#fff;" : ""}"
460
+ onClick=${() => { setRegexMode(!regexMode); haptic(); }}
461
+ title="Toggle regex mode"
462
+ >.*</button>
463
+ ${logSearch.trim() && matchCount > 0 && html`<span class="pill">${matchCount} matches</span>`}
464
+ <label
465
+ class="meta-text toggle-label"
466
+ style="white-space:nowrap"
467
+ onClick=${() => {
468
+ setAutoScroll(!autoScroll);
469
+ haptic();
470
+ }}
471
+ >
472
+ <input
473
+ type="checkbox"
474
+ checked=${autoScroll}
475
+ style="accent-color:var(--accent)"
476
+ />
477
+ Auto-scroll
478
+ </label>
479
+ </div>
480
+ <div ref=${logRef} class="log-box" onScroll=${handleLogScroll} style="overflow-y:auto">
481
+ <div style="height:${topSpacer}px"></div>
482
+ ${visibleLines.map((line, i) => {
483
+ const lineNum = startIdx + i + 1;
484
+ return html`<div class="log-line" key=${lineNum} style="height:${LINE_HEIGHT}px">
485
+ <span class="log-ln">${lineNum}</span>
486
+ <span class="log-lt">${logSearch.trim() ? highlightLine(line, logSearch, regexMode) : line}</span>
487
+ </div>`;
488
+ })}
489
+ <div style="height:${bottomSpacer}px"></div>
490
+ </div>
491
+ <div class="btn-row mt-sm">
492
+ <button
493
+ class="btn btn-ghost btn-sm"
494
+ onClick=${() =>
495
+ sendCommandToChat(`/logs ${logsLines?.value ?? localLogLines}`)}
496
+ >
497
+ /logs to chat
498
+ </button>
499
+ <button
500
+ class="btn btn-ghost btn-sm"
501
+ onClick=${() => copyToClipboard(filteredLogText, "Logs")}
502
+ >
503
+ 📋 Copy
504
+ </button>
505
+ <button
506
+ class="btn btn-ghost btn-sm"
507
+ onClick=${downloadLogs}
508
+ >
509
+ 💾 Download
510
+ </button>
511
+ </div>
512
+ <//>
513
+
514
+ <!-- ── Agent Log Library ── -->
515
+ <${Card} title="Agent Log Library">
516
+ <div class="input-row mb-sm">
517
+ <input
518
+ class="input"
519
+ placeholder="Search log files"
520
+ value=${agentLogQuery?.value ?? ""}
521
+ onInput=${(e) => {
522
+ if (agentLogQuery) agentLogQuery.value = e.target.value;
523
+ }}
524
+ />
525
+ <button class="btn btn-secondary btn-sm" onClick=${handleAgentSearch}>
526
+ 🔍 Search
527
+ </button>
528
+ </div>
529
+ <div class="range-row mb-md">
530
+ <input
531
+ type="range"
532
+ min="50"
533
+ max="800"
534
+ step="50"
535
+ value=${localAgentLines}
536
+ onInput=${(e) => setLocalAgentLines(Number(e.target.value))}
537
+ onChange=${(e) => handleAgentLinesChange(Number(e.target.value))}
538
+ />
539
+ <span class="pill">${localAgentLines} lines</span>
540
+ </div>
541
+ <//>
542
+
543
+ <!-- ── Log Files list ── -->
544
+ <${Card} title="Log Files">
545
+ ${(agentLogFiles?.value || []).length
546
+ ? (agentLogFiles.value || []).map(
547
+ (file) => html`
548
+ <div
549
+ key=${file.name}
550
+ class="task-card"
551
+ style="cursor:pointer"
552
+ onClick=${() => handleAgentOpen(file.name)}
553
+ >
554
+ <div class="task-card-header">
555
+ <div>
556
+ <div class="task-card-title">${file.name}</div>
557
+ <div class="task-card-meta">
558
+ ${formatBytes
559
+ ? formatBytes(file.size)
560
+ : Math.round(file.size / 1024) + "kb"}
561
+ · ${new Date(file.mtime).toLocaleString()}
562
+ </div>
563
+ </div>
564
+ <${Badge} status="log" text="log" />
565
+ </div>
566
+ </div>
567
+ `,
568
+ )
569
+ : html`<${EmptyState} message="No log files found." />`}
570
+ <//>
571
+
572
+ <!-- ── Log Tail viewer ── -->
573
+ <${Card} title=${agentLogFile?.value || "Log Tail"}>
574
+ ${agentLogTail?.value?.truncated &&
575
+ html`<span class="pill mb-sm">Tail clipped</span>`}
576
+ <div ref=${tailRef} class="log-box">${rawTailText}</div>
577
+ <div class="btn-row mt-sm">
578
+ <button
579
+ class="btn btn-ghost btn-sm"
580
+ onClick=${() => copyToClipboard(rawTailText, "Log tail")}
581
+ >
582
+ 📋 Copy
583
+ </button>
584
+ </div>
585
+ <//>
586
+
587
+ <!-- ── Worktree Context ── -->
588
+ <${Card} title="Worktree Context">
589
+ <div class="input-row mb-sm">
590
+ <input
591
+ class="input"
592
+ placeholder="Branch fragment"
593
+ value=${contextQuery}
594
+ onInput=${(e) => setContextQuery(e.target.value)}
595
+ onKeyDown=${(e) => {
596
+ if (e.key === "Enter") handleContextLoad();
597
+ }}
598
+ />
599
+ <button class="btn btn-secondary btn-sm" onClick=${handleContextLoad}>
600
+ 📂 Load
601
+ </button>
602
+ </div>
603
+ <div class="log-box">
604
+ ${agentContext?.value
605
+ ? [
606
+ "Worktree: " + (agentContext.value.name || "?"),
607
+ "",
608
+ agentContext.value.gitLog || "No git log.",
609
+ "",
610
+ agentContext.value.gitStatus || "Clean worktree.",
611
+ "",
612
+ agentContext.value.diffStat || "No diff stat.",
613
+ ].join("\n")
614
+ : "Load a worktree context to view git log/status."}
615
+ </div>
616
+ ${agentContext?.value &&
617
+ html`
618
+ <div class="btn-row mt-sm">
619
+ <button
620
+ class="btn btn-ghost btn-sm"
621
+ onClick=${() =>
622
+ copyToClipboard(
623
+ [
624
+ agentContext.value.gitLog,
625
+ agentContext.value.gitStatus,
626
+ agentContext.value.diffStat,
627
+ ]
628
+ .filter(Boolean)
629
+ .join("\n\n"),
630
+ "Context",
631
+ )}
632
+ >
633
+ 📋 Copy
634
+ </button>
635
+ </div>
636
+ `}
637
+ <//>
638
+
639
+ <!-- ── Git Snapshot ── -->
640
+ <${Card} title="Git Snapshot">
641
+ <div class="btn-row mb-sm">
642
+ <button class="btn btn-secondary btn-sm" onClick=${handleGitRefresh}>
643
+ ${ICONS.refresh} Refresh
644
+ </button>
645
+ <button
646
+ class="btn btn-ghost btn-sm"
647
+ onClick=${() => sendCommandToChat("/diff")}
648
+ >
649
+ /diff
650
+ </button>
651
+ <button
652
+ class="btn btn-ghost btn-sm"
653
+ onClick=${() => copyToClipboard(gitDiff?.value || "", "Diff")}
654
+ >
655
+ 📋 Copy
656
+ </button>
657
+ </div>
658
+ <div class="log-box mb-md">
659
+ ${gitDiff?.value || "Clean working tree."}
660
+ </div>
661
+ <div class="card-subtitle">Recent Branches</div>
662
+ ${(gitBranches?.value || []).length
663
+ ? (gitBranches.value || []).map(
664
+ (line, i) => {
665
+ const parsed = normalizeBranchLine(line);
666
+ return html`
667
+ <button
668
+ key=${i}
669
+ class="branch-row"
670
+ onClick=${() => openBranchDetail(line)}
671
+ >
672
+ <span class="branch-name">${parsed?.short || line}</span>
673
+ <span class="branch-raw">${line}</span>
674
+ </button>
675
+ `;
676
+ },
677
+ )
678
+ : html`<div class="meta-text">No branches found.</div>`}
679
+ <//>
680
+
681
+ ${branchDetail &&
682
+ html`
683
+ <${Modal} title="Branch Detail" onClose=${() => setBranchDetail(null)}>
684
+ ${branchLoading && html`<${SkeletonCard} height="80px" />`}
685
+ ${branchError && html`<div class="meta-text" style="color:var(--color-error)">${branchError}</div>`}
686
+ ${!branchLoading &&
687
+ !branchError &&
688
+ html`
689
+ <div class="meta-text mb-sm">
690
+ Branch: <span class="mono">${branchDetail.branch}</span>
691
+ </div>
692
+ ${branchDetail.base &&
693
+ html`<div class="meta-text mb-sm">Base: ${branchDetail.base}</div>`}
694
+ ${branchDetail.activeSlot &&
695
+ html`<div class="meta-text mb-sm">Active Agent: ${branchDetail.activeSlot.taskTitle || branchDetail.activeSlot.taskId}</div>`}
696
+ ${branchDetail.worktree?.path &&
697
+ html`<div class="meta-text mb-sm">Worktree: <span class="mono">${branchDetail.worktree.path}</span></div>`}
698
+ <div class="btn-row mb-sm">
699
+ ${(branchDetail.workspaceTarget || branchDetail.activeSlot || branchDetail.worktree) &&
700
+ html`<button class="btn btn-primary btn-sm" onClick=${() => openWorkspace(branchDetail)}>
701
+ 🔍 Open Workspace Viewer
702
+ </button>`}
703
+ ${branchDetail.workspaceLink?.url &&
704
+ html`<button
705
+ class="btn btn-secondary btn-sm"
706
+ onClick=${() => openLink(branchDetail.workspaceLink.url)}
707
+ >
708
+ 🔗 Open Workspace Link
709
+ </button>`}
710
+ <button
711
+ class="btn btn-ghost btn-sm"
712
+ onClick=${() => copyToClipboard(branchDetail.diffStat || "", "Diff")}
713
+ >📋 Copy Diff</button>
714
+ </div>
715
+ ${workspaceLink &&
716
+ html`
717
+ <div class="meta-text mb-sm">
718
+ Workspace: ${workspaceLink.label || workspaceLink.taskTitle || workspaceLink.branch || "Active"}
719
+ ${(workspaceLink.target?.workspacePath || workspaceLink.workspacePath)
720
+ ? html`<span class="mono"> · ${workspaceLink.target?.workspacePath || workspaceLink.workspacePath}</span>`
721
+ : ""}
722
+ </div>
723
+ `}
724
+ ${branchDetail.diffSummary &&
725
+ html`
726
+ <div class="meta-text mb-sm">
727
+ Diff: ${branchDetail.diffSummary.totalFiles || 0} files ·
728
+ +${branchDetail.diffSummary.totalAdditions || 0} ·
729
+ -${branchDetail.diffSummary.totalDeletions || 0}
730
+ ${branchDetail.diffSummary.binaryFiles ? `· ${branchDetail.diffSummary.binaryFiles} binary` : ""}
731
+ </div>
732
+ `}
733
+ ${branchCommits.length > 0 &&
734
+ html`
735
+ <div class="card mb-sm">
736
+ <div class="card-title">Commits</div>
737
+ ${branchCommits.map((cm) => {
738
+ const subject = cm.subject || cm.message || "";
739
+ const author =
740
+ cm.author ||
741
+ (cm.authorName && cm.authorEmail
742
+ ? `${cm.authorName} <${cm.authorEmail}>`
743
+ : cm.authorName || cm.authorEmail || "");
744
+ const dateVal = cm.authorDate || cm.date || cm.time;
745
+ return html`
746
+ <div class="meta-text" key=${cm.hash}>
747
+ <span class="mono">${cm.hash}</span> ${subject}
748
+ ${author ? `· ${author}` : ""}
749
+ ${dateVal ? `· ${new Date(dateVal).toLocaleString()}` : ""}
750
+ </div>
751
+ `;
752
+ })}
753
+ </div>
754
+ `}
755
+ <div class="card mb-sm">
756
+ <div class="card-title">Files Changed</div>
757
+ ${branchFileDetails.length
758
+ ? branchFileDetails.map(
759
+ (f) => html`
760
+ <div class="meta-text" key=${f.file}>
761
+ <span class="mono">${f.file}</span>
762
+ ${typeof f.additions === "number" &&
763
+ html`<span class="pill" style="margin-left:6px">+${f.additions}</span>`}
764
+ ${typeof f.deletions === "number" &&
765
+ html`<span class="pill" style="margin-left:6px">-${f.deletions}</span>`}
766
+ ${f.binary && html`<span class="pill" style="margin-left:6px">binary</span>`}
767
+ </div>
768
+ `,
769
+ )
770
+ : html`<div class="meta-text">No diff against base.</div>`}
771
+ </div>
772
+ ${branchDetail.diffStat &&
773
+ html`
774
+ <div class="card">
775
+ <div class="card-title">Diff Summary</div>
776
+ <pre class="workspace-diff">${branchDetail.diffStat}</pre>
777
+ </div>
778
+ `}
779
+ `}
780
+ <//>
781
+ `}
782
+ `;
783
+ }