@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.
- package/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- 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
|
+
}
|