@bill10/agent-007 0.6.2
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/LICENSE +21 -0
- package/README.md +222 -0
- package/VERSION +1 -0
- package/bin/adduser.js +69 -0
- package/bin/agent-007.js +88 -0
- package/lib/cron.js +189 -0
- package/lib/helpers.js +541 -0
- package/lib/jobs.js +965 -0
- package/package.json +63 -0
- package/public/app.js +650 -0
- package/public/assets/characters/LICENSE +21 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/furniture/bookshelf.png +0 -0
- package/public/assets/furniture/cactus.png +0 -0
- package/public/assets/furniture/chair_back.png +0 -0
- package/public/assets/furniture/chair_front.png +0 -0
- package/public/assets/furniture/chair_side.png +0 -0
- package/public/assets/furniture/coffee.png +0 -0
- package/public/assets/furniture/coffee_table.png +0 -0
- package/public/assets/furniture/desk.png +0 -0
- package/public/assets/furniture/desk2.png +0 -0
- package/public/assets/furniture/plant_2.png +0 -0
- package/public/assets/furniture/sofa_front.png +0 -0
- package/public/assets/furniture/sofa_side.png +0 -0
- package/public/assets/furniture/table_front.png +0 -0
- package/public/index.html +245 -0
- package/public/modules/auth.js +83 -0
- package/public/modules/explorer.js +760 -0
- package/public/modules/jobs.js +971 -0
- package/public/modules/office.js +2154 -0
- package/public/modules/paths.js +20 -0
- package/public/modules/shortcuts.js +54 -0
- package/public/modules/state.js +75 -0
- package/public/modules/terminal.js +651 -0
- package/public/modules/voice.js +393 -0
- package/public/modules/ws.js +56 -0
- package/public/style.css +1843 -0
- package/server/agent-mcp-bridge.js +45 -0
- package/server/agent-mcp.js +184 -0
- package/server/agent-transcripts.js +195 -0
- package/server/approvals.js +155 -0
- package/server/auth.js +162 -0
- package/server/billion.js +176 -0
- package/server/claude-trust.js +66 -0
- package/server/command-path.js +102 -0
- package/server/config.js +184 -0
- package/server/direct-run.js +33 -0
- package/server/git.js +630 -0
- package/server/http.js +276 -0
- package/server/jobs.js +2044 -0
- package/server/mcp.js +596 -0
- package/server/messages.js +319 -0
- package/server/permission-hook.js +47 -0
- package/server/pty.js +360 -0
- package/server/state.js +104 -0
- package/server/ws.js +583 -0
- package/server.js +306 -0
- package/templates/billion/COMPANY.md +14 -0
- package/templates/billion/STATE.md +17 -0
- package/templates/billion/charter.md +232 -0
- package/templates/billion/owner.md +11 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
// File explorer panel — repo sections, branch trees, inline diffs
|
|
2
|
+
import { agents, repos, orphans, activeSessionId, setView, billionEnabled } from './state.js';
|
|
3
|
+
import { send } from './ws.js';
|
|
4
|
+
import { switchToSession } from './terminal.js';
|
|
5
|
+
|
|
6
|
+
let explorerVisible = true;
|
|
7
|
+
let expandedBranches = new Set(); // sessionIds with expanded file trees
|
|
8
|
+
let activeDiff = null; // { sessionId, filePath, diff } for modal
|
|
9
|
+
let pendingDiffs = new Set(); // loading state
|
|
10
|
+
let conflicts = []; // current conflict list
|
|
11
|
+
let fullTreeSessions = new Set(); // sessionIds showing full tree
|
|
12
|
+
let fullTreeCache = new Map(); // sessionId -> full tree data
|
|
13
|
+
let fullTreePending = new Set(); // in-flight full tree requests
|
|
14
|
+
let expandedDirs = new Set(); // "sessionId:dir/path" expanded directories (collapsed by default)
|
|
15
|
+
const COLLAPSED_KEY = 'agent007-collapsed-repos';
|
|
16
|
+
let collapsedRepos = loadCollapsedRepos(); // repoPaths with collapsed sections
|
|
17
|
+
|
|
18
|
+
function loadCollapsedRepos() {
|
|
19
|
+
try {
|
|
20
|
+
const saved = JSON.parse(localStorage.getItem(COLLAPSED_KEY));
|
|
21
|
+
return new Set(Array.isArray(saved) ? saved : []);
|
|
22
|
+
} catch { return new Set(); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function setupExplorer() {
|
|
26
|
+
document.getElementById('btn-toggle-explorer').onclick = toggleExplorer;
|
|
27
|
+
document.getElementById('btn-reopen-explorer').onclick = toggleExplorer;
|
|
28
|
+
// Refresh button
|
|
29
|
+
const refreshBtn = document.getElementById('btn-refresh-tree');
|
|
30
|
+
if (refreshBtn) {
|
|
31
|
+
refreshBtn.onclick = () => {
|
|
32
|
+
for (const [sessionId, agent] of agents) {
|
|
33
|
+
if (agent.repoPath) send({ type: 'refresh-tree', sessionId });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// Start collapsed if no repos
|
|
38
|
+
if (repos.size === 0) {
|
|
39
|
+
setExplorerVisible(false);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function toggleExplorer() {
|
|
44
|
+
setExplorerVisible(!explorerVisible);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setExplorerVisible(visible) {
|
|
48
|
+
explorerVisible = visible;
|
|
49
|
+
const panel = document.getElementById('explorer-panel');
|
|
50
|
+
const divider = document.getElementById('divider-explorer');
|
|
51
|
+
const toggleBtn = document.getElementById('btn-toggle-explorer');
|
|
52
|
+
const reopenBtn = document.getElementById('btn-reopen-explorer');
|
|
53
|
+
if (visible) {
|
|
54
|
+
panel.classList.remove('collapsed');
|
|
55
|
+
divider.style.display = '';
|
|
56
|
+
reopenBtn.style.display = 'none';
|
|
57
|
+
toggleBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12"><path d="M8 1L3 6l5 5" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
|
|
58
|
+
} else {
|
|
59
|
+
panel.classList.add('collapsed');
|
|
60
|
+
divider.style.display = 'none';
|
|
61
|
+
reopenBtn.style.display = '';
|
|
62
|
+
toggleBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12"><path d="M4 1l5 5-5 5" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function handleReposList(msg) {
|
|
67
|
+
const hadRepos = repos.size;
|
|
68
|
+
repos.clear();
|
|
69
|
+
for (const r of msg.repos) {
|
|
70
|
+
repos.set(r.path, { slug: r.slug, exists: r.exists });
|
|
71
|
+
}
|
|
72
|
+
renderExplorer();
|
|
73
|
+
// Auto-expand explorer when first repo added
|
|
74
|
+
if (repos.size > 0 && !explorerVisible) {
|
|
75
|
+
setExplorerVisible(true);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function handleRepoError(msg) {
|
|
80
|
+
const bar = document.getElementById('status-bar');
|
|
81
|
+
if (bar) {
|
|
82
|
+
bar.style.display = '';
|
|
83
|
+
bar.textContent = msg.error;
|
|
84
|
+
bar.style.color = 'var(--state-disconnected)';
|
|
85
|
+
setTimeout(() => { bar.style.display = 'none'; bar.style.color = ''; }, 5000);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function handleFileTree(msg) {
|
|
90
|
+
const agent = agents.get(msg.sessionId);
|
|
91
|
+
if (!agent) return;
|
|
92
|
+
agent.fileTree = msg.tree;
|
|
93
|
+
agent.changedCount = msg.changedCount;
|
|
94
|
+
agent.additions = msg.additions || 0;
|
|
95
|
+
agent.removals = msg.removals || 0;
|
|
96
|
+
fullTreeCache.delete(msg.sessionId); // invalidate stale full tree
|
|
97
|
+
renderExplorer();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function handleFullTree(msg) {
|
|
101
|
+
fullTreePending.delete(msg.sessionId);
|
|
102
|
+
fullTreeCache.set(msg.sessionId, msg.tree);
|
|
103
|
+
renderExplorer();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function handleConflictsUpdate(msg) {
|
|
107
|
+
conflicts = msg.conflicts || [];
|
|
108
|
+
// Update agent conflict state
|
|
109
|
+
for (const [, agent] of agents) {
|
|
110
|
+
agent.conflicts = [];
|
|
111
|
+
}
|
|
112
|
+
for (const c of conflicts) {
|
|
113
|
+
for (const sid of c.sessionIds) {
|
|
114
|
+
const agent = agents.get(sid);
|
|
115
|
+
if (agent) {
|
|
116
|
+
agent.conflicts = [...(agent.conflicts || []), ...c.files];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
renderExplorer();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function handleFileDiff(msg) {
|
|
124
|
+
const key = `${msg.sessionId}:${msg.filePath}`;
|
|
125
|
+
pendingDiffs.delete(key);
|
|
126
|
+
activeDiff = { sessionId: msg.sessionId, filePath: msg.filePath, diff: msg.diff };
|
|
127
|
+
showDiffViewer();
|
|
128
|
+
renderExplorer();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function handleOrphansList(msg) {
|
|
132
|
+
orphans.clear();
|
|
133
|
+
for (const o of msg.orphans) {
|
|
134
|
+
orphans.set(o.id, o);
|
|
135
|
+
}
|
|
136
|
+
renderExplorer();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function renderExplorer() {
|
|
140
|
+
const content = document.getElementById('explorer-content');
|
|
141
|
+
if (!content) return;
|
|
142
|
+
const scrollTop = content.scrollTop;
|
|
143
|
+
content.innerHTML = '';
|
|
144
|
+
|
|
145
|
+
renderBillionRow(content);
|
|
146
|
+
|
|
147
|
+
const otherAgents = [...agents.values()].filter(a => !a.isBillion).length;
|
|
148
|
+
if (repos.size === 0 && otherAgents === 0 && orphans.size === 0) {
|
|
149
|
+
const empty = document.createElement('div');
|
|
150
|
+
empty.className = 'explorer-empty';
|
|
151
|
+
empty.textContent = 'No repos yet';
|
|
152
|
+
content.appendChild(empty);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Group agents by repo
|
|
156
|
+
const repoAgents = new Map();
|
|
157
|
+
for (const [sessionId, agent] of agents) {
|
|
158
|
+
if (!agent.repoPath) continue;
|
|
159
|
+
if (!repoAgents.has(agent.repoPath)) repoAgents.set(agent.repoPath, []);
|
|
160
|
+
repoAgents.get(agent.repoPath).push({ sessionId, agent });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Group orphans by repo
|
|
164
|
+
const repoOrphans = new Map();
|
|
165
|
+
for (const [orphanId, orphan] of orphans) {
|
|
166
|
+
if (!repoOrphans.has(orphan.repoPath)) repoOrphans.set(orphan.repoPath, []);
|
|
167
|
+
repoOrphans.get(orphan.repoPath).push({ orphanId, orphan });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Render each configured repo
|
|
171
|
+
for (const [repoPath, repo] of repos) {
|
|
172
|
+
const section = document.createElement('div');
|
|
173
|
+
section.className = 'explorer-repo';
|
|
174
|
+
|
|
175
|
+
// Sticky header — click to collapse/expand section
|
|
176
|
+
const branchAgents = repoAgents.get(repoPath) || [];
|
|
177
|
+
const repoOrphanList = repoOrphans.get(repoPath) || [];
|
|
178
|
+
const { header, isCollapsed } = makeRepoHeader(repoPath, repo.slug);
|
|
179
|
+
|
|
180
|
+
if (!repo.exists) {
|
|
181
|
+
const err = document.createElement('span');
|
|
182
|
+
err.className = 'explorer-error-badge';
|
|
183
|
+
err.textContent = 'not found';
|
|
184
|
+
header.appendChild(err);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const headerActions = document.createElement('span');
|
|
188
|
+
headerActions.className = 'explorer-repo-actions';
|
|
189
|
+
|
|
190
|
+
// Spawn agent on this repo
|
|
191
|
+
const addBtn = document.createElement('button');
|
|
192
|
+
addBtn.className = 'explorer-icon-btn';
|
|
193
|
+
addBtn.textContent = '+';
|
|
194
|
+
addBtn.title = 'Spawn agent on this repo';
|
|
195
|
+
addBtn.onclick = (e) => {
|
|
196
|
+
e.stopPropagation();
|
|
197
|
+
openSpawnFormWithRepo(repoPath);
|
|
198
|
+
};
|
|
199
|
+
headerActions.appendChild(addBtn);
|
|
200
|
+
|
|
201
|
+
// Remove repo
|
|
202
|
+
const removeBtn = document.createElement('button');
|
|
203
|
+
removeBtn.className = 'explorer-icon-btn explorer-remove-btn';
|
|
204
|
+
removeBtn.textContent = '\u00d7';
|
|
205
|
+
removeBtn.title = 'Remove repo';
|
|
206
|
+
removeBtn.onclick = (e) => {
|
|
207
|
+
e.stopPropagation();
|
|
208
|
+
collapsedRepos.delete(repoPath);
|
|
209
|
+
saveCollapsedRepos();
|
|
210
|
+
send({ type: 'remove-repo', path: repoPath });
|
|
211
|
+
};
|
|
212
|
+
headerActions.appendChild(removeBtn);
|
|
213
|
+
|
|
214
|
+
if (isCollapsed) {
|
|
215
|
+
header.appendChild(makeCollapsedBadge(
|
|
216
|
+
branchAgents.map(b => b.agent),
|
|
217
|
+
branchAgents.length + repoOrphanList.length
|
|
218
|
+
));
|
|
219
|
+
}
|
|
220
|
+
header.appendChild(headerActions);
|
|
221
|
+
section.appendChild(header);
|
|
222
|
+
|
|
223
|
+
// Branches
|
|
224
|
+
if (!isCollapsed) {
|
|
225
|
+
if (branchAgents.length === 0 && repoOrphanList.length === 0 && repo.exists) {
|
|
226
|
+
const noBranch = document.createElement('div');
|
|
227
|
+
noBranch.className = 'explorer-no-agents';
|
|
228
|
+
noBranch.textContent = '(no agents)';
|
|
229
|
+
section.appendChild(noBranch);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const { sessionId, agent } of branchAgents) {
|
|
233
|
+
const branchEl = createBranchEntry(sessionId, agent);
|
|
234
|
+
section.appendChild(branchEl);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Render orphans for this repo
|
|
238
|
+
for (const { orphanId, orphan } of repoOrphanList) {
|
|
239
|
+
section.appendChild(createOrphanEntry(orphanId, orphan));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
content.appendChild(section);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Render orphans for repos not in the configured repos list
|
|
247
|
+
for (const [repoPath, orphanList] of repoOrphans) {
|
|
248
|
+
if (repos.has(repoPath)) continue; // Already rendered above
|
|
249
|
+
const section = document.createElement('div');
|
|
250
|
+
section.className = 'explorer-repo';
|
|
251
|
+
const { header, isCollapsed } = makeRepoHeader(repoPath, orphanList[0].orphan.repoSlug || repoPath.split('/').pop());
|
|
252
|
+
if (isCollapsed) header.appendChild(makeCollapsedBadge([], orphanList.length));
|
|
253
|
+
section.appendChild(header);
|
|
254
|
+
if (!isCollapsed) {
|
|
255
|
+
for (const { orphanId, orphan } of orphanList) {
|
|
256
|
+
section.appendChild(createOrphanEntry(orphanId, orphan));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
content.appendChild(section);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Agents without repos (legacy mode)
|
|
263
|
+
const noRepoAgents = [...agents.entries()].filter(([, a]) => !a.repoPath && !a.isBillion);
|
|
264
|
+
if (noRepoAgents.length > 0) {
|
|
265
|
+
const section = document.createElement('div');
|
|
266
|
+
section.className = 'explorer-repo';
|
|
267
|
+
const { header, isCollapsed } = makeRepoHeader('(no repo)', 'No repo');
|
|
268
|
+
if (isCollapsed) {
|
|
269
|
+
header.appendChild(makeCollapsedBadge(noRepoAgents.map(([, a]) => a), noRepoAgents.length));
|
|
270
|
+
}
|
|
271
|
+
section.appendChild(header);
|
|
272
|
+
if (!isCollapsed) {
|
|
273
|
+
for (const [sessionId, agent] of noRepoAgents) {
|
|
274
|
+
const entry = document.createElement('div');
|
|
275
|
+
entry.className = `explorer-branch${sessionId === activeSessionId ? ' active' : ''}`;
|
|
276
|
+
entry.onclick = () => switchToSession(sessionId);
|
|
277
|
+
const dot = document.createElement('span');
|
|
278
|
+
dot.className = 'explorer-dot';
|
|
279
|
+
dot.style.background = stateColor(agent.state);
|
|
280
|
+
entry.appendChild(dot);
|
|
281
|
+
entry.appendChild(document.createTextNode(agent.name));
|
|
282
|
+
section.appendChild(entry);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
content.appendChild(section);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
content.scrollTop = scrollTop;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Billion, pinned above the repos (docs/BILLION.md). The row stays while it is
|
|
292
|
+
// stopped, with a Start button: nothing restarts it on its own.
|
|
293
|
+
function renderBillionRow(content) {
|
|
294
|
+
const [sessionId, agent] = [...agents.entries()].find(([, a]) => a.isBillion) || [];
|
|
295
|
+
if (!billionEnabled && !agent) return;
|
|
296
|
+
const running = !!agent && agent.state !== 'DISCONNECTED';
|
|
297
|
+
const section = document.createElement('div');
|
|
298
|
+
section.className = 'explorer-repo explorer-billion';
|
|
299
|
+
const entry = document.createElement('div');
|
|
300
|
+
entry.className = `explorer-branch${sessionId && sessionId === activeSessionId ? ' active' : ''}`;
|
|
301
|
+
// The whole row starts a stopped Billion, not just its button.
|
|
302
|
+
entry.onclick = running ? () => switchToSession(sessionId) : () => send({ type: 'billion-start' });
|
|
303
|
+
const dot = document.createElement('span');
|
|
304
|
+
dot.className = 'explorer-dot';
|
|
305
|
+
// Red only for a Billion that ran and stopped; grey for one not started yet.
|
|
306
|
+
dot.style.background = stateColor(running ? agent.state : agent ? 'DISCONNECTED' : 'IDLE');
|
|
307
|
+
entry.appendChild(dot);
|
|
308
|
+
const name = document.createElement('span');
|
|
309
|
+
name.className = 'explorer-billion-name';
|
|
310
|
+
name.textContent = 'Billion';
|
|
311
|
+
entry.appendChild(name);
|
|
312
|
+
if (!running) {
|
|
313
|
+
const stopped = document.createElement('span');
|
|
314
|
+
stopped.className = 'explorer-branch-label';
|
|
315
|
+
stopped.textContent = 'stopped';
|
|
316
|
+
entry.appendChild(stopped);
|
|
317
|
+
const start = document.createElement('button');
|
|
318
|
+
start.className = 'explorer-icon-btn explorer-billion-start';
|
|
319
|
+
start.textContent = '\u25b6';
|
|
320
|
+
start.title = 'Start Billion';
|
|
321
|
+
start.setAttribute('aria-label', 'Start Billion');
|
|
322
|
+
start.onclick = (e) => {
|
|
323
|
+
e.stopPropagation();
|
|
324
|
+
send({ type: 'billion-start' });
|
|
325
|
+
};
|
|
326
|
+
entry.appendChild(start);
|
|
327
|
+
}
|
|
328
|
+
section.appendChild(entry);
|
|
329
|
+
content.appendChild(section);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function saveCollapsedRepos() {
|
|
333
|
+
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsedRepos]));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function toggleRepoCollapse(repoPath) {
|
|
337
|
+
if (collapsedRepos.has(repoPath)) collapsedRepos.delete(repoPath);
|
|
338
|
+
else collapsedRepos.add(repoPath);
|
|
339
|
+
saveCollapsedRepos();
|
|
340
|
+
renderExplorer();
|
|
341
|
+
// Re-render destroys the focused header node; refocus it so keyboard
|
|
342
|
+
// toggling works repeatedly, not just once.
|
|
343
|
+
for (const h of document.querySelectorAll('.explorer-repo-header')) {
|
|
344
|
+
if (h.dataset.repoKey === repoPath) { h.focus(); break; }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Clickable section header: ▸/▾ arrow + name, click toggles collapse
|
|
349
|
+
function makeRepoHeader(key, label) {
|
|
350
|
+
const isCollapsed = collapsedRepos.has(key);
|
|
351
|
+
const header = document.createElement('div');
|
|
352
|
+
header.className = 'explorer-repo-header';
|
|
353
|
+
header.onclick = () => toggleRepoCollapse(key);
|
|
354
|
+
// No role="button": the configured-repo header nests real buttons (+/×),
|
|
355
|
+
// and the button role makes descendants presentational (see learning
|
|
356
|
+
// job-card-role-button-hides-nested-controls). Keyboard path kept via
|
|
357
|
+
// tabindex + keydown instead.
|
|
358
|
+
header.tabIndex = 0;
|
|
359
|
+
header.dataset.repoKey = key;
|
|
360
|
+
header.setAttribute('aria-expanded', String(!isCollapsed));
|
|
361
|
+
header.onkeydown = (e) => {
|
|
362
|
+
if (e.target !== header) return; // let nested +/× buttons keep their keys
|
|
363
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
toggleRepoCollapse(key);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
const arrow = document.createElement('span');
|
|
369
|
+
arrow.className = 'explorer-toggle-arrow';
|
|
370
|
+
arrow.textContent = isCollapsed ? '▸' : '▾';
|
|
371
|
+
header.appendChild(arrow);
|
|
372
|
+
const headerName = document.createElement('span');
|
|
373
|
+
headerName.className = 'explorer-repo-name';
|
|
374
|
+
headerName.textContent = label;
|
|
375
|
+
header.appendChild(headerName);
|
|
376
|
+
return { header, isCollapsed };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Collapsed sections still surface entry count + most urgent agent state
|
|
380
|
+
const STATE_PRIORITY = ['MESSAGE', 'WAITING', 'WORKING', 'DISCONNECTED', 'IDLE'];
|
|
381
|
+
function makeCollapsedBadge(agentList, count) {
|
|
382
|
+
const badge = document.createElement('span');
|
|
383
|
+
badge.className = 'explorer-collapsed-badge';
|
|
384
|
+
const top = STATE_PRIORITY.find(s => agentList.some(a => a.state === s));
|
|
385
|
+
if (top) {
|
|
386
|
+
const dot = document.createElement('span');
|
|
387
|
+
dot.className = 'explorer-dot';
|
|
388
|
+
dot.style.background = stateColor(top);
|
|
389
|
+
badge.appendChild(dot);
|
|
390
|
+
}
|
|
391
|
+
badge.appendChild(document.createTextNode(String(count)));
|
|
392
|
+
return badge;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function stateColor(state) {
|
|
396
|
+
switch (state) {
|
|
397
|
+
case 'WORKING': return 'var(--state-working)';
|
|
398
|
+
case 'WAITING': return 'var(--state-waiting)';
|
|
399
|
+
case 'MESSAGE': return 'var(--state-message)';
|
|
400
|
+
case 'IDLE': return 'var(--state-idle)';
|
|
401
|
+
case 'DISCONNECTED': return 'var(--state-disconnected)';
|
|
402
|
+
default: return 'var(--state-idle)';
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function createBranchEntry(sessionId, agent) {
|
|
407
|
+
const isActive = sessionId === activeSessionId;
|
|
408
|
+
const isExpanded = expandedBranches.has(sessionId);
|
|
409
|
+
const wrapper = document.createElement('div');
|
|
410
|
+
wrapper.className = 'explorer-branch-wrapper';
|
|
411
|
+
|
|
412
|
+
// Branch header row — click to toggle file tree
|
|
413
|
+
const branchEl = document.createElement('div');
|
|
414
|
+
branchEl.className = `explorer-branch${isActive ? ' active' : ''}`;
|
|
415
|
+
if (isActive) branchEl.style.borderLeftColor = agent.color;
|
|
416
|
+
branchEl.onclick = () => {
|
|
417
|
+
if (expandedBranches.has(sessionId)) {
|
|
418
|
+
expandedBranches.delete(sessionId);
|
|
419
|
+
} else {
|
|
420
|
+
expandedBranches.add(sessionId);
|
|
421
|
+
}
|
|
422
|
+
renderExplorer();
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// Status dot
|
|
426
|
+
const dot = document.createElement('span');
|
|
427
|
+
dot.className = 'explorer-dot';
|
|
428
|
+
dot.style.background = stateColor(agent.state);
|
|
429
|
+
branchEl.appendChild(dot);
|
|
430
|
+
|
|
431
|
+
// Agent name (click to switch session)
|
|
432
|
+
const nameEl = document.createElement('span');
|
|
433
|
+
nameEl.className = 'explorer-branch-name';
|
|
434
|
+
nameEl.textContent = agent.name;
|
|
435
|
+
nameEl.onclick = (e) => {
|
|
436
|
+
e.stopPropagation();
|
|
437
|
+
switchToSession(sessionId);
|
|
438
|
+
if (expandedBranches.has(sessionId)) {
|
|
439
|
+
expandedBranches.delete(sessionId);
|
|
440
|
+
} else {
|
|
441
|
+
expandedBranches.add(sessionId);
|
|
442
|
+
}
|
|
443
|
+
renderExplorer();
|
|
444
|
+
};
|
|
445
|
+
branchEl.appendChild(nameEl);
|
|
446
|
+
|
|
447
|
+
// Branch label: "on branch/vesper"
|
|
448
|
+
if (agent.branchName) {
|
|
449
|
+
const onLabel = document.createElement('span');
|
|
450
|
+
onLabel.className = 'explorer-branch-label';
|
|
451
|
+
onLabel.textContent = `on ${agent.branchName}`;
|
|
452
|
+
branchEl.appendChild(onLabel);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Tree mode toggle switch (changes vs all files)
|
|
456
|
+
let toggleEl = null;
|
|
457
|
+
if (isExpanded && (agent.fileTree || fullTreeCache.has(sessionId))) {
|
|
458
|
+
const showFull = fullTreeSessions.has(sessionId);
|
|
459
|
+
const toggle = document.createElement('span');
|
|
460
|
+
toggle.className = 'explorer-tree-toggle';
|
|
461
|
+
|
|
462
|
+
const changesOpt = document.createElement('span');
|
|
463
|
+
changesOpt.className = 'explorer-tree-toggle-opt' + (!showFull ? ' active' : '');
|
|
464
|
+
changesOpt.textContent = 'changes';
|
|
465
|
+
changesOpt.onclick = (e) => {
|
|
466
|
+
e.stopPropagation();
|
|
467
|
+
fullTreeSessions.delete(sessionId);
|
|
468
|
+
renderExplorer();
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const allOpt = document.createElement('span');
|
|
472
|
+
allOpt.className = 'explorer-tree-toggle-opt' + (showFull ? ' active' : '');
|
|
473
|
+
allOpt.textContent = 'all';
|
|
474
|
+
allOpt.onclick = (e) => {
|
|
475
|
+
e.stopPropagation();
|
|
476
|
+
fullTreeSessions.add(sessionId);
|
|
477
|
+
if (!fullTreeCache.has(sessionId) && !fullTreePending.has(sessionId)) {
|
|
478
|
+
fullTreePending.add(sessionId);
|
|
479
|
+
send({ type: 'get-full-tree', sessionId });
|
|
480
|
+
}
|
|
481
|
+
renderExplorer();
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
toggle.appendChild(changesOpt);
|
|
485
|
+
toggle.appendChild(allOpt);
|
|
486
|
+
toggleEl = toggle;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
wrapper.appendChild(branchEl);
|
|
490
|
+
|
|
491
|
+
// File tree (if expanded)
|
|
492
|
+
if (isExpanded) {
|
|
493
|
+
const showFull = fullTreeSessions.has(sessionId);
|
|
494
|
+
const treeData = showFull ? fullTreeCache.get(sessionId) : agent.fileTree;
|
|
495
|
+
|
|
496
|
+
if (fullTreePending.has(sessionId)) {
|
|
497
|
+
const loading = document.createElement('div');
|
|
498
|
+
loading.className = 'explorer-diff-loading';
|
|
499
|
+
loading.style.paddingLeft = '16px';
|
|
500
|
+
loading.textContent = 'Loading file tree\u2026';
|
|
501
|
+
wrapper.appendChild(loading);
|
|
502
|
+
} else if (treeData) {
|
|
503
|
+
// Filter toggle gets its own row above the tree, so it no longer
|
|
504
|
+
// overlaps (and looks attached to) the first file/folder row.
|
|
505
|
+
if (toggleEl) {
|
|
506
|
+
const toggleRow = document.createElement('div');
|
|
507
|
+
toggleRow.className = 'explorer-tree-toggle-row';
|
|
508
|
+
toggleRow.appendChild(toggleEl);
|
|
509
|
+
wrapper.appendChild(toggleRow);
|
|
510
|
+
}
|
|
511
|
+
const treeEl = document.createElement('div');
|
|
512
|
+
treeEl.className = 'explorer-file-tree';
|
|
513
|
+
renderTreeNode(treeEl, treeData, sessionId, 1, showFull);
|
|
514
|
+
wrapper.appendChild(treeEl);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return wrapper;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function createOrphanEntry(orphanId, orphan) {
|
|
522
|
+
const wrapper = document.createElement('div');
|
|
523
|
+
wrapper.className = 'explorer-branch-wrapper explorer-orphan';
|
|
524
|
+
|
|
525
|
+
const row = document.createElement('div');
|
|
526
|
+
row.className = 'explorer-branch';
|
|
527
|
+
|
|
528
|
+
// Warning icon instead of toggle arrow
|
|
529
|
+
const warn = document.createElement('span');
|
|
530
|
+
warn.className = 'explorer-toggle-arrow';
|
|
531
|
+
warn.textContent = '\u26a0'; // ⚠
|
|
532
|
+
warn.style.color = 'var(--state-message)';
|
|
533
|
+
row.appendChild(warn);
|
|
534
|
+
|
|
535
|
+
// Dimmed dot
|
|
536
|
+
const dot = document.createElement('span');
|
|
537
|
+
dot.className = 'explorer-dot';
|
|
538
|
+
dot.style.background = 'var(--state-idle)';
|
|
539
|
+
row.appendChild(dot);
|
|
540
|
+
|
|
541
|
+
// Name
|
|
542
|
+
const nameEl = document.createElement('span');
|
|
543
|
+
nameEl.className = 'explorer-branch-name';
|
|
544
|
+
nameEl.textContent = orphan.name;
|
|
545
|
+
row.appendChild(nameEl);
|
|
546
|
+
|
|
547
|
+
// Reason badge
|
|
548
|
+
const badge = document.createElement('span');
|
|
549
|
+
badge.className = 'orphan-badge';
|
|
550
|
+
badge.textContent = orphan.reason;
|
|
551
|
+
row.appendChild(badge);
|
|
552
|
+
|
|
553
|
+
wrapper.appendChild(row);
|
|
554
|
+
|
|
555
|
+
// Action buttons
|
|
556
|
+
const actions = document.createElement('div');
|
|
557
|
+
actions.className = 'explorer-orphan-actions';
|
|
558
|
+
|
|
559
|
+
const respawnBtn = document.createElement('button');
|
|
560
|
+
respawnBtn.textContent = 'Re-spawn';
|
|
561
|
+
respawnBtn.onclick = (e) => {
|
|
562
|
+
e.stopPropagation();
|
|
563
|
+
send({ type: 're-adopt-orphan', orphanId });
|
|
564
|
+
};
|
|
565
|
+
actions.appendChild(respawnBtn);
|
|
566
|
+
|
|
567
|
+
const deleteBtn = document.createElement('button');
|
|
568
|
+
deleteBtn.className = 'orphan-delete-btn';
|
|
569
|
+
deleteBtn.textContent = 'Delete';
|
|
570
|
+
deleteBtn.onclick = (e) => {
|
|
571
|
+
e.stopPropagation();
|
|
572
|
+
if (confirm(`Delete orphan ${orphan.name}? This will remove the worktree and branch permanently.`)) {
|
|
573
|
+
send({ type: 'delete-orphan', orphanId });
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
actions.appendChild(deleteBtn);
|
|
577
|
+
|
|
578
|
+
wrapper.appendChild(actions);
|
|
579
|
+
return wrapper;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function renderTreeNode(container, node, sessionId, depth, fullTreeMode = false, parentPath = '') {
|
|
583
|
+
if (!node || !node.children) return;
|
|
584
|
+
// Sort: dirs first, then files, alphabetical
|
|
585
|
+
const sorted = [...node.children].sort((a, b) => {
|
|
586
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
587
|
+
return a.name.localeCompare(b.name);
|
|
588
|
+
});
|
|
589
|
+
for (const child of sorted) {
|
|
590
|
+
if (child.type === 'dir') {
|
|
591
|
+
const dirPath = parentPath ? `${parentPath}/${child.name}` : child.name;
|
|
592
|
+
const dirKey = `${sessionId}:${dirPath}`;
|
|
593
|
+
const isExpanded = expandedDirs.has(dirKey);
|
|
594
|
+
const dirEl = document.createElement('div');
|
|
595
|
+
dirEl.className = 'explorer-dir';
|
|
596
|
+
dirEl.style.paddingLeft = `${depth * 8}px`;
|
|
597
|
+
dirEl.textContent = (isExpanded ? '\u25be ' : '\u25b8 ') + child.name + '/';
|
|
598
|
+
dirEl.onclick = (e) => {
|
|
599
|
+
e.stopPropagation();
|
|
600
|
+
if (expandedDirs.has(dirKey)) expandedDirs.delete(dirKey);
|
|
601
|
+
else expandedDirs.add(dirKey);
|
|
602
|
+
renderExplorer();
|
|
603
|
+
};
|
|
604
|
+
container.appendChild(dirEl);
|
|
605
|
+
if (isExpanded) {
|
|
606
|
+
renderTreeNode(container, child, sessionId, depth + 1, fullTreeMode, dirPath);
|
|
607
|
+
}
|
|
608
|
+
} else {
|
|
609
|
+
const hasChange = child.status != null;
|
|
610
|
+
const fileEl = document.createElement('div');
|
|
611
|
+
fileEl.className = 'explorer-file';
|
|
612
|
+
fileEl.style.paddingLeft = `${depth * 8}px`;
|
|
613
|
+
|
|
614
|
+
// Status dot (only for changed files)
|
|
615
|
+
if (hasChange) {
|
|
616
|
+
const statusDot = document.createElement('span');
|
|
617
|
+
statusDot.className = 'explorer-status-dot';
|
|
618
|
+
statusDot.style.background = statusColor(child.status);
|
|
619
|
+
statusDot.setAttribute('aria-label', statusLabel(child.status));
|
|
620
|
+
fileEl.appendChild(statusDot);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Filename (dim unchanged files in full tree mode)
|
|
624
|
+
const fname = document.createElement('span');
|
|
625
|
+
fname.className = 'explorer-filename';
|
|
626
|
+
if (fullTreeMode && !hasChange) fname.style.opacity = '0.45';
|
|
627
|
+
fname.textContent = child.name;
|
|
628
|
+
fileEl.appendChild(fname);
|
|
629
|
+
|
|
630
|
+
// Conflict indicator
|
|
631
|
+
const agent = agents.get(sessionId);
|
|
632
|
+
if (agent && agent.conflicts && agent.conflicts.includes(child.path)) {
|
|
633
|
+
const warn = document.createElement('span');
|
|
634
|
+
warn.className = 'explorer-conflict';
|
|
635
|
+
warn.textContent = '\u26a0';
|
|
636
|
+
const otherConflict = conflicts.find(c =>
|
|
637
|
+
c.sessionIds.includes(sessionId) && c.files.includes(child.path)
|
|
638
|
+
);
|
|
639
|
+
if (otherConflict) {
|
|
640
|
+
const otherId = otherConflict.sessionIds.find(id => id !== sessionId);
|
|
641
|
+
const otherAgent = agents.get(otherId);
|
|
642
|
+
if (otherAgent) {
|
|
643
|
+
warn.title = `Also modified by ${otherAgent.name}`;
|
|
644
|
+
warn.onclick = (e) => {
|
|
645
|
+
e.stopPropagation();
|
|
646
|
+
switchToSession(otherId);
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
fileEl.appendChild(warn);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Click to open diff modal (only for changed files)
|
|
654
|
+
if (hasChange) {
|
|
655
|
+
const diffKey = `${sessionId}:${child.path}`;
|
|
656
|
+
fileEl.onclick = () => {
|
|
657
|
+
if (!pendingDiffs.has(diffKey)) {
|
|
658
|
+
pendingDiffs.add(diffKey);
|
|
659
|
+
send({ type: 'get-diff', sessionId, filePath: child.path, status: child.status });
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
fileEl.style.cursor = 'pointer';
|
|
663
|
+
}
|
|
664
|
+
container.appendChild(fileEl);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function renderDiffHtml(diff) {
|
|
670
|
+
if (!diff || !diff.trim()) return '<span class="diff-empty">No changes</span>';
|
|
671
|
+
return diff.split('\n').map(line => {
|
|
672
|
+
const escaped = line.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
673
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
674
|
+
return `<span class="diff-add">${escaped}</span>`;
|
|
675
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
676
|
+
return `<span class="diff-del">${escaped}</span>`;
|
|
677
|
+
} else if (line.startsWith('@@')) {
|
|
678
|
+
return `<span class="diff-hunk">${escaped}</span>`;
|
|
679
|
+
}
|
|
680
|
+
return escaped;
|
|
681
|
+
}).join('\n');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function showDiffViewer() {
|
|
685
|
+
if (!activeDiff) return;
|
|
686
|
+
const viewer = document.getElementById('diff-viewer');
|
|
687
|
+
const canvas = document.getElementById('office-canvas');
|
|
688
|
+
const empty = document.getElementById('office-empty');
|
|
689
|
+
const pathEl = document.getElementById('diff-viewer-path');
|
|
690
|
+
const contentEl = document.getElementById('diff-viewer-content');
|
|
691
|
+
|
|
692
|
+
pathEl.textContent = activeDiff.filePath;
|
|
693
|
+
contentEl.innerHTML = renderDiffHtml(activeDiff.diff);
|
|
694
|
+
|
|
695
|
+
canvas.style.display = 'none';
|
|
696
|
+
empty.style.display = 'none';
|
|
697
|
+
viewer.style.display = 'flex';
|
|
698
|
+
setView('office');
|
|
699
|
+
|
|
700
|
+
// Close button
|
|
701
|
+
document.getElementById('diff-viewer-close').onclick = closeDiffViewer;
|
|
702
|
+
|
|
703
|
+
// Escape to close
|
|
704
|
+
if (!closeDiffViewer._keyHandler) {
|
|
705
|
+
closeDiffViewer._keyHandler = (e) => { if (e.key === 'Escape' && viewer.style.display !== 'none') closeDiffViewer(); };
|
|
706
|
+
document.addEventListener('keydown', closeDiffViewer._keyHandler);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export function closeDiffViewer() {
|
|
711
|
+
activeDiff = null;
|
|
712
|
+
const viewer = document.getElementById('diff-viewer');
|
|
713
|
+
const canvas = document.getElementById('office-canvas');
|
|
714
|
+
const empty = document.getElementById('office-empty');
|
|
715
|
+
// A phone was moved to the office panel to read this; send it back to the
|
|
716
|
+
// file list it picked from (desktop CSS ignores the view).
|
|
717
|
+
if (viewer && viewer.style.display !== 'none') setView('files');
|
|
718
|
+
if (viewer) viewer.style.display = 'none';
|
|
719
|
+
if (canvas) canvas.style.display = '';
|
|
720
|
+
// Restore empty state if no agents
|
|
721
|
+
if (empty && agents.size === 0) empty.style.display = 'flex';
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function statusColor(status) {
|
|
725
|
+
switch (status) {
|
|
726
|
+
case 'M': return 'var(--state-working)'; // yellow
|
|
727
|
+
case 'A': return 'var(--state-waiting)'; // green
|
|
728
|
+
case 'D': return 'var(--state-disconnected)'; // red
|
|
729
|
+
case '?': return 'var(--state-idle)'; // gray
|
|
730
|
+
case 'R': return 'var(--state-message)'; // orange
|
|
731
|
+
default: return 'var(--state-idle)';
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function statusLabel(status) {
|
|
736
|
+
switch (status) {
|
|
737
|
+
case 'M': return 'modified';
|
|
738
|
+
case 'A': return 'added';
|
|
739
|
+
case 'D': return 'deleted';
|
|
740
|
+
case '?': return 'untracked';
|
|
741
|
+
case 'R': return 'renamed';
|
|
742
|
+
default: return status;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function openSpawnFormWithRepo(repoPath) {
|
|
747
|
+
const form = document.getElementById('spawn-form');
|
|
748
|
+
const repoInput = document.getElementById('spawn-repo');
|
|
749
|
+
form.style.display = 'flex';
|
|
750
|
+
if (repoInput) repoInput.value = repoPath;
|
|
751
|
+
const nameInput = document.getElementById('spawn-name');
|
|
752
|
+
if (nameInput) {
|
|
753
|
+
nameInput.value = '';
|
|
754
|
+
nameInput.focus();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
export function isExplorerVisible() {
|
|
759
|
+
return explorerVisible;
|
|
760
|
+
}
|