@henryz2004/agency 1.0.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/README.md +106 -0
- package/lib/codex.js +211 -0
- package/lib/control.js +168 -0
- package/lib/live.js +493 -0
- package/lib/opencode.js +447 -0
- package/lib/paths.js +12 -0
- package/lib/roster.js +204 -0
- package/lib/transcript.js +361 -0
- package/lib/usage.js +346 -0
- package/package.json +27 -0
- package/public/app.js +1021 -0
- package/public/audio-controls.js +165 -0
- package/public/avatar.js +467 -0
- package/public/characters/dev-auburn.json +32 -0
- package/public/characters/dev-auburn.png +0 -0
- package/public/characters/dev-beanie.json +32 -0
- package/public/characters/dev-beanie.png +0 -0
- package/public/characters/dev-glasses.json +32 -0
- package/public/characters/dev-glasses.png +0 -0
- package/public/chat-panel.css +514 -0
- package/public/chat-panel.js +815 -0
- package/public/index.html +190 -0
- package/public/lab.html +129 -0
- package/public/leaderboard.js +222 -0
- package/public/metric.js +34 -0
- package/public/mock-agents.js +70 -0
- package/public/mock.js +277 -0
- package/public/music/Console_Morning.mp3 +0 -0
- package/public/music/Midnight_Desk.mp3 +0 -0
- package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
- package/public/music/Three_AM_Window.mp3 +0 -0
- package/public/office.js +1484 -0
- package/public/sound.js +382 -0
- package/public/sprites.js +983 -0
- package/public/style.css +506 -0
- package/public/ui.js +50 -0
- package/scripts/_pixpng.mjs +104 -0
- package/scripts/animsheet.mjs +60 -0
- package/scripts/charsheet.mjs +61 -0
- package/scripts/install-hook.mjs +120 -0
- package/server.js +370 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
// chat-panel.js — read-only "open this agent's status" side panel.
|
|
2
|
+
//
|
|
3
|
+
// Listens for an `agency:select` CustomEvent (dispatched by office.js when a
|
|
4
|
+
// desk is clicked) and slides in a panel that summarizes the selected agent's
|
|
5
|
+
// truthful status. For a real Claude session it leads with a STATUS + METRICS
|
|
6
|
+
// card (honest state from the agent itself + a 30-min activity readout fetched
|
|
7
|
+
// from GET /api/transcript) — NOT a dump of the conversation. Reading is
|
|
8
|
+
// READ-ONLY. The one action it offers is "Open in Terminal", which POSTs
|
|
9
|
+
// /api/resume to jump back into the session via `claude --resume`.
|
|
10
|
+
//
|
|
11
|
+
// Agent kinds it handles:
|
|
12
|
+
// - Claude sessions (role null/lead, source 'claude', has sessionId+cwd) →
|
|
13
|
+
// status + metrics card (idle or working), plus the resume footer.
|
|
14
|
+
// - In-process teammates (role 'teammate', kind 'teammate') → have no
|
|
15
|
+
// transcript of their own; show their launch brief from the team config
|
|
16
|
+
// instead, plus the lead's resume hint.
|
|
17
|
+
// - opencode / codex agents → no Claude transcript on disk; show a short note.
|
|
18
|
+
//
|
|
19
|
+
// This module owns no render/camera state; it is a pure consumer of the
|
|
20
|
+
// selection event, so it stays decoupled from office.js.
|
|
21
|
+
|
|
22
|
+
const PANEL_ID = 'chatPanel';
|
|
23
|
+
|
|
24
|
+
let panelEl = null;
|
|
25
|
+
let bodyEl = null;
|
|
26
|
+
let titleEl = null;
|
|
27
|
+
let subEl = null;
|
|
28
|
+
let editEl = null; // header-level rename + hide strip (rebuilt per selection)
|
|
29
|
+
let currentKey = null; // selection key of the agent currently shown
|
|
30
|
+
let reqToken = 0; // guards against out-of-order fetch responses
|
|
31
|
+
let refreshTimer = null; // periodic /api/transcript re-fetch while the panel is open
|
|
32
|
+
const REFRESH_MS = 5000; // how often to refresh the live metrics/status readout
|
|
33
|
+
|
|
34
|
+
function esc(s) {
|
|
35
|
+
return String(s == null ? '' : s).replace(/[&<>"]/g, (c) =>
|
|
36
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Stable per-agent key, mirroring office.js's keyOf so selection lines up.
|
|
41
|
+
function keyOf(a) {
|
|
42
|
+
if (!a) return null;
|
|
43
|
+
return a.sessionId || (a.pid != null ? `pid:${a.pid}` : null);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function shortModel(m) {
|
|
47
|
+
if (!m) return '?';
|
|
48
|
+
return m
|
|
49
|
+
.replace(/^.+\//, '')
|
|
50
|
+
.replace('claude-', '')
|
|
51
|
+
.replace(/-\d{8}$/, '')
|
|
52
|
+
.replace(/\[1m\]/, ' 1M');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- DOM scaffold ---------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function build() {
|
|
58
|
+
panelEl = document.createElement('aside');
|
|
59
|
+
panelEl.id = PANEL_ID;
|
|
60
|
+
panelEl.className = 'chat-panel';
|
|
61
|
+
panelEl.setAttribute('aria-hidden', 'true');
|
|
62
|
+
panelEl.innerHTML = `
|
|
63
|
+
<div class="cp-head">
|
|
64
|
+
<div class="cp-headtop">
|
|
65
|
+
<div class="cp-id">
|
|
66
|
+
<div class="cp-title" id="cpTitle">—</div>
|
|
67
|
+
<div class="cp-sub" id="cpSub"></div>
|
|
68
|
+
</div>
|
|
69
|
+
<button type="button" class="cp-close" id="cpClose" title="Close (Esc)" aria-label="Close">✕</button>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="cp-edit" id="cpEdit"></div>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="cp-body" id="cpBody"></div>`;
|
|
74
|
+
document.body.appendChild(panelEl);
|
|
75
|
+
|
|
76
|
+
bodyEl = panelEl.querySelector('#cpBody');
|
|
77
|
+
titleEl = panelEl.querySelector('#cpTitle');
|
|
78
|
+
subEl = panelEl.querySelector('#cpSub');
|
|
79
|
+
editEl = panelEl.querySelector('#cpEdit');
|
|
80
|
+
|
|
81
|
+
panelEl.querySelector('#cpClose').addEventListener('click', () => close());
|
|
82
|
+
// Esc closes when the panel is open.
|
|
83
|
+
document.addEventListener('keydown', (e) => {
|
|
84
|
+
if (e.key === 'Escape' && panelEl.classList.contains('open')) close();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function open() {
|
|
89
|
+
panelEl.classList.add('open');
|
|
90
|
+
panelEl.setAttribute('aria-hidden', 'false');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function close() {
|
|
94
|
+
panelEl.classList.remove('open');
|
|
95
|
+
panelEl.setAttribute('aria-hidden', 'true');
|
|
96
|
+
currentKey = null;
|
|
97
|
+
reqToken++; // abandon any in-flight fetch's render
|
|
98
|
+
stopRefresh();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Stop the periodic metrics refresh (on close or before a new selection).
|
|
102
|
+
function stopRefresh() {
|
|
103
|
+
if (refreshTimer) {
|
|
104
|
+
clearInterval(refreshTimer);
|
|
105
|
+
refreshTimer = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Re-fetch /api/transcript and patch the activity card's metrics + "doing now"
|
|
110
|
+
// line IN PLACE (no body rebuild, so a half-typed rename is undisturbed). Used
|
|
111
|
+
// on an interval while the panel is open so a transient 0 (e.g. a stale entry
|
|
112
|
+
// crowding the metrics window for one tick) can't stick until the user
|
|
113
|
+
// re-selects. On a transient miss we keep the last good values — never blank.
|
|
114
|
+
function refreshMetrics(agent, token, card) {
|
|
115
|
+
if (token !== reqToken) return; // selection changed — this card is stale
|
|
116
|
+
const url = `/api/transcript?sessionId=${encodeURIComponent(agent.sessionId)}&cwd=${encodeURIComponent(agent.cwd)}`;
|
|
117
|
+
fetch(url, { cache: 'no-store' })
|
|
118
|
+
.then((r) => r.json())
|
|
119
|
+
.then((data) => {
|
|
120
|
+
if (token !== reqToken) return;
|
|
121
|
+
fillActivityCard(card, agent, data && data.lastAction, data && data.metrics);
|
|
122
|
+
})
|
|
123
|
+
.catch(() => {
|
|
124
|
+
/* transient miss — leave the last good readout in place */
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- copy affordance ------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
// Copy `text` to the clipboard and flash the button label. Falls back to a
|
|
131
|
+
// hidden textarea + execCommand when the async clipboard API is unavailable
|
|
132
|
+
// (e.g. non-secure contexts), so the affordance still works on 127.0.0.1.
|
|
133
|
+
function copyToClipboard(text, btn) {
|
|
134
|
+
const done = (ok) => {
|
|
135
|
+
if (!btn) return;
|
|
136
|
+
const prev = btn.dataset.label || btn.textContent;
|
|
137
|
+
btn.dataset.label = prev;
|
|
138
|
+
btn.textContent = ok ? '✓ copied' : '⚠ copy failed';
|
|
139
|
+
btn.classList.toggle('ok', ok);
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
btn.textContent = btn.dataset.label;
|
|
142
|
+
btn.classList.remove('ok');
|
|
143
|
+
}, 1400);
|
|
144
|
+
};
|
|
145
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
146
|
+
navigator.clipboard.writeText(text).then(() => done(true), () => fallbackCopy(text, done));
|
|
147
|
+
} else {
|
|
148
|
+
fallbackCopy(text, done);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fallbackCopy(text, done) {
|
|
153
|
+
try {
|
|
154
|
+
const ta = document.createElement('textarea');
|
|
155
|
+
ta.value = text;
|
|
156
|
+
ta.style.position = 'fixed';
|
|
157
|
+
ta.style.opacity = '0';
|
|
158
|
+
document.body.appendChild(ta);
|
|
159
|
+
ta.focus();
|
|
160
|
+
ta.select();
|
|
161
|
+
const ok = document.execCommand('copy');
|
|
162
|
+
document.body.removeChild(ta);
|
|
163
|
+
done(ok);
|
|
164
|
+
} catch {
|
|
165
|
+
done(false);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// One copy button. Returns the element; wires the click to copy `value`.
|
|
170
|
+
function copyButton(label, value, title) {
|
|
171
|
+
const b = document.createElement('button');
|
|
172
|
+
b.type = 'button';
|
|
173
|
+
b.className = 'cp-copy';
|
|
174
|
+
b.textContent = label;
|
|
175
|
+
if (title) b.title = title;
|
|
176
|
+
b.addEventListener('click', () => copyToClipboard(value, b));
|
|
177
|
+
return b;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- rendering ------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
function setHeader(a) {
|
|
183
|
+
const roleTag =
|
|
184
|
+
a.role === 'lead'
|
|
185
|
+
? '<span class="cp-role lead">PM</span>'
|
|
186
|
+
: a.role === 'teammate'
|
|
187
|
+
? '<span class="cp-role teammate">teammate</span>'
|
|
188
|
+
: '';
|
|
189
|
+
titleEl.innerHTML = `${esc(a.name || '—')} ${roleTag}`;
|
|
190
|
+
// Sub-line is project · model (the agent title — "Distinguished Eng" — was
|
|
191
|
+
// dropped as noise; project + model are what identify the desk).
|
|
192
|
+
const bits = [];
|
|
193
|
+
if (a.project) bits.push(`<span class="cp-dept">${esc(a.project)}</span>`);
|
|
194
|
+
if (a.model) bits.push(esc(shortModel(a.model)));
|
|
195
|
+
subEl.innerHTML = bits.join(' · ');
|
|
196
|
+
// The rename + hide strip lives in the header now (not the body), so the body
|
|
197
|
+
// is just status/transcript. Rebuilt per selection; self-gates to null.
|
|
198
|
+
editEl.innerHTML = '';
|
|
199
|
+
const cust = customizeControls(a);
|
|
200
|
+
if (cust) editEl.appendChild(cust);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A small "footer" of actions shown beneath any content. The primary action is
|
|
204
|
+
// "Open in Terminal" — it jumps you back into the agent via `claude --resume`
|
|
205
|
+
// (the one non-read-only call; see openTerminalButton). A "copy" button copies
|
|
206
|
+
// the full resume command for fallback; the command itself is NOT shown (too
|
|
207
|
+
// small to read, and you can't act on the raw text anyway). One clean row: a
|
|
208
|
+
// short note, then the terminal + copy buttons.
|
|
209
|
+
function actionsFor(a, transcript) {
|
|
210
|
+
const wrap = document.createElement('div');
|
|
211
|
+
wrap.className = 'cp-actions';
|
|
212
|
+
|
|
213
|
+
// Resume — only for a real Claude session with an id+cwd. A running BACKGROUND
|
|
214
|
+
// agent can't be --resume'd; you attach to it via `claude agents` instead.
|
|
215
|
+
const bg = a.kind === 'background' || a.kind === 'bg';
|
|
216
|
+
const resumeCmd = !(a.sessionId && a.cwd)
|
|
217
|
+
? null
|
|
218
|
+
: bg
|
|
219
|
+
? `cd ${a.cwd} && claude agents --cwd ${a.cwd}`
|
|
220
|
+
: (transcript && transcript.resumeCmd) || `cd ${a.cwd} && claude --resume ${a.sessionId}`;
|
|
221
|
+
if (a.role !== 'teammate' && a.source === 'claude' && a.sessionId && a.cwd && resumeCmd) {
|
|
222
|
+
// "Background agent" isn't a meaningful category — it's just an agent in agent
|
|
223
|
+
// mode, and it IS reachable. Frame by the SPAWN relationship (was it spawned by
|
|
224
|
+
// someone?), never as inaccessible. The terminal button below is the jump-in.
|
|
225
|
+
const row = document.createElement('div');
|
|
226
|
+
row.className = 'cp-action-row';
|
|
227
|
+
const note = document.createElement('div');
|
|
228
|
+
note.className = 'cp-action-note';
|
|
229
|
+
note.textContent = bg
|
|
230
|
+
? a.leadName
|
|
231
|
+
? `Spawned by ${a.leadName}.`
|
|
232
|
+
: 'Running in agent mode.'
|
|
233
|
+
: 'Resume to read or message it.';
|
|
234
|
+
row.appendChild(note);
|
|
235
|
+
row.appendChild(openTerminalButton(a));
|
|
236
|
+
// copy still copies the FULL resume command, even though it isn't shown.
|
|
237
|
+
row.appendChild(copyButton('copy', resumeCmd, 'Copy the resume command'));
|
|
238
|
+
wrap.appendChild(row);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return wrap.children.length ? wrap : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Open a terminal running `claude --resume` for this agent. This POSTs the one
|
|
245
|
+
// server ACTION endpoint (/api/resume) — the only non-read-only call the panel
|
|
246
|
+
// makes. The server validates id+cwd and spawns osascript→Terminal.
|
|
247
|
+
function openTerminalButton(a) {
|
|
248
|
+
const bg = a.kind === 'background' || a.kind === 'bg';
|
|
249
|
+
const b = document.createElement('button');
|
|
250
|
+
b.type = 'button';
|
|
251
|
+
b.className = 'cp-open';
|
|
252
|
+
b.textContent = bg ? 'Attach in Terminal ▶' : 'Open in Terminal ▶';
|
|
253
|
+
b.title = bg
|
|
254
|
+
? 'Open a terminal and attach to this running agent (claude agents)'
|
|
255
|
+
: 'Open a terminal running claude --resume for this agent';
|
|
256
|
+
b.addEventListener('click', () => {
|
|
257
|
+
const prev = b.textContent;
|
|
258
|
+
b.disabled = true;
|
|
259
|
+
b.textContent = 'opening…';
|
|
260
|
+
fetch('/api/resume', {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { 'content-type': 'application/json' },
|
|
263
|
+
body: JSON.stringify({ sessionId: a.sessionId, cwd: a.cwd, kind: a.kind }),
|
|
264
|
+
})
|
|
265
|
+
.then((r) => r.json().catch(() => ({})))
|
|
266
|
+
.then((d) => { b.textContent = d && d.ok ? '✓ opened' : '⚠ failed'; })
|
|
267
|
+
.catch(() => { b.textContent = '⚠ failed'; })
|
|
268
|
+
.finally(() => {
|
|
269
|
+
setTimeout(() => { b.textContent = prev; b.disabled = false; }, 1600);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
return b;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderNote(html) {
|
|
276
|
+
const n = document.createElement('div');
|
|
277
|
+
n.className = 'cp-note';
|
|
278
|
+
n.innerHTML = html;
|
|
279
|
+
return n;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---- customize controls (rename + hide) -----------------------------------
|
|
283
|
+
// A compact "edit this agent" strip rendered IN THE HEADER (see setHeader) for
|
|
284
|
+
// any agent with a sessionId (claude sessions, teammates, opencode, codex): a
|
|
285
|
+
// slim rename field + Save and a small hide toggle on one row, so the body stays
|
|
286
|
+
// pure status/transcript. Both controls persist server-side via POST
|
|
287
|
+
// /api/agent-override and only WRITE the field they touch:
|
|
288
|
+
// • RENAME → { sessionId, name } (name:"" resets to the minted roster name)
|
|
289
|
+
// • HIDE → { sessionId, hidden } (toggles agent.hidden; office.js owns state)
|
|
290
|
+
// Each control flashes a transient confirmation on its own button, mirroring the
|
|
291
|
+
// copyButton / openTerminalButton pattern, and never throws on a failed/non-JSON
|
|
292
|
+
// response (we guard the .json() like openTerminalButton does).
|
|
293
|
+
|
|
294
|
+
// POST a single override field and flash `btn` with the result. `prevText` is
|
|
295
|
+
// restored after the flash; `body` carries only the field being changed.
|
|
296
|
+
function postOverride(btn, body, prevText) {
|
|
297
|
+
btn.disabled = true;
|
|
298
|
+
fetch('/api/agent-override', {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
headers: { 'content-type': 'application/json' },
|
|
301
|
+
body: JSON.stringify(body),
|
|
302
|
+
})
|
|
303
|
+
.then((r) => r.json().catch(() => ({})))
|
|
304
|
+
.then((d) => { btn.textContent = d && d.ok ? '✓ saved' : '⚠ failed'; btn.classList.toggle('ok', !!(d && d.ok)); })
|
|
305
|
+
.catch(() => { btn.textContent = '⚠ failed'; btn.classList.remove('ok'); })
|
|
306
|
+
.finally(() => {
|
|
307
|
+
setTimeout(() => { btn.textContent = prevText; btn.classList.remove('ok'); btn.disabled = false; }, 1400);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function customizeControls(a) {
|
|
312
|
+
// Gate: only render for a selectable real agent carrying a sessionId.
|
|
313
|
+
if (!a || !a.sessionId) return null;
|
|
314
|
+
|
|
315
|
+
// One compact row: a slim rename field + Save, then a small hide toggle.
|
|
316
|
+
const wrap = document.createElement('div');
|
|
317
|
+
wrap.className = 'cp-customize cp-cust-row';
|
|
318
|
+
|
|
319
|
+
// --- rename: input pre-filled with the current name + a Save button. Saving
|
|
320
|
+
// an empty input clears the custom name (server resets to the minted name). ---
|
|
321
|
+
const input = document.createElement('input');
|
|
322
|
+
input.type = 'text';
|
|
323
|
+
input.className = 'cp-cust-input';
|
|
324
|
+
input.value = a.name || '';
|
|
325
|
+
input.placeholder = 'Rename…';
|
|
326
|
+
input.title = 'Rename this agent (clear to reset to its minted name)';
|
|
327
|
+
const save = document.createElement('button');
|
|
328
|
+
save.type = 'button';
|
|
329
|
+
save.className = 'cp-cust-save';
|
|
330
|
+
save.textContent = 'Save';
|
|
331
|
+
save.title = 'Save the new name';
|
|
332
|
+
const submitName = () => postOverride(save, { sessionId: a.sessionId, name: input.value.trim() }, 'Save');
|
|
333
|
+
save.addEventListener('click', submitName);
|
|
334
|
+
// Enter in the field saves too (blur is left alone so tabbing away is quiet).
|
|
335
|
+
input.addEventListener('keydown', (e) => {
|
|
336
|
+
if (e.key === 'Enter') { e.preventDefault(); submitName(); }
|
|
337
|
+
});
|
|
338
|
+
wrap.appendChild(input);
|
|
339
|
+
wrap.appendChild(save);
|
|
340
|
+
|
|
341
|
+
// --- hide: a small icon toggle reflecting agent.hidden. Optimistically flips
|
|
342
|
+
// its own glyph/label; the next /api/state poll reflects the real state. ---
|
|
343
|
+
const hide = document.createElement('button');
|
|
344
|
+
hide.type = 'button';
|
|
345
|
+
hide.className = 'cp-cust-hide';
|
|
346
|
+
const labelFor = (hidden) => (hidden ? '🙈' : '👁');
|
|
347
|
+
const titleFor = (hidden) => (hidden ? 'Unhide — show on the floor again' : 'Hide this agent from the office floor');
|
|
348
|
+
hide.textContent = labelFor(a.hidden);
|
|
349
|
+
hide.title = titleFor(a.hidden);
|
|
350
|
+
hide.addEventListener('click', () => {
|
|
351
|
+
const next = !a.hidden;
|
|
352
|
+
a.hidden = next; // optimistic; office.js reconciles on the next poll
|
|
353
|
+
const prev = labelFor(next);
|
|
354
|
+
hide.title = titleFor(next);
|
|
355
|
+
postOverride(hide, { sessionId: a.sessionId, hidden: next }, prev);
|
|
356
|
+
});
|
|
357
|
+
wrap.appendChild(hide);
|
|
358
|
+
|
|
359
|
+
return wrap;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---- status + metrics card ------------------------------------------------
|
|
363
|
+
// For EVERY real Claude session (idle OR working) the panel leads with this: a
|
|
364
|
+
// truthful status badge from the agent's OWN state, the task it's on, a 30-min
|
|
365
|
+
// activity readout (tool calls + tokens), and — ONLY when a tool is genuinely
|
|
366
|
+
// in flight — a live "doing now" line. We deliberately do NOT dump the
|
|
367
|
+
// conversation; the user wants honest status, not a transcript to read.
|
|
368
|
+
|
|
369
|
+
// The task/topic the agent is working toward — its AI chat title, the CLI task
|
|
370
|
+
// name, or the last user prompt, in that order of usefulness.
|
|
371
|
+
function taskLabel(a) {
|
|
372
|
+
return a.chatName || a.task || a.lastPrompt || null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Compact a count for the metrics line: <1000 as-is, else "12.3k", "1.2M".
|
|
376
|
+
function fmtCount(n) {
|
|
377
|
+
if (n == null || !isFinite(n)) return '—';
|
|
378
|
+
const abs = Math.abs(n);
|
|
379
|
+
if (abs < 1000) return String(n);
|
|
380
|
+
if (abs < 1e6) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
|
|
381
|
+
return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// One-word status + matching css class for the activity dot/headline.
|
|
385
|
+
function statusBits(a) {
|
|
386
|
+
if (a.activity === 'working') return { cls: 'working', icon: '🟢', text: 'Shipping now' };
|
|
387
|
+
if (a.activity === 'shell') return { cls: 'shell', icon: '⚙', text: 'Running a command' };
|
|
388
|
+
if (a.state === 'done') return { cls: 'done', icon: '✅', text: 'Finished' };
|
|
389
|
+
return { cls: 'idle', icon: '💤', text: 'Idle' };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Render the live "current action" line from a transcript lastAction object,
|
|
393
|
+
// e.g. "Editing app.js" / "Running npm test". Returns '' when we have none.
|
|
394
|
+
function actionLine(lastAction) {
|
|
395
|
+
if (!lastAction || !lastAction.verb) return '';
|
|
396
|
+
const verb = esc(lastAction.verb);
|
|
397
|
+
const target = lastAction.target ? `<span class="cp-act-target">${esc(lastAction.target)}</span>` : '';
|
|
398
|
+
return `${verb}${target ? ' ' + target : ''}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// The status + metrics header card. `lastAction` + `metrics` arrive with the
|
|
402
|
+
// transcript fetch; we render the card immediately (with both null) and patch
|
|
403
|
+
// them in once the fetch resolves, so the card never blocks on the network.
|
|
404
|
+
function renderActivityCard(a, lastAction, metrics) {
|
|
405
|
+
const s = statusBits(a);
|
|
406
|
+
const card = document.createElement('div');
|
|
407
|
+
card.className = `cp-activity ${s.cls}`;
|
|
408
|
+
|
|
409
|
+
const head = document.createElement('div');
|
|
410
|
+
head.className = 'cp-act-head';
|
|
411
|
+
head.innerHTML = `<span class="cp-act-dot ${s.cls}"></span><span class="cp-act-status">${s.icon} ${esc(s.text)}</span>`;
|
|
412
|
+
card.appendChild(head);
|
|
413
|
+
|
|
414
|
+
const task = taskLabel(a);
|
|
415
|
+
if (task) {
|
|
416
|
+
const t = document.createElement('div');
|
|
417
|
+
t.className = 'cp-act-task';
|
|
418
|
+
t.textContent = task;
|
|
419
|
+
card.appendChild(t);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// The headline 30-min activity readout; renders em-dashes until the fetch
|
|
423
|
+
// resolves, then patches in real counts (see show()).
|
|
424
|
+
const stat = document.createElement('div');
|
|
425
|
+
stat.className = 'cp-act-metrics';
|
|
426
|
+
setMetricsLine(stat, metrics);
|
|
427
|
+
card.appendChild(stat);
|
|
428
|
+
|
|
429
|
+
// The live "doing now" line — populated ONLY when a tool is genuinely in
|
|
430
|
+
// flight. Patched async; starts empty (no false "running" text on idle).
|
|
431
|
+
const act = document.createElement('div');
|
|
432
|
+
act.className = 'cp-act-now';
|
|
433
|
+
setActionLine(act, a, lastAction);
|
|
434
|
+
card.appendChild(act);
|
|
435
|
+
|
|
436
|
+
const sub = (a.subagents || []).length;
|
|
437
|
+
if (sub) {
|
|
438
|
+
const m = document.createElement('div');
|
|
439
|
+
m.className = 'cp-act-sub';
|
|
440
|
+
m.textContent = `🤖 ${sub} subagent${sub > 1 ? 's' : ''} running`;
|
|
441
|
+
card.appendChild(m);
|
|
442
|
+
}
|
|
443
|
+
return card;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Fill the metrics readout element from a `metrics` object. Missing metrics
|
|
447
|
+
// degrade to em-dashes rather than vanishing, so the headline stat stays put.
|
|
448
|
+
function setMetricsLine(el, metrics) {
|
|
449
|
+
const win = (metrics && metrics.windowMin) || 30;
|
|
450
|
+
const calls = metrics ? fmtCount(metrics.toolCalls30m) : '—';
|
|
451
|
+
const toks = metrics ? fmtCount(metrics.tokensOut30m) : '—';
|
|
452
|
+
el.innerHTML =
|
|
453
|
+
`<span class="cp-act-bolt">⚡</span> ${esc(calls)} tool calls · ` +
|
|
454
|
+
`${esc(toks)} tokens <span class="cp-act-win">· last ${esc(win)} min</span>`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Fill the "doing now" line. CRUCIAL honesty rule: when `lastAction` is null we
|
|
458
|
+
// print NOTHING (the status badge already says idle/running) — UNLESS the agent
|
|
459
|
+
// is genuinely mid-generation (activity 'working' with no tool in flight), in
|
|
460
|
+
// which case a subtle "thinking…" is fair. A null action on an idle/shell agent
|
|
461
|
+
// must never read as "doing now …".
|
|
462
|
+
function setActionLine(el, a, lastAction) {
|
|
463
|
+
const line = actionLine(lastAction);
|
|
464
|
+
if (line) {
|
|
465
|
+
el.innerHTML = `<span class="cp-act-label">doing now</span> ${line}`;
|
|
466
|
+
el.classList.remove('cp-act-empty');
|
|
467
|
+
} else if (a.activity === 'working') {
|
|
468
|
+
el.innerHTML = '<span class="cp-act-label">doing now</span> <span class="cp-dim">thinking…</span>';
|
|
469
|
+
el.classList.remove('cp-act-empty');
|
|
470
|
+
} else {
|
|
471
|
+
// Idle / shell with no in-flight tool — say nothing here.
|
|
472
|
+
el.textContent = '';
|
|
473
|
+
el.classList.add('cp-act-empty');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Patch an already-rendered card's metrics + action lines in place (called after
|
|
478
|
+
// the transcript fetch resolves). Mirrors the pre-render-then-patch pattern.
|
|
479
|
+
function fillActivityCard(card, a, lastAction, metrics) {
|
|
480
|
+
if (!card) return;
|
|
481
|
+
const stat = card.querySelector('.cp-act-metrics');
|
|
482
|
+
if (stat) setMetricsLine(stat, metrics);
|
|
483
|
+
const act = card.querySelector('.cp-act-now');
|
|
484
|
+
if (act) setActionLine(act, a, lastAction);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---- blocked-background banner --------------------------------------------
|
|
488
|
+
// A needsYou agent is a BACKGROUND agent blocked on the user (state:'blocked').
|
|
489
|
+
// Unlike awaitingReply it holds NO Stop-hook connection, so a reply box here
|
|
490
|
+
// couldn't reach it — the only way in is its own terminal. So instead of a reply
|
|
491
|
+
// box we show WHY it's blocked + point at the attach footer, reusing the amber
|
|
492
|
+
// reply styling so it reads as the same "waiting on you" class of signal.
|
|
493
|
+
function renderBlockedBanner(agent, hasAttach) {
|
|
494
|
+
const wrap = document.createElement('div');
|
|
495
|
+
wrap.className = 'cp-reply cp-blocked';
|
|
496
|
+
const head = document.createElement('div');
|
|
497
|
+
head.className = 'cp-reply-head';
|
|
498
|
+
head.innerHTML = '<span class="cp-reply-dot"></span> Blocked — waiting on you in its terminal.';
|
|
499
|
+
wrap.appendChild(head);
|
|
500
|
+
// Its parting question, when we have it (same source the reply box uses).
|
|
501
|
+
if (agent.pendingQuestion) {
|
|
502
|
+
const q = document.createElement('div');
|
|
503
|
+
q.className = 'cp-reply-q';
|
|
504
|
+
q.textContent = agent.pendingQuestion;
|
|
505
|
+
wrap.appendChild(q);
|
|
506
|
+
}
|
|
507
|
+
const hint = document.createElement('div');
|
|
508
|
+
hint.className = 'cp-reply-q cp-dim';
|
|
509
|
+
hint.textContent = hasAttach
|
|
510
|
+
? 'No live chat hold here — attach in its terminal below to respond and unblock it.'
|
|
511
|
+
: 'No live chat hold here — reach it in the terminal where it is running to respond.';
|
|
512
|
+
wrap.appendChild(hint);
|
|
513
|
+
return wrap;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---- reply box (Control Phase-1) ------------------------------------------
|
|
517
|
+
// When the selected agent is paused on a Stop hook (awaitingReply), render a
|
|
518
|
+
// reply box ABOVE the transcript: a textarea + Send that POSTs /api/reply
|
|
519
|
+
// { sessionId, text }. On success the agent is resumed with the typed text and
|
|
520
|
+
// the box collapses to a "sent" confirmation. This is the one place the panel
|
|
521
|
+
// writes anything (the authorized control surface, alongside /api/resume) — and
|
|
522
|
+
// it only resolves an already-held hook connection, never the ~/.claude data.
|
|
523
|
+
function renderReplyBox(agent) {
|
|
524
|
+
const wrap = document.createElement('div');
|
|
525
|
+
wrap.className = 'cp-reply';
|
|
526
|
+
|
|
527
|
+
const head = document.createElement('div');
|
|
528
|
+
head.className = 'cp-reply-head';
|
|
529
|
+
head.innerHTML =
|
|
530
|
+
'<span class="cp-reply-dot"></span> Waiting on your input. Reply to resume it.';
|
|
531
|
+
wrap.appendChild(head);
|
|
532
|
+
|
|
533
|
+
// Show the agent's parting question only when we actually have it
|
|
534
|
+
// (pendingQuestion). We deliberately DON'T fall back to lastPrompt/chatName —
|
|
535
|
+
// those are the last *user* prompt / chat title, not what the agent is asking,
|
|
536
|
+
// so labelling them as its question would mislead.
|
|
537
|
+
if (agent.pendingQuestion) {
|
|
538
|
+
const ctx = document.createElement('div');
|
|
539
|
+
ctx.className = 'cp-reply-q';
|
|
540
|
+
ctx.textContent = agent.pendingQuestion;
|
|
541
|
+
wrap.appendChild(ctx);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const ta = document.createElement('textarea');
|
|
545
|
+
ta.className = 'cp-reply-input';
|
|
546
|
+
ta.rows = 3;
|
|
547
|
+
ta.placeholder = 'Type your reply… (⌘/Ctrl+Enter to send)';
|
|
548
|
+
wrap.appendChild(ta);
|
|
549
|
+
|
|
550
|
+
const row = document.createElement('div');
|
|
551
|
+
row.className = 'cp-reply-row';
|
|
552
|
+
const status = document.createElement('span');
|
|
553
|
+
status.className = 'cp-reply-status';
|
|
554
|
+
const send = document.createElement('button');
|
|
555
|
+
send.type = 'button';
|
|
556
|
+
send.className = 'cp-reply-send';
|
|
557
|
+
send.textContent = 'Send ▶';
|
|
558
|
+
row.appendChild(status);
|
|
559
|
+
row.appendChild(send);
|
|
560
|
+
wrap.appendChild(row);
|
|
561
|
+
|
|
562
|
+
const doSend = () => {
|
|
563
|
+
const text = ta.value.trim();
|
|
564
|
+
if (!text) {
|
|
565
|
+
ta.focus();
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
send.disabled = true;
|
|
569
|
+
ta.disabled = true;
|
|
570
|
+
status.textContent = 'sending…';
|
|
571
|
+
status.className = 'cp-reply-status';
|
|
572
|
+
fetch('/api/reply', {
|
|
573
|
+
method: 'POST',
|
|
574
|
+
headers: { 'content-type': 'application/json' },
|
|
575
|
+
body: JSON.stringify({ sessionId: agent.sessionId, text }),
|
|
576
|
+
})
|
|
577
|
+
.then((r) => r.json().then((d) => ({ ok: r.ok, d })).catch(() => ({ ok: r.ok, d: {} })))
|
|
578
|
+
.then(({ ok, d }) => {
|
|
579
|
+
if (ok && d && d.ok) {
|
|
580
|
+
// Collapse to a confirmation; the next poll will drop awaitingReply.
|
|
581
|
+
wrap.innerHTML =
|
|
582
|
+
'<div class="cp-reply-sent">✓ sent — resuming the agent</div>';
|
|
583
|
+
} else {
|
|
584
|
+
status.textContent =
|
|
585
|
+
d && d.error === 'no agent waiting for this session'
|
|
586
|
+
? 'agent already resumed or timed out'
|
|
587
|
+
: '⚠ failed to send';
|
|
588
|
+
status.className = 'cp-reply-status err';
|
|
589
|
+
send.disabled = false;
|
|
590
|
+
ta.disabled = false;
|
|
591
|
+
}
|
|
592
|
+
})
|
|
593
|
+
.catch(() => {
|
|
594
|
+
status.textContent = '⚠ failed to send';
|
|
595
|
+
status.className = 'cp-reply-status err';
|
|
596
|
+
send.disabled = false;
|
|
597
|
+
ta.disabled = false;
|
|
598
|
+
});
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
send.addEventListener('click', doSend);
|
|
602
|
+
ta.addEventListener('keydown', (e) => {
|
|
603
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
604
|
+
e.preventDefault();
|
|
605
|
+
doSend();
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
// Autofocus so the reply box is immediately typeable on selection.
|
|
609
|
+
setTimeout(() => ta.focus(), 0);
|
|
610
|
+
return wrap;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// The agent's most recent turn(s), so you can tell at a glance which real
|
|
614
|
+
// session a desk maps to (the rest of the panel is honest-status only). Renders
|
|
615
|
+
// the last assistant + user turn from /api/transcript's `messages`, each clipped.
|
|
616
|
+
function renderLastMessages(messages) {
|
|
617
|
+
if (!Array.isArray(messages) || !messages.length) return null;
|
|
618
|
+
const recent = messages.slice(-2); // last turn + the prior one for context
|
|
619
|
+
const card = document.createElement('div');
|
|
620
|
+
card.className = 'cp-lastmsg';
|
|
621
|
+
const head = document.createElement('div');
|
|
622
|
+
head.className = 'cp-lastmsg-head';
|
|
623
|
+
head.textContent = recent.length > 1 ? 'Last messages' : 'Last message';
|
|
624
|
+
card.appendChild(head);
|
|
625
|
+
for (const m of recent) {
|
|
626
|
+
const assistant = m && m.role === 'assistant';
|
|
627
|
+
const row = document.createElement('div');
|
|
628
|
+
row.className = 'cp-msg ' + (assistant ? 'cp-msg-agent' : 'cp-msg-you');
|
|
629
|
+
const who = document.createElement('span');
|
|
630
|
+
who.className = 'cp-msg-who';
|
|
631
|
+
who.textContent = assistant ? 'agent' : 'you';
|
|
632
|
+
const txt = document.createElement('div');
|
|
633
|
+
txt.className = 'cp-msg-text';
|
|
634
|
+
const t = String((m && m.text) || '').replace(/\s+/g, ' ').trim();
|
|
635
|
+
txt.textContent = t.length > 300 ? t.slice(0, 299) + '…' : t;
|
|
636
|
+
row.appendChild(who);
|
|
637
|
+
row.appendChild(txt);
|
|
638
|
+
card.appendChild(row);
|
|
639
|
+
}
|
|
640
|
+
return card;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ---- selection flow -------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
function show(agent) {
|
|
646
|
+
if (!agent) {
|
|
647
|
+
close();
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const key = keyOf(agent);
|
|
651
|
+
// Clicking the already-open agent toggles the panel shut (matches the
|
|
652
|
+
// renderer's tap-to-deselect feel).
|
|
653
|
+
if (key && key === currentKey && panelEl.classList.contains('open')) {
|
|
654
|
+
close();
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
currentKey = key;
|
|
658
|
+
// Bump the request token on EVERY selection (not just the fetch path) so a
|
|
659
|
+
// still-in-flight Claude transcript response can't render into a later
|
|
660
|
+
// selection's panel (e.g. clicking a Claude agent then a teammate).
|
|
661
|
+
reqToken++;
|
|
662
|
+
stopRefresh(); // a prior agent's refresh loop must not patch this selection
|
|
663
|
+
setHeader(agent);
|
|
664
|
+
open();
|
|
665
|
+
|
|
666
|
+
// ---- in-process teammate: no transcript, show its launch brief ----------
|
|
667
|
+
if (agent.role === 'teammate' || agent.kind === 'teammate') {
|
|
668
|
+
bodyEl.innerHTML = '';
|
|
669
|
+
const lead = agent.leadName ? esc(agent.leadName) : 'its lead';
|
|
670
|
+
bodyEl.appendChild(
|
|
671
|
+
renderNote(`Spawned by ${lead} — part of their run. Its launch brief:`)
|
|
672
|
+
);
|
|
673
|
+
const brief = agent.lastPrompt || agent.prompt;
|
|
674
|
+
if (brief) {
|
|
675
|
+
const b = document.createElement('div');
|
|
676
|
+
b.className = 'cp-brief';
|
|
677
|
+
b.textContent = brief;
|
|
678
|
+
bodyEl.appendChild(b);
|
|
679
|
+
} else {
|
|
680
|
+
bodyEl.appendChild(renderNote('<span class="cp-dim">No launch brief on record.</span>'));
|
|
681
|
+
}
|
|
682
|
+
// (Rename/hide for this teammate, when it carries a sessionId-ish key, lives
|
|
683
|
+
// in the header strip now — see setHeader → customizeControls.)
|
|
684
|
+
// Reach path: a teammate runs INSIDE its lead's session, so jumping in =
|
|
685
|
+
// opening the lead. Reuse actionsFor with a lead-targeted pseudo-agent.
|
|
686
|
+
if (agent.leadSessionId && agent.cwd) {
|
|
687
|
+
const leadAgent = { ...agent, sessionId: agent.leadSessionId, kind: 'interactive', role: null, source: 'claude' };
|
|
688
|
+
const acts = actionsFor(leadAgent, null);
|
|
689
|
+
if (acts) bodyEl.appendChild(acts);
|
|
690
|
+
}
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ---- opencode / codex: no Claude transcript, but INLINE metrics ----------
|
|
695
|
+
if (agent.source && agent.source !== 'claude') {
|
|
696
|
+
// Non-Claude agents carry their 30-min metrics INLINE on the agent object
|
|
697
|
+
// (their adapter computes them), so render the same status + metrics card
|
|
698
|
+
// straight from `agent.metrics` — no /api/transcript fetch (that path is
|
|
699
|
+
// Claude-only). The Claude-vs-non-Claude shape asymmetry is INTENTIONAL: we
|
|
700
|
+
// don't recompute Claude metrics every poll, so only non-Claude is inline.
|
|
701
|
+
bodyEl.innerHTML = '';
|
|
702
|
+
bodyEl.appendChild(renderActivityCard(agent, null, agent.metrics || null));
|
|
703
|
+
const acts = actionsFor(agent, null);
|
|
704
|
+
if (acts) bodyEl.appendChild(acts);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ---- needsYou: a BACKGROUND agent blocked on the USER (state:'blocked') -----
|
|
709
|
+
// It holds NO Stop-hook connection, so the reply box can't resume it — the only
|
|
710
|
+
// way in is its own terminal. Surface that ACTIONABLE path. This sits ABOVE the
|
|
711
|
+
// cwd guard below so a cwd-less blocked agent still gets the attach note instead
|
|
712
|
+
// of dead-ending at "No transcript available". (awaitingReply — interactive,
|
|
713
|
+
// hook-held — keeps its reply box in the card path.) Net: every amber "!" on
|
|
714
|
+
// the floor leads to a real next step.
|
|
715
|
+
if (agent.needsYou && !agent.awaitingReply) {
|
|
716
|
+
bodyEl.innerHTML = '';
|
|
717
|
+
const acts = actionsFor(agent, null); // the attach-in-terminal footer = the unblock path
|
|
718
|
+
const banner = renderBlockedBanner(agent, !!acts);
|
|
719
|
+
bodyEl.appendChild(banner);
|
|
720
|
+
if (acts) bodyEl.appendChild(acts);
|
|
721
|
+
// A blocked BACKGROUND agent still has a transcript on disk — surface its last
|
|
722
|
+
// message(s) too (it used to show only the attach banner), so you can see what
|
|
723
|
+
// it's blocked on without attaching. Fetched async, inserted just below the banner.
|
|
724
|
+
if (agent.source === 'claude' && agent.sessionId && agent.cwd) {
|
|
725
|
+
const token = reqToken; // bumped at the top of show(); a newer selection bails this
|
|
726
|
+
const url = `/api/transcript?sessionId=${encodeURIComponent(agent.sessionId)}&cwd=${encodeURIComponent(agent.cwd)}`;
|
|
727
|
+
fetch(url, { cache: 'no-store' })
|
|
728
|
+
.then((r) => r.json())
|
|
729
|
+
.then((data) => {
|
|
730
|
+
if (token !== reqToken || !banner.parentNode) return; // superseded / detached
|
|
731
|
+
const lastMsgs = renderLastMessages(data && data.messages);
|
|
732
|
+
if (lastMsgs) banner.insertAdjacentElement('afterend', lastMsgs);
|
|
733
|
+
})
|
|
734
|
+
.catch(() => { /* fail-soft — keep the banner */ });
|
|
735
|
+
}
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ---- real Claude session: status + metrics card --------------------------
|
|
740
|
+
if (!agent.sessionId || !agent.cwd) {
|
|
741
|
+
bodyEl.innerHTML = '';
|
|
742
|
+
bodyEl.appendChild(renderNote('<span class="cp-dim">No transcript available for this agent.</span>'));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// CONTEXTUAL MODE — the panel adapts to the agent's state:
|
|
747
|
+
// • WAITING ON YOU (awaitingReply): reply box pinned on top, then the
|
|
748
|
+
// status + metrics card below it so you can answer in context.
|
|
749
|
+
// • IDLE / WORKING / done (and NOT waiting): just the status + metrics card.
|
|
750
|
+
// The status + metrics card builds for EVERY real Claude session (idle
|
|
751
|
+
// included) — its badge is the agent's OWN honest state, not derived from the
|
|
752
|
+
// fetch. The reply box + card render IMMEDIATELY (before the fetch) so the
|
|
753
|
+
// panel is useful the instant a desk is clicked; the live metrics + "doing
|
|
754
|
+
// now" line patch in once the transcript fetch resolves.
|
|
755
|
+
const waiting = !!agent.awaitingReply;
|
|
756
|
+
|
|
757
|
+
const replyBox = waiting ? renderReplyBox(agent) : null;
|
|
758
|
+
// The card is pre-built (metrics + action null) so we can patch them in async.
|
|
759
|
+
const activityCard = renderActivityCard(agent, null, null);
|
|
760
|
+
|
|
761
|
+
bodyEl.innerHTML = '';
|
|
762
|
+
if (replyBox) bodyEl.appendChild(replyBox);
|
|
763
|
+
bodyEl.appendChild(activityCard);
|
|
764
|
+
const acts = actionsFor(agent, null);
|
|
765
|
+
if (acts) bodyEl.appendChild(acts);
|
|
766
|
+
|
|
767
|
+
const token = reqToken; // already bumped at the top of show(); guards this fetch
|
|
768
|
+
const url = `/api/transcript?sessionId=${encodeURIComponent(agent.sessionId)}&cwd=${encodeURIComponent(agent.cwd)}`;
|
|
769
|
+
fetch(url, { cache: 'no-store' })
|
|
770
|
+
.then((r) => r.json())
|
|
771
|
+
.then((data) => {
|
|
772
|
+
if (token !== reqToken) return; // a newer selection superseded this one
|
|
773
|
+
// Patch the live metrics + "doing now" line into the card. lastAction is
|
|
774
|
+
// null whenever no tool is in flight; setActionLine handles that honestly.
|
|
775
|
+
fillActivityCard(activityCard, agent, data && data.lastAction, data && data.metrics);
|
|
776
|
+
// Rebuild the resume footer with the transcript's resumeCmd if present.
|
|
777
|
+
const newActs = actionsFor(agent, data);
|
|
778
|
+
bodyEl.innerHTML = '';
|
|
779
|
+
if (replyBox) bodyEl.appendChild(replyBox);
|
|
780
|
+
bodyEl.appendChild(activityCard);
|
|
781
|
+
// The last message(s) — surfaced so a desk is identifiable as a real session.
|
|
782
|
+
const lastMsgs = renderLastMessages(data && data.messages);
|
|
783
|
+
if (lastMsgs) bodyEl.appendChild(lastMsgs);
|
|
784
|
+
if (newActs) bodyEl.appendChild(newActs);
|
|
785
|
+
// The rename/hide strip lives in the header (editEl), which this rebuild
|
|
786
|
+
// leaves untouched — so a half-typed rename survives the metrics patch.
|
|
787
|
+
})
|
|
788
|
+
.catch(() => {
|
|
789
|
+
if (token !== reqToken) return;
|
|
790
|
+
// Couldn't reach /api/transcript — leave the honest status badge alone and
|
|
791
|
+
// just clear the pending metrics to em-dashes.
|
|
792
|
+
fillActivityCard(activityCard, agent, null, null);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// Keep the readout fresh while the panel stays open: re-fetch metrics + status
|
|
796
|
+
// on an interval so a momentary 0 can't stick until the user re-selects. Patches
|
|
797
|
+
// the card in place (see refreshMetrics) without rebuilding the body.
|
|
798
|
+
refreshTimer = setInterval(() => refreshMetrics(agent, token, activityCard), REFRESH_MS);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Normalize the event detail: accept either { agent } or the agent directly,
|
|
802
|
+
// and treat a null/absent agent as a deselect.
|
|
803
|
+
function agentFromEvent(e) {
|
|
804
|
+
const d = e && e.detail;
|
|
805
|
+
if (!d) return null;
|
|
806
|
+
if ('agent' in d) return d.agent;
|
|
807
|
+
// detail is the agent object itself (be liberal in what we accept).
|
|
808
|
+
return d.sessionId !== undefined || d.pid !== undefined ? d : null;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export function initChatPanel() {
|
|
812
|
+
if (panelEl) return; // idempotent
|
|
813
|
+
build();
|
|
814
|
+
window.addEventListener('agency:select', (e) => show(agentFromEvent(e)));
|
|
815
|
+
}
|