@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,1417 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * Tab: Agents — thread/slot cards, capacity, detail expansion
3
+ * ────────────────────────────────────────────────────────────── */
4
+ import { h } from "preact";
5
+ import { useState, useCallback, useEffect, useRef } from "preact/hooks";
6
+ import htm from "htm";
7
+
8
+ const html = htm.bind(h);
9
+
10
+ import { haptic, showConfirm } from "../modules/telegram.js";
11
+ import { apiFetch, sendCommandToChat } from "../modules/api.js";
12
+ import {
13
+ executorData,
14
+ agentsData,
15
+ agentLogQuery,
16
+ agentLogFile,
17
+ agentWorkspaceTarget,
18
+ showToast,
19
+ refreshTab,
20
+ scheduleRefresh,
21
+ } from "../modules/state.js";
22
+ import { navigateTo } from "../modules/router.js";
23
+ import { ICONS } from "../modules/icons.js";
24
+ import { formatRelative, truncate } from "../modules/utils.js";
25
+ import {
26
+ Card,
27
+ Badge,
28
+ StatCard,
29
+ SkeletonCard,
30
+ EmptyState,
31
+ } from "../components/shared.js";
32
+ import { ProgressBar } from "../components/charts.js";
33
+ import { Collapsible } from "../components/forms.js";
34
+ import {
35
+ SessionList,
36
+ loadSessions,
37
+ loadSessionMessages,
38
+ selectedSessionId,
39
+ sessionsData,
40
+ sessionMessages,
41
+ } from "../components/session-list.js";
42
+ import { ChatView } from "../components/chat-view.js";
43
+ import { DiffViewer } from "../components/diff-viewer.js";
44
+
45
+ /* ─── Status indicator helpers ─── */
46
+ function statusColor(s) {
47
+ const map = {
48
+ idle: "var(--color-todo)",
49
+ busy: "var(--color-inprogress)",
50
+ running: "var(--color-inprogress)",
51
+ error: "var(--color-error)",
52
+ done: "var(--color-done)",
53
+ };
54
+ return map[(s || "").toLowerCase()] || "var(--text-secondary)";
55
+ }
56
+
57
+ function StatusDot({ status }) {
58
+ return html`<span
59
+ class="status-dot"
60
+ style="background:${statusColor(status)}"
61
+ ></span>`;
62
+ }
63
+
64
+ /* ─── Duration formatting ─── */
65
+ function formatDuration(startedAt) {
66
+ if (!startedAt) return "";
67
+ const sec = Math.round((Date.now() - new Date(startedAt).getTime()) / 1000);
68
+ if (sec < 60) return `${sec}s`;
69
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
70
+ return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
71
+ }
72
+
73
+ /* ─── Workspace Viewer Modal ─── */
74
+ function WorkspaceViewer({ agent, onClose }) {
75
+ const [logText, setLogText] = useState("Loading…");
76
+ const [contextData, setContextData] = useState(null);
77
+ const [steerInput, setSteerInput] = useState("");
78
+ const [activeTab, setActiveTab] = useState("stream");
79
+ const [streamPaused, setStreamPaused] = useState(false);
80
+ const [streamFilter, setStreamFilter] = useState("all");
81
+ const [streamSearch, setStreamSearch] = useState("");
82
+ const [fileFilter, setFileFilter] = useState("all");
83
+ const [fileSearch, setFileSearch] = useState("");
84
+ const [streamSnapshot, setStreamSnapshot] = useState({
85
+ events: [],
86
+ fileAccess: null,
87
+ capturedAt: null,
88
+ });
89
+ const logRef = useRef(null);
90
+
91
+ const query = agent.branch || agent.taskId || agent.sessionId || "";
92
+ const sessionId =
93
+ contextData?.session?.id || agent.taskId || agent.sessionId || null;
94
+
95
+ useEffect(() => {
96
+ if (!query) return;
97
+ let active = true;
98
+
99
+ const fetchLogs = () => {
100
+ apiFetch(`/api/agent-logs/tail?query=${encodeURIComponent(query)}&lines=200`, { _silent: true })
101
+ .then((res) => {
102
+ if (!active) return;
103
+ const data = res.data ?? res ?? "";
104
+ const content =
105
+ typeof data === "string"
106
+ ? data
107
+ : data?.content || data?.lines || data?.data || "";
108
+ const text = Array.isArray(content)
109
+ ? content.join("\n")
110
+ : content || "";
111
+ setLogText(text || "(no logs yet)");
112
+ if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
113
+ })
114
+ .catch(() => { if (active) setLogText("(failed to load logs)"); });
115
+ };
116
+
117
+ const fetchContext = () => {
118
+ apiFetch(`/api/agent-context?query=${encodeURIComponent(query)}`, { _silent: true })
119
+ .then((res) => { if (active) setContextData(res.data ?? res ?? null); })
120
+ .catch(() => {});
121
+ };
122
+
123
+ fetchLogs();
124
+ fetchContext();
125
+ const interval = setInterval(() => {
126
+ fetchLogs();
127
+ fetchContext();
128
+ }, 5000);
129
+ return () => { active = false; clearInterval(interval); };
130
+ }, [query]);
131
+
132
+ useEffect(() => {
133
+ if (!sessionId) return;
134
+ let active = true;
135
+ const fetchSession = () => {
136
+ if (!active) return;
137
+ loadSessionMessages(sessionId);
138
+ };
139
+ fetchSession();
140
+ const interval = setInterval(fetchSession, 4000);
141
+ return () => {
142
+ active = false;
143
+ clearInterval(interval);
144
+ };
145
+ }, [sessionId]);
146
+
147
+ useEffect(() => {
148
+ setStreamPaused(false);
149
+ setStreamFilter("all");
150
+ setStreamSearch("");
151
+ setFileFilter("all");
152
+ setFileSearch("");
153
+ setStreamSnapshot({ events: [], fileAccess: null, capturedAt: null });
154
+ }, [query]);
155
+
156
+ const handleStop = async () => {
157
+ if (agent.index == null) return;
158
+ const ok = await showConfirm(`Force-stop agent on "${truncate(agent.taskTitle || agent.taskId || "task", 40)}"?`);
159
+ if (!ok) return;
160
+ haptic("heavy");
161
+ try {
162
+ await apiFetch("/api/executor/stop-slot", {
163
+ method: "POST",
164
+ body: JSON.stringify({ slotIndex: agent.index, taskId: agent.taskId }),
165
+ });
166
+ showToast("Stop signal sent", "success");
167
+ onClose();
168
+ scheduleRefresh(200);
169
+ } catch { /* toast via apiFetch */ }
170
+ };
171
+
172
+ const handleSteer = () => {
173
+ if (!steerInput.trim()) return;
174
+ sendCommandToChat(`/steer ${steerInput.trim()}`);
175
+ showToast("Steer command sent", "success");
176
+ setSteerInput("");
177
+ };
178
+
179
+ const copyToClipboard = (text, successMessage) => {
180
+ if (!text) return;
181
+ if (!navigator?.clipboard?.writeText) {
182
+ showToast("Clipboard unavailable", "error");
183
+ return;
184
+ }
185
+ navigator.clipboard
186
+ .writeText(text)
187
+ .then(() => showToast(successMessage || "Copied", "success"))
188
+ .catch(() => showToast("Copy failed", "error"));
189
+ };
190
+
191
+ const downloadText = (filename, content) => {
192
+ if (!content) return;
193
+ const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
194
+ const url = URL.createObjectURL(blob);
195
+ const link = document.createElement("a");
196
+ link.href = url;
197
+ link.download = filename;
198
+ document.body.appendChild(link);
199
+ link.click();
200
+ link.remove();
201
+ setTimeout(() => URL.revokeObjectURL(url), 1500);
202
+ };
203
+
204
+ const renderChanges = () => {
205
+ const ctx = contextData?.context;
206
+ const matches = contextData?.matches || {};
207
+ const diagnostics = contextData?.diagnostics || {};
208
+ const sessionInfo = contextData?.session || null;
209
+ const slotInfo = contextData?.slot || null;
210
+ const actionHistory = contextData?.actionHistory || contextData?.toolHistory || [];
211
+ const fileAccess = contextData?.fileAccessSummary || null;
212
+ const fileAccessFiles = fileAccess?.files || [];
213
+ const streamMessages = sessionMessages.value || [];
214
+ const rawToolEvents = streamMessages
215
+ .filter((msg) => msg?.type === "tool_call" || msg?.type === "tool_result" || msg?.type === "error")
216
+ .slice(-200)
217
+ .map((evt, index) => ({
218
+ ...evt,
219
+ _id: evt?.id || `${evt?.type || "evt"}-${evt?.timestamp || evt?.createdAt || index}`,
220
+ }));
221
+ const liveToolEvents = rawToolEvents.slice().reverse();
222
+ const liveFileAccess = (() => {
223
+ const map = new Map();
224
+ const counts = { read: 0, write: 0, other: 0 };
225
+ const filePattern = /([a-zA-Z0-9_./-]+\.(?:js|mjs|cjs|ts|tsx|jsx|json|md|mdx|css|scss|less|html|yml|yaml|toml|env|lock|go|rs|py|sh|ps1|psm1|txt|sql))/g;
226
+ const classify = (detail) => {
227
+ const lowered = String(detail || "").toLowerCase();
228
+ if (lowered.includes("apply_patch") || lowered.includes("write")) return "write";
229
+ if (/\b(rg|cat|sed|ls|stat|head|tail|grep|find)\b/.test(lowered)) return "read";
230
+ return "other";
231
+ };
232
+ const addFile = (path, kind) => {
233
+ if (!path) return;
234
+ const entry = map.get(path) || { path, kinds: new Set() };
235
+ if (!entry.kinds.has(kind)) {
236
+ entry.kinds.add(kind);
237
+ if (counts[kind] != null) counts[kind] += 1;
238
+ else counts.other += 1;
239
+ }
240
+ map.set(path, entry);
241
+ };
242
+ for (const evt of rawToolEvents) {
243
+ if (!evt?.content) continue;
244
+ const kind = classify(evt.content);
245
+ const matches = evt.content.matchAll(filePattern);
246
+ for (const match of matches) {
247
+ addFile(match?.[1], kind);
248
+ }
249
+ }
250
+ for (const changed of fileAccessFiles) {
251
+ if (changed?.path) addFile(changed.path, "write");
252
+ }
253
+ if (map.size === 0) return null;
254
+ return {
255
+ files: Array.from(map.values()).map((entry) => ({
256
+ path: entry.path,
257
+ kinds: Array.from(entry.kinds),
258
+ })),
259
+ counts,
260
+ };
261
+ })();
262
+ const toolEvents = streamPaused ? streamSnapshot.events : liveToolEvents;
263
+ const snapshotMeta = streamPaused ? streamSnapshot.capturedAt : null;
264
+
265
+ const formatReason = (reason) => {
266
+ const map = {
267
+ "no-matching-slots-or-worktrees": "No matching active slots or worktrees found.",
268
+ "no-session-match": "No matching session found.",
269
+ "no-worktree-match": "No matching worktree found.",
270
+ "worktree-path-missing": "Matched worktree path missing or inaccessible.",
271
+ };
272
+ return map[reason] || reason;
273
+ };
274
+
275
+ const renderMatchList = (title, items, renderItem) => {
276
+ if (!items || items.length === 0) return null;
277
+ return html`
278
+ <div class="card mb-sm">
279
+ <div class="card-title">${title}</div>
280
+ ${items.map((item, i) => html`
281
+ <div class="meta-text" key=${i}>
282
+ ${renderItem(item)}
283
+ </div>
284
+ `)}
285
+ </div>
286
+ `;
287
+ };
288
+
289
+ const hasMatches =
290
+ (matches.worktrees && matches.worktrees.length > 0) ||
291
+ (matches.slots && matches.slots.length > 0) ||
292
+ (matches.sessions && matches.sessions.length > 0);
293
+ const hasActions = actionHistory.length > 0 || toolEvents.length > 0;
294
+ const hasFileAccess = fileAccessFiles.length > 0 || liveFileAccess?.files?.length > 0;
295
+ const showUnavailable =
296
+ !ctx &&
297
+ !sessionInfo &&
298
+ !slotInfo &&
299
+ !hasMatches &&
300
+ !hasActions &&
301
+ !hasFileAccess;
302
+
303
+ if (showUnavailable) {
304
+ const reasons = diagnostics?.reasons || [];
305
+ const hints = diagnostics?.hints || [];
306
+ return html`
307
+ <div class="workspace-context">
308
+ <div class="card mb-sm">
309
+ <div class="card-title">Workspace Context Unavailable</div>
310
+ <div class="meta-text">Query: ${contextData?.query || query || "unknown"}</div>
311
+ ${reasons.length > 0 &&
312
+ html`<div class="meta-text mt-xs">
313
+ ${reasons.map((r) => formatReason(r)).join(" ")}
314
+ </div>`}
315
+ ${hints.length > 0 &&
316
+ html`<div class="meta-text mt-xs">
317
+ ${hints.join(" ")}
318
+ </div>`}
319
+ ${(diagnostics?.searched?.activeSlots != null ||
320
+ diagnostics?.searched?.activeWorktrees != null ||
321
+ diagnostics?.searched?.sessions != null) &&
322
+ html`<div class="meta-text mt-xs">
323
+ Searched: ${diagnostics?.searched?.activeSlots ?? 0} slots · ${diagnostics?.searched?.activeWorktrees ?? 0} worktrees · ${diagnostics?.searched?.sessions ?? 0} sessions
324
+ </div>`}
325
+ </div>
326
+
327
+ ${renderMatchList("Worktree Matches", matches.worktrees, (wt) =>
328
+ html`<span class="mono">${wt.name || wt.branch || "worktree"}</span> ${wt.path ? `· ${wt.path}` : ""}`)}
329
+ ${renderMatchList("Slot Matches", matches.slots, (slot) =>
330
+ html`<span class="mono">${slot.taskId || slot.taskTitle || "slot"}</span> ${slot.branch ? `· ${slot.branch}` : ""}`)}
331
+ ${renderMatchList("Session Matches", matches.sessions, (sess) =>
332
+ html`<span class="mono">${sess.id || sess.taskId || "session"}</span> ${sess.status ? `· ${sess.status}` : ""}`)}
333
+
334
+ ${sessionInfo && html`
335
+ <div class="card mb-sm">
336
+ <div class="card-title">Session</div>
337
+ <div class="meta-text">
338
+ <span class="mono">${sessionInfo.id || sessionInfo.taskId}</span>
339
+ ${sessionInfo.status ? ` · ${sessionInfo.status}` : ""}
340
+ </div>
341
+ ${sessionInfo.preview &&
342
+ html`<div class="meta-text mt-xs">${truncate(sessionInfo.preview, 120)}</div>`}
343
+ <button class="btn btn-ghost btn-sm mt-sm" onClick=${() => setActiveTab("stream")}>
344
+ 💬 View Stream
345
+ </button>
346
+ </div>
347
+ `}
348
+ </div>
349
+ `;
350
+ }
351
+
352
+ const renderActionHistory = () => {
353
+ if (!sessionInfo && actionHistory.length === 0) return null;
354
+ return html`
355
+ <div class="card mb-sm">
356
+ <div class="card-title">Action History</div>
357
+ ${actionHistory.length === 0 &&
358
+ html`<div class="meta-text">No recent tool actions recorded</div>`}
359
+ ${actionHistory.map((action, i) => {
360
+ const label =
361
+ action.type === "tool_result"
362
+ ? "RESULT"
363
+ : action.tool || "TOOL";
364
+ const detail = action.detail || action.content || "";
365
+ return html`
366
+ <div class="meta-text" key=${i}>
367
+ <span class="mono">${label}</span>
368
+ ${detail ? ` ${truncate(detail, 140)}` : ""}
369
+ ${action.timestamp ? ` · ${formatRelative(action.timestamp)}` : ""}
370
+ </div>
371
+ `;
372
+ })}
373
+ </div>
374
+ `;
375
+ };
376
+
377
+ const renderLiveToolEvents = () => {
378
+ if (!sessionInfo && toolEvents.length === 0) return null;
379
+ const counts = toolEvents.reduce(
380
+ (acc, evt) => {
381
+ acc.all += 1;
382
+ if (evt.type === "tool_result") acc.result += 1;
383
+ else if (evt.type === "error") acc.error += 1;
384
+ else acc.tool += 1;
385
+ return acc;
386
+ },
387
+ { all: 0, tool: 0, result: 0, error: 0 },
388
+ );
389
+ const search = streamSearch.trim().toLowerCase();
390
+ const filteredEvents = toolEvents.filter((evt) => {
391
+ if (streamFilter !== "all") {
392
+ if (streamFilter === "tool" && evt.type !== "tool_call") return false;
393
+ if (streamFilter === "result" && evt.type !== "tool_result") return false;
394
+ if (streamFilter === "error" && evt.type !== "error") return false;
395
+ }
396
+ if (!search) return true;
397
+ const haystack = `${evt.tool || ""} ${evt.content || ""} ${evt.detail || ""}`
398
+ .toLowerCase();
399
+ return haystack.includes(search);
400
+ });
401
+ const exportText = filteredEvents.map((evt) => {
402
+ const label = evt.type === "tool_result" ? "RESULT" : evt.type === "error" ? "ERROR" : "TOOL";
403
+ const ts = evt.timestamp || evt.createdAt || "";
404
+ const tool = evt.tool ? ` ${evt.tool}` : "";
405
+ const body = evt.content || evt.detail || "";
406
+ return `${ts ? `[${ts}] ` : ""}${label}${tool} ${body}`.trim();
407
+ }).join("\n");
408
+ const toolLabel = (type) => (
409
+ type === "tool_result" ? "RESULT" : type === "error" ? "ERROR" : "TOOL"
410
+ );
411
+ return html`
412
+ <div class="card mb-sm">
413
+ <div class="card-title">Live Tool/Event Stream</div>
414
+ <div class="stream-toolbar">
415
+ <div class="chip-group stream-chips">
416
+ <button
417
+ class="chip ${streamFilter === "all" ? "active" : ""}"
418
+ onClick=${() => setStreamFilter("all")}
419
+ >
420
+ All (${counts.all})
421
+ </button>
422
+ <button
423
+ class="chip ${streamFilter === "tool" ? "active" : ""}"
424
+ onClick=${() => setStreamFilter("tool")}
425
+ >
426
+ Tool (${counts.tool})
427
+ </button>
428
+ <button
429
+ class="chip ${streamFilter === "result" ? "active" : ""}"
430
+ onClick=${() => setStreamFilter("result")}
431
+ >
432
+ Result (${counts.result})
433
+ </button>
434
+ <button
435
+ class="chip ${streamFilter === "error" ? "active" : ""}"
436
+ onClick=${() => setStreamFilter("error")}
437
+ >
438
+ Error (${counts.error})
439
+ </button>
440
+ </div>
441
+ <div class="stream-actions">
442
+ <div class="stream-search">
443
+ <span class="icon-inline">${ICONS.search}</span>
444
+ <input
445
+ class="input input-compact"
446
+ placeholder="Filter events..."
447
+ value=${streamSearch}
448
+ onInput=${(e) => setStreamSearch(e.target.value)}
449
+ />
450
+ </div>
451
+ <button class="btn btn-ghost btn-sm" onClick=${() => {
452
+ if (!streamPaused) {
453
+ setStreamSnapshot({
454
+ events: liveToolEvents,
455
+ fileAccess: liveFileAccess,
456
+ capturedAt: new Date().toISOString(),
457
+ });
458
+ setStreamPaused(true);
459
+ } else {
460
+ setStreamPaused(false);
461
+ setStreamSnapshot({ events: [], fileAccess: null, capturedAt: null });
462
+ }
463
+ }}>
464
+ ${streamPaused ? "▶ Resume" : "⏸ Pause"}
465
+ </button>
466
+ <button
467
+ class="btn btn-ghost btn-sm"
468
+ onClick=${() => copyToClipboard(exportText, "Stream copied")}
469
+ disabled=${filteredEvents.length === 0}
470
+ >
471
+ <span class="icon-inline">${ICONS.copy}</span> Copy
472
+ </button>
473
+ <button
474
+ class="btn btn-ghost btn-sm"
475
+ onClick=${() => downloadText(
476
+ `tool-stream-${agent.taskId || agent.branch || "agent"}.txt`,
477
+ exportText,
478
+ )}
479
+ disabled=${filteredEvents.length === 0}
480
+ >
481
+ <span class="icon-inline">${ICONS.download}</span> Export
482
+ </button>
483
+ </div>
484
+ </div>
485
+ ${streamPaused && snapshotMeta &&
486
+ html`<div class="meta-text mt-xs">Paused at ${snapshotMeta}</div>`}
487
+ ${filteredEvents.length === 0 &&
488
+ html`<div class="stream-empty">
489
+ <div class="stream-empty-icon">🛰️</div>
490
+ <div class="stream-empty-text">
491
+ ${toolEvents.length === 0 ? "No tool events yet" : "No events match filters"}
492
+ </div>
493
+ </div>`}
494
+ ${filteredEvents.length > 0 &&
495
+ html`<div class="stream-list">
496
+ ${filteredEvents.map((evt) => html`
497
+ <div class="stream-item stream-${evt.type}" key=${evt._id}>
498
+ <div class="stream-item-header">
499
+ <span class="stream-tag stream-tag-${evt.type}">
500
+ ${toolLabel(evt.type)}
501
+ </span>
502
+ ${evt.tool && html`<span class="stream-item-tool mono">${evt.tool}</span>`}
503
+ ${evt.timestamp && html`<span class="stream-item-time">${formatRelative(evt.timestamp)}</span>`}
504
+ </div>
505
+ ${evt.content && html`<div class="stream-item-body">${truncate(evt.content, 260)}</div>`}
506
+ </div>
507
+ `)}
508
+ </div>`}
509
+ </div>
510
+ `;
511
+ };
512
+
513
+ const renderFileAccess = () => {
514
+ if (!sessionInfo && !fileAccess && !liveFileAccess) return null;
515
+ const summarySource = (streamPaused ? streamSnapshot.fileAccess : liveFileAccess) || fileAccess;
516
+ const summaryCounts = summarySource?.counts || {};
517
+ const summaryFiles = summarySource?.files || [];
518
+ const counts = {
519
+ read: summaryCounts.read ?? summaryFiles.filter((f) => f.kinds?.includes("read")).length,
520
+ write: summaryCounts.write ?? summaryFiles.filter((f) => f.kinds?.includes("write")).length,
521
+ other: summaryCounts.other ?? summaryFiles.filter((f) => f.kinds?.includes("other")).length,
522
+ };
523
+ const search = fileSearch.trim().toLowerCase();
524
+ const filteredFiles = summaryFiles.filter((entry) => {
525
+ if (fileFilter !== "all" && !entry.kinds?.includes(fileFilter)) return false;
526
+ if (!search) return true;
527
+ return entry.path?.toLowerCase().includes(search);
528
+ });
529
+ const exportText = filteredFiles.map((entry) => {
530
+ const kinds = entry.kinds?.length ? ` (${entry.kinds.join(", ")})` : "";
531
+ return `${entry.path}${kinds}`;
532
+ }).join("\n");
533
+ return html`
534
+ <div class="card mb-sm">
535
+ <div class="card-title">File Access</div>
536
+ <div class="stream-toolbar">
537
+ <div class="chip-group stream-chips">
538
+ <button
539
+ class="chip ${fileFilter === "all" ? "active" : ""}"
540
+ onClick=${() => setFileFilter("all")}
541
+ >
542
+ All (${summaryFiles.length})
543
+ </button>
544
+ <button
545
+ class="chip ${fileFilter === "read" ? "active" : ""}"
546
+ onClick=${() => setFileFilter("read")}
547
+ >
548
+ Read (${counts.read})
549
+ </button>
550
+ <button
551
+ class="chip ${fileFilter === "write" ? "active" : ""}"
552
+ onClick=${() => setFileFilter("write")}
553
+ >
554
+ Write (${counts.write})
555
+ </button>
556
+ <button
557
+ class="chip ${fileFilter === "other" ? "active" : ""}"
558
+ onClick=${() => setFileFilter("other")}
559
+ >
560
+ Other (${counts.other})
561
+ </button>
562
+ </div>
563
+ <div class="stream-actions">
564
+ <div class="stream-search">
565
+ <span class="icon-inline">${ICONS.search}</span>
566
+ <input
567
+ class="input input-compact"
568
+ placeholder="Filter files..."
569
+ value=${fileSearch}
570
+ onInput=${(e) => setFileSearch(e.target.value)}
571
+ />
572
+ </div>
573
+ <button
574
+ class="btn btn-ghost btn-sm"
575
+ onClick=${() => copyToClipboard(exportText, "File list copied")}
576
+ disabled=${filteredFiles.length === 0}
577
+ >
578
+ <span class="icon-inline">${ICONS.copy}</span> Copy
579
+ </button>
580
+ <button
581
+ class="btn btn-ghost btn-sm"
582
+ onClick=${() => downloadText(
583
+ `file-access-${agent.taskId || agent.branch || "agent"}.txt`,
584
+ exportText,
585
+ )}
586
+ disabled=${filteredFiles.length === 0}
587
+ >
588
+ <span class="icon-inline">${ICONS.download}</span> Export
589
+ </button>
590
+ </div>
591
+ </div>
592
+ <div class="meta-text">
593
+ ${counts.read} read · ${counts.write} written · ${counts.other} other
594
+ </div>
595
+ ${streamPaused && snapshotMeta &&
596
+ html`<div class="meta-text mt-xs">Paused at ${snapshotMeta}</div>`}
597
+ ${filteredFiles.length === 0 &&
598
+ html`<div class="stream-empty">
599
+ <div class="stream-empty-icon">📂</div>
600
+ <div class="stream-empty-text">
601
+ ${summaryFiles.length === 0 ? "No file access recorded" : "No files match filters"}
602
+ </div>
603
+ </div>`}
604
+ ${filteredFiles.length > 0 &&
605
+ html`<div class="stream-list">
606
+ ${filteredFiles.map((entry) => html`
607
+ <div class="stream-item stream-file" key=${entry.path}>
608
+ <div class="stream-item-header">
609
+ <span class="stream-tag stream-tag-file">FILE</span>
610
+ <span class="mono">${entry.path}</span>
611
+ </div>
612
+ ${entry.kinds?.length &&
613
+ html`<div class="stream-item-body">Access: ${entry.kinds.join(", ")}</div>`}
614
+ </div>
615
+ `)}
616
+ </div>`}
617
+ </div>
618
+ `;
619
+ };
620
+
621
+ const files = ctx?.changedFiles || [];
622
+ const commits = ctx?.recentCommits || [];
623
+ const aheadBehind = ctx?.gitAheadBehind || "";
624
+ return html`
625
+ <div class="workspace-context">
626
+ ${ctx && html`
627
+ <div class="card mb-sm">
628
+ <div class="card-title">Branch</div>
629
+ <div class="meta-text">${ctx.gitBranch || agent.branch || "unknown"}</div>
630
+ <div class="meta-text mt-xs">${ctx.path || "unknown path"}</div>
631
+ ${aheadBehind &&
632
+ html`<div class="meta-text mt-xs">Ahead/Behind: ${aheadBehind}</div>`}
633
+ </div>
634
+ `}
635
+ ${sessionInfo && html`
636
+ <div class="card mb-sm">
637
+ <div class="card-title">Session</div>
638
+ <div class="meta-text">
639
+ <span class="mono">${sessionInfo.id || sessionInfo.taskId}</span>
640
+ ${sessionInfo.status ? ` · ${sessionInfo.status}` : ""}
641
+ </div>
642
+ ${sessionInfo.lastActiveAt &&
643
+ html`<div class="meta-text mt-xs">Last Active: ${sessionInfo.lastActiveAt}</div>`}
644
+ ${sessionInfo.preview &&
645
+ html`<div class="meta-text mt-xs">${truncate(sessionInfo.preview, 140)}</div>`}
646
+ <button class="btn btn-ghost btn-sm mt-sm" onClick=${() => setActiveTab("stream")}>
647
+ 💬 View Stream
648
+ </button>
649
+ </div>
650
+ `}
651
+ ${slotInfo && html`
652
+ <div class="card mb-sm">
653
+ <div class="card-title">Active Slot</div>
654
+ <div class="meta-text">
655
+ ${slotInfo.taskTitle || slotInfo.taskId || "slot"}
656
+ ${slotInfo.status ? ` · ${slotInfo.status}` : ""}
657
+ </div>
658
+ ${slotInfo.branch &&
659
+ html`<div class="meta-text mt-xs">Branch: ${slotInfo.branch}</div>`}
660
+ </div>
661
+ `}
662
+ ${renderActionHistory()}
663
+ ${renderLiveToolEvents()}
664
+ ${renderFileAccess()}
665
+ ${renderMatchList("Worktree Matches", matches.worktrees, (wt) =>
666
+ html`<span class="mono">${wt.name || wt.branch || "worktree"}</span> ${wt.path ? `· ${wt.path}` : ""}`)}
667
+ ${renderMatchList("Slot Matches", matches.slots, (slot) =>
668
+ html`<span class="mono">${slot.taskId || slot.taskTitle || "slot"}</span> ${slot.branch ? `· ${slot.branch}` : ""}`)}
669
+ ${renderMatchList("Session Matches", matches.sessions, (sess) =>
670
+ html`<span class="mono">${sess.id || sess.taskId || "session"}</span> ${sess.status ? `· ${sess.status}` : ""}`)}
671
+ ${commits.length > 0 &&
672
+ html`
673
+ <div class="card mb-sm">
674
+ <div class="card-title">Recent Commits</div>
675
+ ${commits.map(
676
+ (cm) => html`
677
+ <div class="meta-text" key=${cm.hash}>
678
+ <span class="mono">${cm.hash}</span> ${cm.message || ""} ${cm.time ? `· ${cm.time}` : ""}
679
+ </div>
680
+ `,
681
+ )}
682
+ </div>
683
+ `}
684
+ ${ctx && html`
685
+ <div class="card mb-sm">
686
+ <div class="card-title">Changed Files</div>
687
+ ${files.length === 0 &&
688
+ html`<div class="meta-text">Clean working tree</div>`}
689
+ ${files.map(
690
+ (f) => html`
691
+ <div class="meta-text" key=${f.file}>
692
+ <span class="mono">${f.code}</span> ${f.file}
693
+ </div>
694
+ `,
695
+ )}
696
+ </div>
697
+ `}
698
+ ${ctx?.diffSummary &&
699
+ html`
700
+ <div class="card">
701
+ <div class="card-title">Diff Summary</div>
702
+ <pre class="workspace-diff">${ctx.diffSummary}</pre>
703
+ </div>
704
+ `}
705
+ </div>
706
+ `;
707
+ };
708
+
709
+ return html`
710
+ <div class="modal-overlay" onClick=${(e) => e.target === e.currentTarget && onClose()}>
711
+ <div class="modal-content">
712
+ <div class="modal-handle" />
713
+ <div class="workspace-viewer">
714
+ <div class="workspace-header">
715
+ <div>
716
+ <div class="task-card-title">
717
+ <${StatusDot} status=${agent.status || "busy"} />
718
+ ${agent.taskTitle || "(no title)"}
719
+ </div>
720
+ <div class="task-card-meta">
721
+ ${agent.branch || "?"} · Slot ${(agent.index ?? 0) + 1} · ${formatDuration(agent.startedAt)}
722
+ </div>
723
+ </div>
724
+ <button class="btn btn-ghost btn-sm" onClick=${onClose}>✕</button>
725
+ </div>
726
+ <div class="session-detail-tabs workspace-tabs">
727
+ <button
728
+ class="session-detail-tab ${activeTab === "stream" ? "active" : ""}"
729
+ onClick=${() => setActiveTab("stream")}
730
+ >💬 Stream</button>
731
+ <button
732
+ class="session-detail-tab ${activeTab === "changes" ? "active" : ""}"
733
+ onClick=${() => setActiveTab("changes")}
734
+ >📝 Changes</button>
735
+ <button
736
+ class="session-detail-tab ${activeTab === "logs" ? "active" : ""}"
737
+ onClick=${() => setActiveTab("logs")}
738
+ >📄 Logs</button>
739
+ </div>
740
+
741
+ ${activeTab === "stream" &&
742
+ html`
743
+ ${sessionId
744
+ ? html`<${ChatView} sessionId=${sessionId} readOnly=${true} />`
745
+ : html`
746
+ <div class="chat-view chat-empty-state">
747
+ <div class="session-empty-icon">💬</div>
748
+ <div class="session-empty-text">No session stream available</div>
749
+ </div>
750
+ `}
751
+ `}
752
+ ${activeTab === "changes" && renderChanges()}
753
+ ${activeTab === "logs" &&
754
+ html`<div class="workspace-log" ref=${logRef}>${logText}</div>`}
755
+
756
+ <div class="workspace-controls">
757
+ <input
758
+ class="input"
759
+ placeholder="Steer agent…"
760
+ value=${steerInput}
761
+ onInput=${(e) => setSteerInput(e.target.value)}
762
+ onKeyDown=${(e) => e.key === "Enter" && handleSteer()}
763
+ />
764
+ <button class="btn btn-primary btn-sm" onClick=${handleSteer}>🎯</button>
765
+ <button
766
+ class="btn btn-danger btn-sm"
767
+ disabled=${agent.index == null}
768
+ onClick=${handleStop}
769
+ >⛔ Stop</button>
770
+ </div>
771
+ </div>
772
+ </div>
773
+ </div>
774
+ `;
775
+ }
776
+
777
+ /* ─── Dispatch Section ─── */
778
+ function DispatchSection({ freeSlots }) {
779
+ const [taskId, setTaskId] = useState("");
780
+ const [prompt, setPrompt] = useState("");
781
+ const [dispatching, setDispatching] = useState(false);
782
+
783
+ const canDispatch = freeSlots > 0 && (taskId.trim() || prompt.trim());
784
+
785
+ const handleDispatch = async () => {
786
+ if (!canDispatch || dispatching) return;
787
+ haptic();
788
+ setDispatching(true);
789
+ try {
790
+ const body = taskId.trim()
791
+ ? { taskId: taskId.trim() }
792
+ : { prompt: prompt.trim() };
793
+ const res = await apiFetch("/api/executor/dispatch", {
794
+ method: "POST",
795
+ body: JSON.stringify(body),
796
+ });
797
+ if (res.ok !== false) {
798
+ showToast(`Dispatched to slot ${(res.slotIndex ?? 0) + 1}`, "success");
799
+ setTaskId("");
800
+ setPrompt("");
801
+ scheduleRefresh(200);
802
+ }
803
+ } catch {
804
+ /* toast via apiFetch */
805
+ } finally {
806
+ setDispatching(false);
807
+ }
808
+ };
809
+
810
+ return html`
811
+ <${Card} title="Dispatch Agent">
812
+ <div class="dispatch-section">
813
+ <div class="meta-text mb-sm">
814
+ ${freeSlots > 0
815
+ ? `${freeSlots} slot${freeSlots > 1 ? "s" : ""} available`
816
+ : "No free slots"}
817
+ </div>
818
+ <div class="input-row">
819
+ <input
820
+ class="input"
821
+ placeholder="Task ID"
822
+ value=${taskId}
823
+ onInput=${(e) => { setTaskId(e.target.value); if (e.target.value) setPrompt(""); }}
824
+ />
825
+ </div>
826
+ <div class="divider-label">or</div>
827
+ <textarea
828
+ class="input"
829
+ placeholder="Freeform prompt…"
830
+ rows="2"
831
+ value=${prompt}
832
+ onInput=${(e) => { setPrompt(e.target.value); if (e.target.value) setTaskId(""); }}
833
+ />
834
+ <button
835
+ class="btn btn-primary"
836
+ disabled=${!canDispatch || dispatching}
837
+ onClick=${handleDispatch}
838
+ >
839
+ ${dispatching ? "Dispatching…" : "🚀 Dispatch"}
840
+ </button>
841
+ </div>
842
+ <//>
843
+ `;
844
+ }
845
+
846
+ /* ─── AgentsTab ─── */
847
+ export function AgentsTab() {
848
+ const executor = executorData.value;
849
+ const agents = agentsData?.value || [];
850
+ const execData = executor?.data;
851
+ const slots = execData?.slots || [];
852
+ const maxParallel = execData?.maxParallel || 0;
853
+ const activeSlots = execData?.activeSlots || 0;
854
+
855
+ const [expandedSlot, setExpandedSlot] = useState(null);
856
+ const [selectedAgent, setSelectedAgent] = useState(null);
857
+ const workspaceTarget = agentWorkspaceTarget.value;
858
+
859
+ useEffect(() => {
860
+ const current = selectedSessionId.value;
861
+ const sessions = sessionsData.value || [];
862
+ if (current || sessions.length === 0) return;
863
+ const activeSession =
864
+ sessions.find((s) => s.status === "active" || s.status === "running") ||
865
+ sessions[0];
866
+ if (activeSession?.id) {
867
+ selectedSessionId.value = activeSession.id;
868
+ }
869
+ }, [sessionsData.value, selectedSessionId.value]);
870
+
871
+ useEffect(() => {
872
+ if (!workspaceTarget) return;
873
+ const slotIndex = slots.findIndex((s) => {
874
+ const targetTask = workspaceTarget.taskId || "";
875
+ const targetBranch = workspaceTarget.branch || "";
876
+ return (
877
+ (targetTask && s.taskId === targetTask) ||
878
+ (targetBranch && s.branch === targetBranch)
879
+ );
880
+ });
881
+ if (slotIndex >= 0) {
882
+ setSelectedAgent({ ...slots[slotIndex], index: slotIndex });
883
+ } else {
884
+ setSelectedAgent({
885
+ taskId: workspaceTarget.taskId || null,
886
+ taskTitle: workspaceTarget.taskTitle || workspaceTarget.branch || "Workspace",
887
+ branch: workspaceTarget.branch || null,
888
+ status: "idle",
889
+ index: null,
890
+ });
891
+ }
892
+ agentWorkspaceTarget.value = null;
893
+ }, [workspaceTarget, slots]);
894
+
895
+ /* Navigate to logs tab with agent query pre-filled */
896
+ const viewAgentLogs = (query) => {
897
+ haptic();
898
+ if (agentLogQuery) agentLogQuery.value = query;
899
+ if (agentLogFile) agentLogFile.value = "";
900
+ navigateTo("logs");
901
+ };
902
+
903
+ /* Force stop a specific agent slot */
904
+ const handleForceStop = async (slot) => {
905
+ const ok = await showConfirm(
906
+ `Force-stop agent working on "${truncate(slot.taskTitle || slot.taskId || "task", 40)}"?`,
907
+ );
908
+ if (!ok) return;
909
+ haptic("heavy");
910
+ try {
911
+ await apiFetch("/api/executor/stop-slot", {
912
+ method: "POST",
913
+ body: JSON.stringify({ slotIndex: slot.index, taskId: slot.taskId }),
914
+ });
915
+ showToast("Stop signal sent", "success");
916
+ scheduleRefresh(200);
917
+ } catch {
918
+ /* toast via apiFetch */
919
+ }
920
+ };
921
+
922
+ /* Toggle expanded detail view for a slot */
923
+ const toggleExpand = (i) => {
924
+ haptic();
925
+ setExpandedSlot(expandedSlot === i ? null : i);
926
+ };
927
+
928
+ /* Open workspace viewer for an agent */
929
+ const openWorkspace = (slot, i) => {
930
+ haptic();
931
+ setSelectedAgent({ ...slot, index: i });
932
+ };
933
+
934
+ /* Capacity utilisation */
935
+ const freeSlots = Math.max(0, maxParallel - activeSlots);
936
+ const capacityPct =
937
+ maxParallel > 0 ? Math.round((activeSlots / maxParallel) * 100) : 0;
938
+
939
+ /* Aggregate stats */
940
+ const totalCompleted = slots.reduce((n, s) => n + (s.completedCount || 0), 0);
941
+ const avgTimeMs = slots.length
942
+ ? slots.reduce((n, s) => n + (s.avgDurationMs || 0), 0) / slots.length
943
+ : 0;
944
+ const avgTimeStr = avgTimeMs > 0 ? `${Math.round(avgTimeMs / 1000)}s` : "—";
945
+
946
+ /* Loading state */
947
+ if (!executor && !agents.length)
948
+ return html`<${Card} title="Loading…"><${SkeletonCard} count=${3} /><//>`;
949
+
950
+ return html`
951
+ <!-- Dispatch section -->
952
+ <${DispatchSection} freeSlots=${freeSlots} />
953
+
954
+ <!-- Capacity overview -->
955
+ <${Card} title="Agent Capacity">
956
+ <div class="stats-grid mb-sm">
957
+ <${StatCard}
958
+ value=${activeSlots}
959
+ label="Active"
960
+ color="var(--color-inprogress)"
961
+ />
962
+ <${StatCard} value=${maxParallel} label="Max" />
963
+ <${StatCard}
964
+ value=${totalCompleted}
965
+ label="Completed"
966
+ color="var(--color-done)"
967
+ />
968
+ <${StatCard} value=${avgTimeStr} label="Avg Time" />
969
+ </div>
970
+ <${ProgressBar} percent=${capacityPct} />
971
+ <div class="meta-text text-center mt-xs">
972
+ ${capacityPct}% capacity used
973
+ </div>
974
+ <//>
975
+
976
+ <!-- Visual slot grid -->
977
+ <${Card} title="Slot Grid">
978
+ <div class="slot-grid">
979
+ ${Array.from(
980
+ { length: Math.max(maxParallel, slots.length, 1) },
981
+ (_, i) => {
982
+ const slot = slots[i];
983
+ const st = slot ? slot.status || "busy" : "idle";
984
+ return html`
985
+ <div
986
+ key=${i}
987
+ class="slot-cell slot-${st}"
988
+ title=${slot
989
+ ? `${slot.taskTitle || slot.taskId} (${st})`
990
+ : `Slot ${i + 1} idle`}
991
+ onClick=${() => slot && openWorkspace(slot, i)}
992
+ >
993
+ <${StatusDot} status=${st} />
994
+ <span class="slot-label">${i + 1}</span>
995
+ </div>
996
+ `;
997
+ },
998
+ )}
999
+ </div>
1000
+ <//>
1001
+
1002
+ <!-- Active agents / slots -->
1003
+ <${Card} title="Active Agents">
1004
+ ${slots.length
1005
+ ? slots.map(
1006
+ (slot, i) => html`
1007
+ <div
1008
+ key=${i}
1009
+ class="task-card ${expandedSlot === i
1010
+ ? "task-card-expanded"
1011
+ : ""}"
1012
+ >
1013
+ <div
1014
+ class="task-card-header"
1015
+ onClick=${() => toggleExpand(i)}
1016
+ style="cursor:pointer"
1017
+ >
1018
+ <div>
1019
+ <div class="task-card-title">
1020
+ <${StatusDot} status=${slot.status || "busy"} />
1021
+ ${slot.taskTitle || "(no title)"}
1022
+ </div>
1023
+ <div class="task-card-meta">
1024
+ ${slot.taskId || "?"} · Agent
1025
+ ${slot.agentInstanceId || "n/a"} · ${slot.sdk || "?"}${slot.model ? ` · ${slot.model}` : ""}
1026
+ </div>
1027
+ </div>
1028
+ <${Badge}
1029
+ status=${slot.status || "busy"}
1030
+ text=${slot.status || "busy"}
1031
+ />
1032
+ </div>
1033
+ <div class="flex-between">
1034
+ <div class="meta-text">Attempt ${slot.attempt || 1}</div>
1035
+ ${slot.startedAt && html`
1036
+ <div class="agent-duration">${formatDuration(slot.startedAt)}</div>
1037
+ `}
1038
+ </div>
1039
+
1040
+ <!-- Progress indicator for active tasks -->
1041
+ ${(slot.status === "running" || slot.status === "busy") &&
1042
+ html`
1043
+ <div class="agent-progress-bar mt-sm">
1044
+ <div
1045
+ class="agent-progress-bar-fill agent-progress-pulse"
1046
+ ></div>
1047
+ </div>
1048
+ `}
1049
+
1050
+ <!-- Expanded detail -->
1051
+ ${expandedSlot === i &&
1052
+ html`
1053
+ <div class="agent-detail mt-sm">
1054
+ ${slot.branch &&
1055
+ html`<div class="meta-text">Branch: ${slot.branch}</div>`}
1056
+ ${slot.startedAt &&
1057
+ html`<div class="meta-text">
1058
+ Started: ${formatRelative(slot.startedAt)}
1059
+ </div>`}
1060
+ ${slot.completedCount != null &&
1061
+ html`<div class="meta-text">
1062
+ Completed: ${slot.completedCount} tasks
1063
+ </div>`}
1064
+ ${slot.avgDurationMs &&
1065
+ html`<div class="meta-text">
1066
+ Avg: ${Math.round(slot.avgDurationMs / 1000)}s
1067
+ </div>`}
1068
+ ${slot.model &&
1069
+ html`<div class="meta-text">Model: ${slot.model}</div>`}
1070
+ ${slot.lastError &&
1071
+ html`<div
1072
+ class="meta-text"
1073
+ style="color:var(--color-error)"
1074
+ >
1075
+ Last error: ${truncate(slot.lastError, 100)}
1076
+ </div>`}
1077
+ </div>
1078
+ `}
1079
+
1080
+ <div class="btn-row mt-sm">
1081
+ <button
1082
+ class="btn btn-ghost btn-sm"
1083
+ onClick=${() =>
1084
+ viewAgentLogs(
1085
+ (slot.taskId || slot.branch || "").slice(0, 12),
1086
+ )}
1087
+ >
1088
+ 📄 Logs
1089
+ </button>
1090
+ <button
1091
+ class="btn btn-ghost btn-sm"
1092
+ onClick=${() =>
1093
+ sendCommandToChat(
1094
+ `/steer focus on ${slot.taskTitle || slot.taskId}`,
1095
+ )}
1096
+ >
1097
+ 🎯 Steer
1098
+ </button>
1099
+ <button
1100
+ class="btn btn-ghost btn-sm"
1101
+ onClick=${() => openWorkspace(slot, i)}
1102
+ >
1103
+ 🔍 View
1104
+ </button>
1105
+ <button
1106
+ class="btn btn-danger btn-sm"
1107
+ onClick=${() => handleForceStop({ ...slot, index: i })}
1108
+ >
1109
+ ⛔ Stop
1110
+ </button>
1111
+ </div>
1112
+ </div>
1113
+ `,
1114
+ )
1115
+ : html`<${EmptyState} message="No active agents." />`}
1116
+ <//>
1117
+
1118
+ <!-- Agent threads (if separate from slots) -->
1119
+ ${agents.length > 0 &&
1120
+ html`
1121
+ <${Collapsible} title="Agent Threads" defaultOpen=${false}>
1122
+ <${Card}>
1123
+ <div class="stats-grid">
1124
+ ${agents.map(
1125
+ (t, i) => html`
1126
+ <${StatCard}
1127
+ key=${i}
1128
+ value=${t.turnCount || 0}
1129
+ label="${truncate(t.taskKey || `Thread ${i}`, 20)} (${t.sdk ||
1130
+ "?"})"
1131
+ />
1132
+ `,
1133
+ )}
1134
+ </div>
1135
+ <//>
1136
+ <//>
1137
+ `}
1138
+
1139
+ <!-- Workspace viewer modal -->
1140
+ ${selectedAgent && html`
1141
+ <${WorkspaceViewer}
1142
+ agent=${selectedAgent}
1143
+ onClose=${() => setSelectedAgent(null)}
1144
+ />
1145
+ `}
1146
+
1147
+ <!-- Sessions panel -->
1148
+ <${SessionsPanel} />
1149
+ `;
1150
+ }
1151
+
1152
+ /* ─── Context Viewer for session detail tab ─── */
1153
+ function ContextViewer({ sessionId }) {
1154
+ const [ctx, setCtx] = useState(null);
1155
+ const [loading, setLoading] = useState(true);
1156
+ const [error, setError] = useState(null);
1157
+ const intervalRef = useRef(null);
1158
+
1159
+ const fetchContext = useCallback(() => {
1160
+ if (!sessionId) return;
1161
+ apiFetch(`/api/agent-context?query=${encodeURIComponent(sessionId)}`, { _silent: true })
1162
+ .then((res) => {
1163
+ const d = res.data ?? res ?? null;
1164
+ setCtx(d);
1165
+ setLoading(false);
1166
+ setError(null);
1167
+ })
1168
+ .catch((err) => {
1169
+ setLoading(false);
1170
+ setError(err.message || "Failed to load context");
1171
+ });
1172
+ }, [sessionId]);
1173
+
1174
+ useEffect(() => {
1175
+ setLoading(true);
1176
+ setError(null);
1177
+ setCtx(null);
1178
+ fetchContext();
1179
+ intervalRef.current = setInterval(fetchContext, 10000);
1180
+ return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
1181
+ }, [fetchContext]);
1182
+
1183
+ const parseCommits = (detailed) => {
1184
+ if (!detailed) return [];
1185
+ return detailed.split("\n").filter(Boolean).map((line) => {
1186
+ const parts = line.split("||");
1187
+ return { hash: parts[0] || "", message: parts[1] || "", time: parts[2] || "" };
1188
+ });
1189
+ };
1190
+
1191
+ const parseStatus = (raw) => {
1192
+ if (!raw) return [];
1193
+ return raw.split("\n").filter(Boolean).map((line) => {
1194
+ const code = line.substring(0, 2).trim() || "?";
1195
+ const file = line.substring(3);
1196
+ return { code, file };
1197
+ });
1198
+ };
1199
+
1200
+ const parseAheadBehind = (raw) => {
1201
+ if (!raw) return { ahead: 0, behind: 0 };
1202
+ const parts = raw.split(/\s+/);
1203
+ return { ahead: parseInt(parts[0], 10) || 0, behind: parseInt(parts[1], 10) || 0 };
1204
+ };
1205
+
1206
+ const statusColor = (code) => {
1207
+ if (code === "M" || code === "MM") return "var(--color-inprogress)";
1208
+ if (code === "A") return "var(--color-done)";
1209
+ if (code === "D") return "var(--color-error)";
1210
+ if (code === "?" || code === "??") return "var(--text-secondary)";
1211
+ return "var(--text-primary)";
1212
+ };
1213
+
1214
+ const statusLabel = (code) => {
1215
+ const map = { M: "Modified", MM: "Modified", A: "Added", D: "Deleted", "?": "Untracked", "??": "Untracked", R: "Renamed", C: "Copied" };
1216
+ return map[code] || code;
1217
+ };
1218
+
1219
+ const copyContext = () => {
1220
+ if (!ctx?.context) return;
1221
+ const c = ctx.context;
1222
+ const ab = parseAheadBehind(c.gitAheadBehind);
1223
+ const commits = parseCommits(c.gitLogDetailed);
1224
+ const files = parseStatus(c.gitStatus);
1225
+ let text = `## Workspace Context\n`;
1226
+ text += `Branch: ${c.gitBranch || "unknown"}\n`;
1227
+ text += `Path: ${c.path || "unknown"}\n`;
1228
+ text += `Status: ${files.length === 0 ? "Clean" : `${files.length} changed file(s)`}\n`;
1229
+ if (ab.ahead || ab.behind) text += `Ahead: ${ab.ahead}, Behind: ${ab.behind}\n`;
1230
+ if (commits.length) {
1231
+ text += `\n### Recent Commits\n`;
1232
+ commits.forEach((cm) => { text += `${cm.hash} ${cm.message} (${cm.time})\n`; });
1233
+ }
1234
+ if (files.length) {
1235
+ text += `\n### Modified Files\n`;
1236
+ files.forEach((f) => { text += `[${f.code}] ${f.file}\n`; });
1237
+ }
1238
+ navigator.clipboard.writeText(text).then(() => showToast("Context copied", "success")).catch(() => showToast("Copy failed", "error"));
1239
+ };
1240
+
1241
+ if (loading) {
1242
+ return html`<div class="chat-view" style="padding:16px;">
1243
+ <${SkeletonCard} height="40px" />
1244
+ <${SkeletonCard} height="120px" className="mt-sm" />
1245
+ <${SkeletonCard} height="80px" className="mt-sm" />
1246
+ </div>`;
1247
+ }
1248
+
1249
+ if (error) {
1250
+ return html`<div class="chat-view chat-empty-state">
1251
+ <div class="session-empty-icon" style="color:var(--color-error)">⚠️</div>
1252
+ <div class="session-empty-text">${error}</div>
1253
+ <button class="btn btn-primary btn-sm mt-sm" onClick=${() => { setLoading(true); setError(null); fetchContext(); }}>🔄 Retry</button>
1254
+ </div>`;
1255
+ }
1256
+
1257
+ if (!ctx?.context) {
1258
+ return html`<div class="chat-view chat-empty-state">
1259
+ <div class="session-empty-icon">📋</div>
1260
+ <div class="session-empty-text">No context available for this session</div>
1261
+ </div>`;
1262
+ }
1263
+
1264
+ const c = ctx.context;
1265
+ const ab = parseAheadBehind(c.gitAheadBehind);
1266
+ const commits = parseCommits(c.gitLogDetailed);
1267
+ const files = parseStatus(c.gitStatus);
1268
+ const isDirty = files.length > 0;
1269
+
1270
+ return html`
1271
+ <div class="chat-view" style="padding:12px; overflow-y:auto;">
1272
+ <!-- Toolbar -->
1273
+ <div style="display:flex; gap:8px; justify-content:flex-end; margin-bottom:12px;">
1274
+ <button class="btn btn-ghost btn-sm" onClick=${() => { setLoading(true); fetchContext(); }}>
1275
+ <span class="icon-inline">${ICONS.refresh}</span> Refresh
1276
+ </button>
1277
+ <button class="btn btn-ghost btn-sm" onClick=${copyContext}>
1278
+ <span class="icon-inline">${ICONS.copy}</span> Copy Context
1279
+ </button>
1280
+ </div>
1281
+
1282
+ <!-- Branch & Status -->
1283
+ <div class="card mb-sm">
1284
+ <div class="card-title" style="display:flex; align-items:center; gap:8px;">
1285
+ <span class="icon-inline">${ICONS.git}</span> Branch & Status
1286
+ </div>
1287
+ <div style="display:flex; flex-wrap:wrap; gap:12px; margin-top:8px;">
1288
+ <div style="flex:1; min-width:120px;">
1289
+ <div class="meta-text">Branch</div>
1290
+ <div style="font-weight:600; font-family:monospace; font-size:13px;">${c.gitBranch || "unknown"}</div>
1291
+ </div>
1292
+ <div>
1293
+ <div class="meta-text">Status</div>
1294
+ <${Badge}
1295
+ status=${isDirty ? "inprogress" : "done"}
1296
+ text=${isDirty ? `${files.length} changed` : "Clean"}
1297
+ />
1298
+ </div>
1299
+ ${(ab.ahead > 0 || ab.behind > 0) && html`
1300
+ <div>
1301
+ <div class="meta-text">Sync</div>
1302
+ <div style="font-size:13px;">
1303
+ ${ab.ahead > 0 ? html`<span style="color:var(--color-done)">↑${ab.ahead}</span>` : null}
1304
+ ${ab.ahead > 0 && ab.behind > 0 ? " " : null}
1305
+ ${ab.behind > 0 ? html`<span style="color:var(--color-error)">↓${ab.behind}</span>` : null}
1306
+ </div>
1307
+ </div>
1308
+ `}
1309
+ </div>
1310
+ </div>
1311
+
1312
+ <!-- Working Directory -->
1313
+ <div class="card mb-sm">
1314
+ <div class="card-title" style="display:flex; align-items:center; gap:8px;">
1315
+ <span class="icon-inline">${ICONS.folder}</span> Working Directory
1316
+ </div>
1317
+ <div style="font-family:monospace; font-size:12px; color:var(--text-secondary); margin-top:6px; word-break:break-all;">
1318
+ ${c.path || "unknown"}
1319
+ </div>
1320
+ </div>
1321
+
1322
+ <!-- Recent Commits -->
1323
+ ${commits.length > 0 && html`
1324
+ <div class="card mb-sm">
1325
+ <div class="card-title" style="display:flex; align-items:center; gap:8px;">
1326
+ <span class="icon-inline">${ICONS.clock}</span> Recent Commits
1327
+ </div>
1328
+ <div style="margin-top:8px;">
1329
+ ${commits.map((cm) => html`
1330
+ <div key=${cm.hash} style="display:flex; gap:8px; align-items:baseline; padding:4px 0; border-bottom:1px solid var(--border-color, rgba(255,255,255,0.06));">
1331
+ <code style="color:var(--color-inprogress); font-size:12px; flex-shrink:0;">${cm.hash}</code>
1332
+ <span style="flex:1; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${cm.message}</span>
1333
+ <span class="meta-text" style="flex-shrink:0; font-size:11px;">${cm.time}</span>
1334
+ </div>
1335
+ `)}
1336
+ </div>
1337
+ </div>
1338
+ `}
1339
+
1340
+ <!-- Modified Files -->
1341
+ ${files.length > 0 && html`
1342
+ <div class="card mb-sm">
1343
+ <div class="card-title" style="display:flex; align-items:center; gap:8px;">
1344
+ <span class="icon-inline">${ICONS.edit}</span> Modified Files
1345
+ <${Badge} text="${files.length}" className="ml-auto" />
1346
+ </div>
1347
+ <div style="margin-top:8px;">
1348
+ ${files.map((f) => html`
1349
+ <div key=${f.file} style="display:flex; gap:8px; align-items:center; padding:4px 0; border-bottom:1px solid var(--border-color, rgba(255,255,255,0.06));">
1350
+ <code style="color:${statusColor(f.code)}; font-size:11px; font-weight:700; min-width:20px; text-align:center;" title=${statusLabel(f.code)}>${f.code}</code>
1351
+ <span style="font-family:monospace; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${f.file}</span>
1352
+ </div>
1353
+ `)}
1354
+ </div>
1355
+ </div>
1356
+ `}
1357
+
1358
+ <!-- Diff Stats -->
1359
+ ${c.gitDiffStat && html`
1360
+ <div class="card mb-sm">
1361
+ <div class="card-title" style="display:flex; align-items:center; gap:8px;">
1362
+ <span class="icon-inline">${ICONS.terminal}</span> Diff Summary
1363
+ </div>
1364
+ <pre style="font-size:11px; margin:8px 0 0; white-space:pre-wrap; color:var(--text-secondary); overflow-x:auto;">${c.gitDiffStat}</pre>
1365
+ </div>
1366
+ `}
1367
+ </div>
1368
+ `;
1369
+ }
1370
+
1371
+ /* ─── Sessions Panel — split view with list + detail ─── */
1372
+ function SessionsPanel() {
1373
+ const [detailTab, setDetailTab] = useState("chat");
1374
+ const sessionId = selectedSessionId.value;
1375
+
1376
+ const handleBack = useCallback(() => {
1377
+ selectedSessionId.value = null;
1378
+ }, []);
1379
+
1380
+ return html`
1381
+ <${Card} title="Sessions">
1382
+ <div class="session-split">
1383
+ <${SessionList} onSelect=${() => setDetailTab("chat")} />
1384
+ <div class="session-detail">
1385
+ ${sessionId && html`
1386
+ <button class="session-back-btn" onClick=${handleBack}>
1387
+ ← Back to sessions
1388
+ </button>
1389
+ <div class="session-detail-tabs">
1390
+ <button
1391
+ class="session-detail-tab ${detailTab === "chat" ? "active" : ""}"
1392
+ onClick=${() => setDetailTab("chat")}
1393
+ >💬 Chat</button>
1394
+ <button
1395
+ class="session-detail-tab ${detailTab === "diff" ? "active" : ""}"
1396
+ onClick=${() => setDetailTab("diff")}
1397
+ >📝 Diff</button>
1398
+ <button
1399
+ class="session-detail-tab ${detailTab === "context" ? "active" : ""}"
1400
+ onClick=${() => setDetailTab("context")}
1401
+ >📋 Context</button>
1402
+ </div>
1403
+ `}
1404
+ ${detailTab === "chat" && html`<${ChatView} sessionId=${sessionId} />`}
1405
+ ${detailTab === "diff" && sessionId && html`<${DiffViewer} sessionId=${sessionId} />`}
1406
+ ${detailTab === "context" && sessionId && html`<${ContextViewer} sessionId=${sessionId} />`}
1407
+ ${!sessionId && detailTab !== "chat" && html`
1408
+ <div class="chat-view chat-empty-state">
1409
+ <div class="session-empty-icon">💬</div>
1410
+ <div class="session-empty-text">Select a session</div>
1411
+ </div>
1412
+ `}
1413
+ </div>
1414
+ </div>
1415
+ <//>
1416
+ `;
1417
+ }