crewforth 3.0.3 → 3.1.0-rc.1
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/CHANGELOG.md +211 -0
- package/README.md +3 -3
- package/VERSION +1 -1
- package/adopt.sh +86 -34
- package/kit/CLAUDE.md +1 -1
- package/kit/README.md +1 -1
- package/kit/agents/crew-commit-agent.md +7 -3
- package/kit/agents/crew-frontend-expert.md +4 -4
- package/kit/agents/crew-performance-expert.md +1 -1
- package/kit/agents/crew-review-agent.md +1 -1
- package/kit/eval/doctor.sh +57 -3
- package/kit/eval/golden-routing.txt +13 -0
- package/kit/eval/lib/git-bash.sh +11 -2
- package/kit/eval/parser-conformance.sh +1 -0
- package/kit/eval/routing-eval.sh +7 -3
- package/kit/eval/smoke-test.sh +2545 -214
- package/kit/hooks/commit-msg +1 -1
- package/kit/hooks/context-usage.sh +7 -1
- package/kit/hooks/guard-bash.sh +1608 -103
- package/kit/hooks/guard-commit-scan.sh +138 -19
- package/kit/hooks/guard-powershell.sh +89 -15
- package/kit/hooks/guard-write.sh +98 -22
- package/kit/hooks/no-bash-guard.ps1 +71 -0
- package/kit/hooks/pre-commit +5 -2
- package/kit/hooks/prompt-approval.sh +484 -0
- package/kit/hooks/route-hint.sh +13 -5
- package/kit/hooks/session-rehydrate.sh +18 -0
- package/kit/hooks/skill-trust.sh +17 -2
- package/kit/owned-blobs.tsv +43 -0
- package/kit/settings.json +20 -4
- package/kit/skills/a11y/SKILL.md +1 -0
- package/kit/skills/crew-doctor/SKILL.md +2 -2
- package/kit/skills/frontend/SKILL.md +3 -3
- package/kit/skills/frontend-flutter/SKILL.md +94 -0
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
- package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
- package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
- package/kit/studio/server/hooks/studio-gate.sh +35 -1
- package/kit/studio/server/index.js +14 -4
- package/kit/studio/server/lib/graph.js +64 -3
- package/kit/studio/server/lib/kit.js +16 -2
- package/kit/studio/server/lib/permissions.js +77 -2
- package/kit/studio/server/lib/projects.js +60 -7
- package/kit/studio/server/lib/session.js +16 -2
- package/kit/studio/web/app.js +1765 -573
- package/kit/studio/web/approvals.js +124 -0
- package/kit/studio/web/canvas.js +932 -885
- package/kit/studio/web/chat.js +322 -145
- package/kit/studio/web/convo.js +174 -0
- package/kit/studio/web/dock.js +221 -0
- package/kit/studio/web/graph-plan.js +457 -0
- package/kit/studio/web/icons/builtin.svg +1 -0
- package/kit/studio/web/index.html +116 -50
- package/kit/studio/web/inspect.js +110 -0
- package/kit/studio/web/list-plan.js +112 -0
- package/kit/studio/web/list.js +157 -0
- package/kit/studio/web/liveness.js +66 -0
- package/kit/studio/web/nav.js +237 -0
- package/kit/studio/web/newsession.js +235 -0
- package/kit/studio/web/press.js +65 -0
- package/kit/studio/web/style.css +1281 -1014
- package/kit/studio/web/theme.js +43 -0
- package/kit/studio/web/timeline-plan.js +291 -0
- package/kit/studio/web/timeline.js +400 -0
- package/kit/studio/web/tokens.css +131 -0
- package/kit/studio/web/toolbar-fit.js +35 -0
- package/package.json +5 -4
package/kit/studio/web/app.js
CHANGED
|
@@ -6,45 +6,104 @@
|
|
|
6
6
|
// the kind of lie this panel exists to stop telling.
|
|
7
7
|
|
|
8
8
|
import { migrateStorage } from './storage-migrate.js';
|
|
9
|
+
import { initTheme } from './theme.js';
|
|
9
10
|
import { Canvas } from './canvas.js';
|
|
10
11
|
import { renderMarkdown } from './md.js';
|
|
11
12
|
import { Chat } from './chat.js';
|
|
13
|
+
import {
|
|
14
|
+
summaryChips, sessionStatus, sessionName, sessionSub, ago, matchesSession, highlight, badgeFor,
|
|
15
|
+
liveSessions, machines, seenAgo, isFirstRun,
|
|
16
|
+
} from './nav.js';
|
|
17
|
+
import { liveness, offlineNote } from './liveness.js';
|
|
18
|
+
import { attention, AUTO } from './graph-plan.js';
|
|
19
|
+
import { ServerClock, queue, settled, OUTCOME_WORD } from './approvals.js';
|
|
20
|
+
import { Dock } from './dock.js';
|
|
21
|
+
import { NewSession } from './newsession.js';
|
|
22
|
+
import { Timeline } from './timeline.js';
|
|
23
|
+
import { List } from './list.js';
|
|
24
|
+
import { defaultView, narrowWarning } from './list-plan.js';
|
|
25
|
+
import { fitLevel, runsOver } from './toolbar-fit.js';
|
|
26
|
+
import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
|
|
12
27
|
|
|
13
28
|
const FLEET_POLL_MS = 2000;
|
|
14
29
|
const SESSION_POLL_MS = 5000;
|
|
15
30
|
|
|
16
31
|
const el = {
|
|
17
32
|
fleet: document.getElementById('fleet'),
|
|
18
|
-
fleetMeta: document.getElementById('fleet-meta'),
|
|
19
33
|
sessions: document.getElementById('sessions'),
|
|
20
|
-
filter: document.getElementById('filter'),
|
|
21
|
-
kit: document.getElementById('kitline'),
|
|
22
34
|
reach: document.getElementById('reach'),
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
otherMachines: document.getElementById('other-machines'),
|
|
36
|
+
liveCount: document.getElementById('live-count'),
|
|
37
|
+
filter: document.getElementById('filter'),
|
|
25
38
|
chat: document.getElementById('chat'),
|
|
26
39
|
chatSplit: document.getElementById('chat-split'),
|
|
27
40
|
resizer: document.getElementById('resizer'),
|
|
28
|
-
sideWide: document.getElementById('side-wide'),
|
|
29
41
|
sideHide: document.getElementById('side-hide'),
|
|
30
42
|
sideShow: document.getElementById('side-show'),
|
|
43
|
+
rail: document.getElementById('side-rail'),
|
|
44
|
+
home: document.getElementById('home'),
|
|
45
|
+
bar: document.querySelector('.bar'),
|
|
46
|
+
crumb: document.getElementById('crumb'),
|
|
31
47
|
fullscreen: document.getElementById('fullscreen'),
|
|
32
48
|
newSession: document.getElementById('new-session'),
|
|
33
|
-
continueSession: document.getElementById('continue-session'),
|
|
34
|
-
sessionsMeta: document.getElementById('sessions-meta'),
|
|
35
49
|
summary: document.getElementById('graph-summary'),
|
|
36
50
|
pulse: document.getElementById('pulse'),
|
|
37
51
|
foot: document.getElementById('foot-note'),
|
|
38
52
|
theme: document.getElementById('theme'),
|
|
39
53
|
inspector: document.getElementById('inspector'),
|
|
54
|
+
toast: document.getElementById('toast'),
|
|
55
|
+
attention: document.getElementById('attention'),
|
|
56
|
+
dock: document.getElementById('dock'),
|
|
57
|
+
newPanel: document.getElementById('new-panel'),
|
|
58
|
+
tbGroup: document.getElementById('tb-group'),
|
|
59
|
+
tbDensity: document.getElementById('tb-density'),
|
|
60
|
+
tbShow: document.getElementById('tb-show'),
|
|
61
|
+
tbExpand: document.getElementById('tb-expand'),
|
|
62
|
+
tbFold: document.getElementById('tb-fold'),
|
|
63
|
+
tbZoomOut: document.getElementById('tb-zoom-out'),
|
|
64
|
+
tbZoom: document.getElementById('tb-zoom'),
|
|
65
|
+
tbZoomIn: document.getElementById('tb-zoom-in'),
|
|
66
|
+
tbFit: document.getElementById('tb-fit'),
|
|
67
|
+
canvasEl: document.getElementById('canvas'),
|
|
68
|
+
timeline: document.getElementById('timeline'),
|
|
69
|
+
viewGraph: document.getElementById('view-graph'),
|
|
70
|
+
viewTimeline: document.getElementById('view-timeline'),
|
|
71
|
+
viewList: document.getElementById('view-list'),
|
|
72
|
+
list: document.getElementById('list'),
|
|
73
|
+
firstRun: document.getElementById('first-run'),
|
|
74
|
+
stageNote: document.getElementById('stage-note'),
|
|
75
|
+
stage: document.querySelector('.stage'),
|
|
76
|
+
phoneHead: document.getElementById('phone-head'),
|
|
77
|
+
menuBtn: document.getElementById('menu-btn'),
|
|
78
|
+
tbOptions: document.getElementById('tb-options'),
|
|
79
|
+
toolbar: document.querySelector('.toolbar'),
|
|
80
|
+
tbFollow: document.getElementById('tb-follow'),
|
|
81
|
+
tbRangeOut: document.getElementById('tb-range-out'),
|
|
82
|
+
tbRange: document.getElementById('tb-range'),
|
|
83
|
+
tbRangeIn: document.getElementById('tb-range-in'),
|
|
84
|
+
menu: document.getElementById('menu'),
|
|
40
85
|
};
|
|
41
86
|
|
|
42
87
|
const token = new URLSearchParams(location.search).get('token');
|
|
43
88
|
const api = (p) => (token ? `${p}${p.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : p);
|
|
89
|
+
|
|
90
|
+
// When the server was last heard from, for the Live indicator. A request that does not complete is the
|
|
91
|
+
// connection failing; one that completes with an error status is the server answering, and is neither.
|
|
92
|
+
const heard = { okAt: null, failed: false };
|
|
93
|
+
const heardNow = () => { heard.okAt = Date.now(); heard.failed = false; };
|
|
94
|
+
|
|
44
95
|
const getJson = async (p) => {
|
|
45
|
-
|
|
96
|
+
let r;
|
|
97
|
+
try {
|
|
98
|
+
r = await fetch(api(p), { cache: 'no-store' });
|
|
99
|
+
} catch (e) {
|
|
100
|
+
heard.failed = true;
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
46
103
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
47
|
-
|
|
104
|
+
const body = await r.json();
|
|
105
|
+
heardNow();
|
|
106
|
+
return body;
|
|
48
107
|
};
|
|
49
108
|
|
|
50
109
|
/* ---------------------------------------------------------------- theme */
|
|
@@ -55,14 +114,14 @@ const store = {
|
|
|
55
114
|
set(k, v) { try { localStorage.setItem(k, v); } catch { /* blocked storage */ } },
|
|
56
115
|
};
|
|
57
116
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
117
|
+
// Handed the raw storage and not `store`: theme.js guards its own reads and
|
|
118
|
+
// writes, and a storage that cannot even be named is replaced by one that
|
|
119
|
+
// remembers nothing.
|
|
120
|
+
const systemLight = window.matchMedia('(prefers-color-scheme: light)');
|
|
121
|
+
let themeStorage = { getItem: () => null, setItem: () => {} };
|
|
122
|
+
try { themeStorage = localStorage; } catch { /* blocked storage */ }
|
|
123
|
+
const relabelTheme = initTheme(document.documentElement, el.theme, themeStorage, () => systemLight.matches);
|
|
124
|
+
systemLight.addEventListener('change', relabelTheme);
|
|
66
125
|
|
|
67
126
|
/* --------------------------------------------------------------- panels
|
|
68
127
|
Three columns and two dividers. Each width is the user's, remembered, and
|
|
@@ -71,8 +130,8 @@ el.theme.addEventListener('click', () => {
|
|
|
71
130
|
control. */
|
|
72
131
|
|
|
73
132
|
const PANEL = {
|
|
74
|
-
side: { min: 170, max: 620, def:
|
|
75
|
-
chat: { min: 280, max: 900, def:
|
|
133
|
+
side: { min: 170, max: 620, def: 272, wide: 460, varName: '--side-w', key: 'crewforth-studio-side-w' },
|
|
134
|
+
chat: { min: 280, max: 900, def: 480, wide: 720, varName: '--chat-w', key: 'crewforth-studio-chat-w' },
|
|
76
135
|
};
|
|
77
136
|
|
|
78
137
|
function setPanel(which, px, persist = true) {
|
|
@@ -93,13 +152,24 @@ const shell = document.querySelector('.shell');
|
|
|
93
152
|
// the canvas exists. `typeof canvas` is not a guard here: a const in its
|
|
94
153
|
// temporal dead zone throws on typeof too, which is what took the whole page
|
|
95
154
|
// down rather than skipping one re-fit.
|
|
96
|
-
|
|
155
|
+
//
|
|
156
|
+
// Below 1024px the navigator is its rail and opens over the canvas. That is the
|
|
157
|
+
// window's doing, not the viewer's choice, so nothing done in that band is
|
|
158
|
+
// remembered: widening the window brings back whatever was chosen at a width
|
|
159
|
+
// where there was a choice to make.
|
|
160
|
+
const railBand = window.matchMedia('(max-width: 1023px)');
|
|
161
|
+
|
|
162
|
+
function setSideHidden(hidden, refit = true, persist = !railBand.matches) {
|
|
97
163
|
shell.classList.toggle('no-side', hidden);
|
|
98
|
-
el.
|
|
99
|
-
store.set('crewforth-studio-side-hidden', hidden ? '1' : '0');
|
|
164
|
+
el.rail.hidden = !hidden;
|
|
165
|
+
if (persist) store.set('crewforth-studio-side-hidden', hidden ? '1' : '0');
|
|
100
166
|
if (refit) canvas.fitIfUntouched();
|
|
101
167
|
}
|
|
102
|
-
|
|
168
|
+
const applyRailBand = (refit) => setSideHidden(
|
|
169
|
+
railBand.matches || store.get('crewforth-studio-side-hidden') === '1', refit, false,
|
|
170
|
+
);
|
|
171
|
+
applyRailBand(false);
|
|
172
|
+
railBand.addEventListener('change', () => applyRailBand(true));
|
|
103
173
|
|
|
104
174
|
function dragPanel(handle, which, edge) {
|
|
105
175
|
let drag = null;
|
|
@@ -139,22 +209,11 @@ function dragPanel(handle, which, edge) {
|
|
|
139
209
|
dragPanel(el.resizer, 'side', 'left');
|
|
140
210
|
dragPanel(el.chatSplit, 'chat', 'right');
|
|
141
211
|
|
|
142
|
-
// Widen toggles between the default and a roomier width rather than growing
|
|
143
|
-
// without end: a panel you have to drag back is not a convenience.
|
|
144
|
-
function wideToggle(btn, which) {
|
|
145
|
-
btn.addEventListener('click', () => {
|
|
146
|
-
const cur = parseInt(getComputedStyle(document.documentElement).getPropertyValue(PANEL[which].varName), 10) || PANEL[which].def;
|
|
147
|
-
setPanel(which, Math.abs(cur - PANEL[which].wide) < 24 ? PANEL[which].def : PANEL[which].wide);
|
|
148
|
-
canvas.fitIfUntouched();
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
wideToggle(el.sideWide, 'side');
|
|
152
|
-
|
|
153
212
|
/* ------------------------------------------------------------ full screen
|
|
154
213
|
The browser's own, so it hides the browser too — a panel meant to be watched
|
|
155
|
-
while work runs should be able to take the whole display.
|
|
156
|
-
|
|
157
|
-
|
|
214
|
+
while work runs should be able to take the whole display. The stylesheet
|
|
215
|
+
takes the navigator, the inspector and the conversation off with it, so what
|
|
216
|
+
is left is the canvas; Esc brings everything back. */
|
|
158
217
|
|
|
159
218
|
async function toggleFullscreen() {
|
|
160
219
|
try {
|
|
@@ -170,9 +229,9 @@ el.fullscreen.addEventListener('click', toggleFullscreen);
|
|
|
170
229
|
|
|
171
230
|
document.addEventListener('fullscreenchange', () => {
|
|
172
231
|
const on = Boolean(document.fullscreenElement);
|
|
173
|
-
el.fullscreen.textContent = on ? '⛶' : '⛶';
|
|
174
232
|
el.fullscreen.classList.toggle('on', on);
|
|
175
233
|
el.fullscreen.title = on ? 'Leave full screen (f or Esc)' : 'Full screen (f)';
|
|
234
|
+
el.fullscreen.setAttribute('aria-label', on ? 'Leave full screen' : 'Full screen');
|
|
176
235
|
canvas.fitIfUntouched();
|
|
177
236
|
});
|
|
178
237
|
|
|
@@ -188,68 +247,57 @@ document.addEventListener('keydown', (e) => {
|
|
|
188
247
|
el.sideHide.addEventListener('click', () => setSideHidden(true));
|
|
189
248
|
el.sideShow.addEventListener('click', () => setSideHidden(false));
|
|
190
249
|
|
|
191
|
-
/* ------------------------------------------------------ sidebar sections
|
|
192
|
-
Fleet, peers and Projects each fold independently, so a machine with many
|
|
193
|
-
projects can hide the fleet and vice versa. The choice is per-browser and
|
|
194
|
-
survives a reload; nothing about it reaches the server. */
|
|
195
|
-
|
|
196
|
-
function setFold(key, folded) {
|
|
197
|
-
for (const body of document.querySelectorAll(`[data-fold-body="${key}"]`)) {
|
|
198
|
-
body.classList.toggle('is-folded', folded);
|
|
199
|
-
}
|
|
200
|
-
for (const t of document.querySelectorAll(`[data-fold="${key}"]`)) {
|
|
201
|
-
t.setAttribute('aria-expanded', String(!folded));
|
|
202
|
-
t.textContent = folded ? '▸' : '▾';
|
|
203
|
-
const name = t.getAttribute('aria-label')?.replace(/^(Collapse|Expand) /, '') ?? key;
|
|
204
|
-
t.setAttribute('aria-label', `${folded ? 'Expand' : 'Collapse'} ${name}`);
|
|
205
|
-
}
|
|
206
|
-
// A folded Projects list must stop claiming the leftover height, or the
|
|
207
|
-
// sidebar keeps a tall empty gap where the tree used to be.
|
|
208
|
-
const sect = document.querySelector(`.side-block[data-sect="${key}"]`);
|
|
209
|
-
if (sect) sect.classList.toggle('folded', folded);
|
|
210
|
-
try {
|
|
211
|
-
localStorage.setItem(`crewforth-studio-fold-${key}`, folded ? '1' : '0');
|
|
212
|
-
} catch { /* private mode: the fold still works, it just is not remembered */ }
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function foldState(key) {
|
|
216
|
-
try {
|
|
217
|
-
return localStorage.getItem(`crewforth-studio-fold-${key}`) === '1';
|
|
218
|
-
} catch {
|
|
219
|
-
return false;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
for (const t of document.querySelectorAll('[data-fold]')) {
|
|
224
|
-
const key = t.getAttribute('data-fold');
|
|
225
|
-
t.addEventListener('click', (e) => {
|
|
226
|
-
e.stopPropagation();
|
|
227
|
-
setFold(key, t.getAttribute('aria-expanded') !== 'false');
|
|
228
|
-
});
|
|
229
|
-
setFold(key, foldState(key));
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// The heading is the bigger target, so it toggles too — but only the heading:
|
|
233
|
-
// the meta count and the pin buttons beside it keep their own behaviour.
|
|
234
|
-
for (const h of document.querySelectorAll('[data-fold-for]')) {
|
|
235
|
-
h.addEventListener('click', () => {
|
|
236
|
-
const key = h.getAttribute('data-fold-for');
|
|
237
|
-
const t = document.querySelector(`[data-fold="${key}"]`);
|
|
238
|
-
setFold(key, t?.getAttribute('aria-expanded') !== 'false');
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
|
|
242
250
|
/* ----------------------------------------------------------------- chat
|
|
243
251
|
Write endpoints need the token and a header that a cross-origin page cannot
|
|
244
252
|
attach without a preflight this server never answers. */
|
|
245
253
|
|
|
246
254
|
const writeHeaders = { 'x-crew-studio': '1', ...(token ? { authorization: `Bearer ${token}` } : {}) };
|
|
247
|
-
|
|
255
|
+
// What the conversation is lent from the rest of the page: the navigator's names, the graph's agents, and the
|
|
256
|
+
// two ways out of a conversation Studio only reads. It knows none of those places itself.
|
|
257
|
+
const nodeOf = (sessionId, agentId) => (sessionId === current ? canvas.nodes.get(agentId) ?? null : null);
|
|
258
|
+
const chat = new Chat(el.chat, {
|
|
259
|
+
api,
|
|
260
|
+
headers: writeHeaders,
|
|
261
|
+
hooks: {
|
|
262
|
+
nameOf: (id) => sessionLabel(id),
|
|
263
|
+
agentsOf: (id) => (id === current ? lastNodes : null),
|
|
264
|
+
tileOf: (id, agentId) => { const n = nodeOf(id, agentId); return n ? canvas.tileFor(n) : null; },
|
|
265
|
+
statusOf: (id, agentId) => { const n = nodeOf(id, agentId); return n ? canvas.statusOf(n) : null; },
|
|
266
|
+
// A delegation card goes to its agent: the graph selects it, and the inspector takes the panel.
|
|
267
|
+
onAgent: (pane, agentId) => {
|
|
268
|
+
if (pane.id !== current) selectSession(pane.id);
|
|
269
|
+
goTo(agentId);
|
|
270
|
+
},
|
|
271
|
+
// The reminder in the conversation leads to the dock, where the answer is given.
|
|
272
|
+
onReview: () => el.dock.focus(),
|
|
273
|
+
// Did this page allow that call? Asked when Claude Code reports having refused it anyway.
|
|
274
|
+
wasAllowed: (sessionId, toolUseId, toolName) =>
|
|
275
|
+
(outcomes.get(sessionId) ?? []).some((o) => o.toolUseId === toolUseId && o.outcome.startsWith('allowed'))
|
|
276
|
+
|| (owned.get(sessionId)?.alwaysAllowed ?? []).includes(toolName),
|
|
277
|
+
onContinue: (pane, button) => continueHere(button, pane.id),
|
|
278
|
+
onTerminal: (pane, button) => offerTerminal(button, pane.id),
|
|
279
|
+
},
|
|
280
|
+
});
|
|
248
281
|
|
|
249
282
|
const ownedIds = new Set();
|
|
250
283
|
|
|
251
|
-
|
|
252
|
-
|
|
284
|
+
/* The right-hand panel is one thing at a time: the inspector, a conversation, or New session. Side by side with
|
|
285
|
+
the navigator they left the graph a third of the window; one at a time, the canvas keeps its width and the
|
|
286
|
+
way back is always one click — "Open conversation" from the inspector, a card or a node from the conversation.
|
|
287
|
+
Conversations that are not in front keep streaming. */
|
|
288
|
+
let right = 'none';
|
|
289
|
+
const restRight = () => (chat.ids.length ? 'conversation' : 'none');
|
|
290
|
+
function setRight(which) {
|
|
291
|
+
right = which;
|
|
292
|
+
const column = which === 'conversation' || which === 'new';
|
|
293
|
+
el.chat.hidden = which !== 'conversation';
|
|
294
|
+
el.newPanel.hidden = which !== 'new';
|
|
295
|
+
el.chatSplit.hidden = !column;
|
|
296
|
+
shell.classList.toggle('no-chat', !column);
|
|
297
|
+
el.inspector.hidden = which !== 'inspector';
|
|
298
|
+
canvas.fitIfUntouched();
|
|
299
|
+
}
|
|
300
|
+
|
|
253
301
|
// The conversation's own widen control lives in its header, beside the tabs.
|
|
254
302
|
chat.onGrow = () => {
|
|
255
303
|
const cur = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--chat-w'), 10) || PANEL.chat.def;
|
|
@@ -257,10 +305,12 @@ chat.onGrow = () => {
|
|
|
257
305
|
canvas.fitIfUntouched();
|
|
258
306
|
};
|
|
259
307
|
|
|
308
|
+
// Switching tabs points the canvas at that session too: the graph and the
|
|
309
|
+
// conversation are two views of one thing.
|
|
260
310
|
chat.onActivate = (sessionId) => {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
311
|
+
// The last tab closing gives the panel back; a tab coming forward takes it.
|
|
312
|
+
if (sessionId) setRight('conversation');
|
|
313
|
+
else if (right === 'conversation') setRight('none');
|
|
264
314
|
// Activating a tab is not a claim of ownership. It used to add the id here,
|
|
265
315
|
// which meant opening a read-only conversation marked that session as one the
|
|
266
316
|
// panel had started — and every later attempt to open it went down the owned
|
|
@@ -270,17 +320,14 @@ chat.onActivate = (sessionId) => {
|
|
|
270
320
|
|
|
271
321
|
/** Bring a session's conversation forward, however the panel can reach it. */
|
|
272
322
|
function openConversation(sessionId) {
|
|
273
|
-
if (chat.activeId === sessionId) return;
|
|
323
|
+
if (chat.activeId === sessionId) { setRight('conversation'); return; }
|
|
274
324
|
if (ownedIds.has(sessionId)) { openOwned(sessionId); return; }
|
|
275
325
|
openReadOnlyPane(sessionId);
|
|
276
326
|
}
|
|
277
327
|
|
|
278
328
|
function openReadOnlyPane(sessionId) {
|
|
279
|
-
const
|
|
280
|
-
chat.openReadOnly(sessionId,
|
|
281
|
-
el.chat.hidden = false;
|
|
282
|
-
el.chatSplit.hidden = false;
|
|
283
|
-
shell.classList.remove('no-chat');
|
|
329
|
+
const known = findSessionRow(sessionId);
|
|
330
|
+
chat.openReadOnly(sessionId, known ? sessionName(known.session, labels.all()) : null);
|
|
284
331
|
}
|
|
285
332
|
|
|
286
333
|
async function openOwned(sessionId) {
|
|
@@ -299,14 +346,13 @@ async function openOwned(sessionId) {
|
|
|
299
346
|
}
|
|
300
347
|
}
|
|
301
348
|
|
|
302
|
-
// Continuing a conversation the panel did not start.
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
const from = current;
|
|
349
|
+
// Continuing a conversation the panel did not start. A session nothing else holds is continued itself; one
|
|
350
|
+
// still open in a terminal somewhere is copied instead, so it is never written to by two things at once.
|
|
351
|
+
// Offered on the session in the inspector and at the foot of its read-only conversation.
|
|
352
|
+
async function continueHere(button, from = current) {
|
|
307
353
|
if (!from) return;
|
|
308
|
-
|
|
309
|
-
|
|
354
|
+
button.disabled = true;
|
|
355
|
+
button.textContent = 'Continuing…';
|
|
310
356
|
try {
|
|
311
357
|
const r = await chat.start({ cwd: projectsData?.cwd ?? null, permissionMode: 'plan', resume: from });
|
|
312
358
|
if (!r.ok) {
|
|
@@ -319,62 +365,305 @@ el.continueSession.addEventListener('click', async () => {
|
|
|
319
365
|
? `${from.slice(0, 8)} is open in another process, so this is a copy — `
|
|
320
366
|
+ `messages here do not reach it.`
|
|
321
367
|
: `continued ${from.slice(0, 8)} itself — same session, same transcript.`;
|
|
368
|
+
pollOwned();
|
|
322
369
|
}
|
|
323
370
|
} finally {
|
|
324
|
-
|
|
325
|
-
|
|
371
|
+
button.disabled = false;
|
|
372
|
+
button.textContent = 'Continue here';
|
|
326
373
|
}
|
|
327
|
-
}
|
|
374
|
+
}
|
|
328
375
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
376
|
+
/* New session: a panel, not a button that starts one. Where it runs, how much it may do and what it is asked
|
|
377
|
+
first are chosen before anything starts — and `plan` is what it opens on: a panel that can start a session
|
|
378
|
+
must not also be the reason one got write access nobody asked for. */
|
|
379
|
+
let offeredModes = [];
|
|
380
|
+
const newPanel = new NewSession(el.newPanel, {
|
|
381
|
+
shorten: (p) => shortPath(p, 44),
|
|
382
|
+
onClose: () => setRight(inspectorNode ? 'inspector' : restRight()),
|
|
383
|
+
onStart: async ({ cwd, permissionMode, first }) => {
|
|
384
|
+
const r = await chat.start({ cwd, permissionMode });
|
|
385
|
+
if (!r.ok) return r;
|
|
386
|
+
ownedIds.add(r.session.sessionId);
|
|
387
|
+
pollOwned();
|
|
388
|
+
// The first message goes the way every later one does, and shows the same way: as what the session
|
|
389
|
+
// says it received.
|
|
390
|
+
if (first) chat.panes.get(r.session.sessionId)?.sendText(first);
|
|
391
|
+
return r;
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
el.newSession.addEventListener('click', () => {
|
|
395
|
+
// Already open: a second click is not a second panel, and not a second session.
|
|
396
|
+
const opened = newPanel.open({
|
|
397
|
+
projects: projectsData?.projects ?? [],
|
|
398
|
+
modes: offeredModes,
|
|
399
|
+
preferKey: (current ? findSessionRow(current)?.project.key : null) ?? null,
|
|
400
|
+
});
|
|
401
|
+
if (opened) setRight('new');
|
|
343
402
|
});
|
|
344
403
|
|
|
345
404
|
/* --------------------------------------------------------------- canvas */
|
|
346
405
|
|
|
347
|
-
|
|
406
|
+
/* The toolbar says what the canvas is doing. It is painted from the canvas's own
|
|
407
|
+
state, so a choice restored from storage shows without being re-made. */
|
|
408
|
+
const GROUP_WORD = { run: 'Workflow run', type: 'Agent type', parent: 'Parent', none: 'None' };
|
|
409
|
+
const DENSITY_WORD = { auto: 'Auto', comfortable: 'Comfortable', compact: 'Compact' };
|
|
410
|
+
const SHOW = {
|
|
411
|
+
all: { word: 'All', statuses: null },
|
|
412
|
+
attention: { word: 'Needs attention', statuses: ['failed', 'killed', 'stopped'], waiting: true },
|
|
413
|
+
running: { word: 'Running', statuses: ['running', 'starting'] },
|
|
414
|
+
failed: { word: 'Failed', statuses: ['failed', 'killed', 'stopped'] },
|
|
415
|
+
};
|
|
416
|
+
let show = 'all';
|
|
417
|
+
const tbValue = {
|
|
418
|
+
group: el.tbGroup.querySelector('span'),
|
|
419
|
+
density: el.tbDensity.querySelector('span'),
|
|
420
|
+
show: el.tbShow.querySelector('span'),
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
function paintToolbar(st) {
|
|
424
|
+
tbValue.group.textContent = GROUP_WORD[st.group] ?? st.group;
|
|
425
|
+
// The grouping is one choice for both views: the Timeline's rows follow the graph's.
|
|
426
|
+
timeline.setGroup(st.group);
|
|
427
|
+
tbValue.density.textContent = DENSITY_WORD[st.density] ?? st.density;
|
|
428
|
+
// Auto is a choice the canvas makes; say which one it made, and why.
|
|
429
|
+
el.tbDensity.title = st.density === 'auto' && st.drawnAs
|
|
430
|
+
? `Auto: ${st.drawnAs}${st.stacked ? ', agents of one type folded together' : ''}. `
|
|
431
|
+
+ `Cards stay cards while the picture is no taller than ${AUTO.tallest}px.`
|
|
432
|
+
: '';
|
|
433
|
+
const pct = `${Math.round(st.zoom * 100)}%`;
|
|
434
|
+
el.tbZoom.textContent = pct;
|
|
435
|
+
el.tbZoom.setAttribute('aria-label', `Zoom ${pct}. Back to 100%`);
|
|
436
|
+
el.tbExpand.disabled = st.groups === 0;
|
|
437
|
+
el.tbFold.disabled = st.groups === 0;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Made before the canvas: the canvas reports its state while it is being built, and the toolbar passes the
|
|
441
|
+
// grouping on to the Timeline.
|
|
442
|
+
const timeline = new Timeline(el.timeline, {
|
|
443
|
+
tileOf: (n) => canvas.tileFor(n),
|
|
444
|
+
statusOf: (n) => canvas.statusOf(n),
|
|
445
|
+
detailOf: (n) => detailCache.get(`${n.id}:${n.status ?? '?'}`) ?? null,
|
|
446
|
+
// The drawer's "last error" is read from the agent's transcript, the same reading the inspector uses.
|
|
447
|
+
onSelect: (n) => { if (n?.kind === 'agent') loadDetail(n.id, n.status); },
|
|
448
|
+
onShowOnGraph: (n) => { setView('graph', n.id); },
|
|
449
|
+
onOpenConversation: () => { if (current) openConversation(current); },
|
|
450
|
+
onChange: paintTimelineBar,
|
|
451
|
+
fmt: { duration: fmtDuration, tokens: fmtTokens },
|
|
452
|
+
});
|
|
453
|
+
// Bars are placed on the server's clock: the timestamps they are drawn from are that machine's.
|
|
454
|
+
timeline.now = () => serverNow();
|
|
455
|
+
|
|
456
|
+
const canvas = new Canvas(document.getElementById('canvas'), {
|
|
457
|
+
onSelect: showInspector,
|
|
458
|
+
onChange: paintToolbar,
|
|
459
|
+
onMenu: (n, at) => {
|
|
460
|
+
if (!n) return;
|
|
461
|
+
const st = canvas.state();
|
|
462
|
+
const items = [];
|
|
463
|
+
if (n.kind === 'agent') {
|
|
464
|
+
items.push(st.focus
|
|
465
|
+
? { label: 'Show the whole graph', run: () => canvas.setFocus(null) }
|
|
466
|
+
: { label: 'Focus on this branch', run: () => canvas.setFocus(n.id) });
|
|
467
|
+
items.push({ label: 'Copy agent id', run: () => copyText(n.id, 'Copied the agent id') });
|
|
468
|
+
}
|
|
469
|
+
if (at.pinned) items.push({ label: 'Reset layout', run: () => canvas.resetLayout() });
|
|
470
|
+
if (!items.length) return;
|
|
471
|
+
openMenu({ getBoundingClientRect: () => ({ left: at.x, bottom: at.y }) }, items);
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// The mark for every agent that is not Crewforth's is one file, so that changing
|
|
476
|
+
// it is changing that file.
|
|
477
|
+
fetch('/icons/builtin.svg')
|
|
478
|
+
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
479
|
+
.then((svg) => canvas.setIcons({ builtin: svg }))
|
|
480
|
+
.catch(() => { /* the tile stays empty; its title still says whose agent it is */ });
|
|
481
|
+
|
|
482
|
+
const pickFrom = (button, words, current, set) => button.addEventListener('click', (e) => {
|
|
483
|
+
e.stopPropagation();
|
|
484
|
+
openMenu(button, Object.entries(words).map(([key, w]) => ({
|
|
485
|
+
label: typeof w === 'string' ? w : w.word, checked: current() === key, run: () => set(key),
|
|
486
|
+
})));
|
|
487
|
+
});
|
|
488
|
+
pickFrom(el.tbGroup, GROUP_WORD, () => canvas.state().group, (g) => canvas.setGroup(g));
|
|
489
|
+
pickFrom(el.tbDensity, DENSITY_WORD, () => canvas.state().density, (d) => canvas.setDensity(d));
|
|
490
|
+
pickFrom(el.tbShow, SHOW, () => show, (key) => setShow(key));
|
|
491
|
+
// The toolbar is measured, not assumed: it gives up a step at a time until what it shows fits (toolbar-fit.js).
|
|
492
|
+
// Every step is tried from the first, so a toolbar that has room again takes its words back.
|
|
493
|
+
function fitToolbar() {
|
|
494
|
+
const bar = el.toolbar;
|
|
495
|
+
const step = fitLevel((n) => {
|
|
496
|
+
bar.dataset.fit = String(n);
|
|
497
|
+
const box = bar.getBoundingClientRect();
|
|
498
|
+
if (!box.width) return false; // not on screen: nothing to fit
|
|
499
|
+
const shown = [...bar.children].filter((c) => !c.classList.contains('toolbar-fill') && !c.classList.contains('toolbar-note') && c.getClientRects().length);
|
|
500
|
+
return runsOver({ right: box.right, padRight: parseFloat(getComputedStyle(bar).paddingRight) || 0 }, shown.map((c) => c.getBoundingClientRect().right));
|
|
501
|
+
});
|
|
502
|
+
bar.dataset.fit = String(step);
|
|
503
|
+
}
|
|
504
|
+
let fitQueued = false;
|
|
505
|
+
function queueFit() {
|
|
506
|
+
if (fitQueued) return;
|
|
507
|
+
fitQueued = true;
|
|
508
|
+
// A frame later, so the write is not made inside an observer's own delivery.
|
|
509
|
+
requestAnimationFrame(() => { fitQueued = false; fitToolbar(); });
|
|
510
|
+
}
|
|
511
|
+
// Its width changes with the window and with the panels beside it; what it holds changes with the view (setView
|
|
512
|
+
// asks) and with the words of its menus. Those words are written on every report from the canvas, a zoom frame
|
|
513
|
+
// included, so a write is not a change: the toolbar is measured again only when the words are different ones.
|
|
514
|
+
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(queueFit).observe(el.toolbar);
|
|
515
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
516
|
+
const wordsNow = () => Object.values(tbValue).map((v) => v.textContent).join('|');
|
|
517
|
+
let fitWords = wordsNow();
|
|
518
|
+
const words = new MutationObserver(() => { const now = wordsNow(); if (now !== fitWords) { fitWords = now; queueFit(); } });
|
|
519
|
+
for (const v of Object.values(tbValue)) words.observe(v, { childList: true, characterData: true, subtree: true });
|
|
520
|
+
}
|
|
521
|
+
// A narrow toolbar drops Group, Density, Show, Expand and Fold. They are not gone: this one menu holds them.
|
|
522
|
+
el.tbOptions.addEventListener('click', (e) => {
|
|
523
|
+
e.stopPropagation();
|
|
524
|
+
const st = canvas.state();
|
|
525
|
+
const pick = (words, now, set) => Object.entries(words).map(([key, w]) => ({
|
|
526
|
+
label: typeof w === 'string' ? w : w.word, checked: now === key, run: () => set(key),
|
|
527
|
+
}));
|
|
528
|
+
const items = view === 'list' ? [] : [{ note: 'Group' }, ...pick(GROUP_WORD, st.group, (g) => canvas.setGroup(g))];
|
|
529
|
+
if (view === 'graph') items.push({ note: 'Density' }, ...pick(DENSITY_WORD, st.density, (d) => canvas.setDensity(d)));
|
|
530
|
+
items.push({ note: 'Show' }, ...pick(SHOW, show, (key) => setShow(key)));
|
|
531
|
+
if (view === 'graph' && st.groups > 0) {
|
|
532
|
+
items.push({ note: 'Groups' }, { label: 'Expand all', run: () => canvas.expandAll() }, { label: 'Fold all', run: () => canvas.foldAll() });
|
|
533
|
+
}
|
|
534
|
+
openMenu(el.tbOptions, items);
|
|
535
|
+
});
|
|
536
|
+
el.tbExpand.addEventListener('click', () => canvas.expandAll());
|
|
537
|
+
el.tbFold.addEventListener('click', () => canvas.foldAll());
|
|
538
|
+
el.tbZoomOut.addEventListener('click', () => canvas.zoomBy(1 / 1.2));
|
|
539
|
+
el.tbZoomIn.addEventListener('click', () => canvas.zoomBy(1.2));
|
|
540
|
+
el.tbZoom.addEventListener('click', () => canvas.zoomTo(1));
|
|
541
|
+
el.tbFit.addEventListener('click', () => canvas.fit());
|
|
542
|
+
|
|
543
|
+
/* --------------------------------------------------------------- views
|
|
544
|
+
The same session two ways: where its agents are, and when they were. The choice of what is selected, how it is
|
|
545
|
+
grouped and what is shown is one choice, kept across the switch. */
|
|
546
|
+
|
|
547
|
+
let view = 'graph';
|
|
548
|
+
|
|
549
|
+
// The List: the same agents by what they need from the reader. A waiting request is a card with its own three
|
|
550
|
+
// answers there, so the dock stands down while the List is the view.
|
|
551
|
+
const list = new List(el.list, {
|
|
552
|
+
tileOf: (n) => canvas.tileFor(n),
|
|
553
|
+
dimmed: (n) => canvas.dimmed(n),
|
|
554
|
+
// A row leads to the agent's detail: the inspector, which on a phone is a page of its own.
|
|
555
|
+
onSelect: (n) => { list.select(n.id); canvas.select(n.id); },
|
|
556
|
+
onDecide: (item, verdict) => decideRequest(item, verdict),
|
|
557
|
+
onLocate: (item) => { if (item.sessionId !== current) selectSession(item.sessionId); },
|
|
558
|
+
});
|
|
559
|
+
list.now = () => serverNow();
|
|
560
|
+
|
|
561
|
+
// Under 640px there is no room for the graph beside anything: the List is what a phone opens on.
|
|
562
|
+
const phoneBand = window.matchMedia('(max-width: 639px)');
|
|
563
|
+
|
|
564
|
+
function paintTimelineBar(st) {
|
|
565
|
+
el.tbRange.textContent = st.range;
|
|
566
|
+
el.tbRange.setAttribute('aria-label', `Time shown: ${st.range === 'all' ? 'the whole session' : st.range}`);
|
|
567
|
+
el.tbRangeIn.disabled = !st.canZoomIn;
|
|
568
|
+
el.tbRangeOut.disabled = !st.canZoomOut;
|
|
569
|
+
// The range is in the buttons' own names too: on a narrow toolbar the label between them is not shown.
|
|
570
|
+
el.tbRangeIn.setAttribute('aria-label', `Show less time (now ${st.range})`);
|
|
571
|
+
el.tbRangeOut.setAttribute('aria-label', `Show more time (now ${st.range})`);
|
|
572
|
+
el.tbFollow.setAttribute('aria-checked', String(st.follow));
|
|
573
|
+
// There is no "now" to follow in a session that has ended.
|
|
574
|
+
el.tbFollow.disabled = !st.live;
|
|
575
|
+
el.tbFollow.title = st.live ? 'Keep the view at now' : 'This session has ended: there is no now to follow';
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* @param select an agent to select in the view being opened; without one, what was selected stays selected
|
|
580
|
+
*/
|
|
581
|
+
function setView(next, select = null, remember = true) {
|
|
582
|
+
const was = view;
|
|
583
|
+
view = ['graph', 'timeline', 'list'].includes(next) ? next : 'graph';
|
|
584
|
+
// A view the window opened on by default is not a choice, and is not remembered as one.
|
|
585
|
+
if (remember) store.set('crewforth-studio-view', view);
|
|
586
|
+
const carried = select ?? (was === 'timeline' ? timeline.selected : was === 'list' ? list.selected : canvas.selected) ?? null;
|
|
587
|
+
el.canvasEl.hidden = view !== 'graph';
|
|
588
|
+
el.timeline.hidden = view !== 'timeline';
|
|
589
|
+
el.list.hidden = view !== 'list';
|
|
590
|
+
el.viewGraph.setAttribute('aria-selected', String(view === 'graph'));
|
|
591
|
+
el.viewTimeline.setAttribute('aria-selected', String(view === 'timeline'));
|
|
592
|
+
el.viewList.setAttribute('aria-selected', String(view === 'list'));
|
|
593
|
+
// Each control names the views it belongs to; the List has no grouping, so Group is not offered there.
|
|
594
|
+
for (const c of document.querySelectorAll('.toolbar [data-view]')) c.hidden = !c.dataset.view.split(' ').includes(view);
|
|
595
|
+
queueFit();
|
|
596
|
+
el.stage.dataset.view = view;
|
|
597
|
+
// The List's cards carry the answers; the dock would be the same requests a second time.
|
|
598
|
+
dock.setSuppressed(view === 'list');
|
|
599
|
+
measureDock();
|
|
600
|
+
paintStageNote();
|
|
601
|
+
|
|
602
|
+
if (view === 'timeline') {
|
|
603
|
+
// The drawer shows the selection here; the inspector gives the panel back.
|
|
604
|
+
if (right === 'inspector') setRight(restRight());
|
|
605
|
+
timeline.select(carried && canvas.nodes.get(carried)?.kind === 'agent' ? carried : null);
|
|
606
|
+
paintTimelineBar(timeline.state());
|
|
607
|
+
const n = timeline.selected ? canvas.nodes.get(timeline.selected) : null;
|
|
608
|
+
if (n) loadDetail(n.id, n.status);
|
|
609
|
+
} else if (view === 'list') {
|
|
610
|
+
list.select(carried && canvas.nodes.get(carried)?.kind === 'agent' ? carried : null);
|
|
611
|
+
list.render();
|
|
612
|
+
} else {
|
|
613
|
+
canvas.fitIfUntouched();
|
|
614
|
+
if (carried && canvas.nodes.has(carried)) canvas.focus(carried);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/** Bring an agent forward in whichever view is open: its card on the graph, or its row on the Timeline. */
|
|
618
|
+
function goTo(agentId) {
|
|
619
|
+
if (view === 'graph') { canvas.focus(agentId); return; }
|
|
620
|
+
if (view === 'list') { list.select(agentId); canvas.select(agentId); return; }
|
|
621
|
+
timeline.select(agentId);
|
|
622
|
+
const n = canvas.nodes.get(agentId);
|
|
623
|
+
if (n?.kind === 'agent') loadDetail(n.id, n.status);
|
|
624
|
+
}
|
|
625
|
+
el.viewGraph.addEventListener('click', () => setView('graph'));
|
|
626
|
+
el.viewTimeline.addEventListener('click', () => setView('timeline'));
|
|
627
|
+
el.viewList.addEventListener('click', () => setView('list'));
|
|
628
|
+
el.tbFollow.addEventListener('click', () => timeline.setFollow(el.tbFollow.getAttribute('aria-checked') !== 'true'));
|
|
629
|
+
// "Out" is more time on screen, "in" is less: the same way round as the graph's zoom.
|
|
630
|
+
el.tbRangeOut.addEventListener('click', () => timeline.zoom(1));
|
|
631
|
+
el.tbRangeIn.addEventListener('click', () => timeline.zoom(-1));
|
|
632
|
+
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => timeline.render())).observe(el.timeline);
|
|
348
633
|
|
|
349
634
|
getJson('/api/palette')
|
|
350
635
|
.then((p) => {
|
|
351
636
|
canvas.setPalette(p);
|
|
352
|
-
//
|
|
353
|
-
// one it is has to be said out loud, or
|
|
354
|
-
// as a fact about the agents.
|
|
637
|
+
// An unread palette makes every agent "unrecognised", and twelve of those
|
|
638
|
+
// read like twelve strangers. Which one it is has to be said out loud, or
|
|
639
|
+
// the panel is drawing "not measured" as a fact about the agents.
|
|
355
640
|
if (p && p.measured === false) {
|
|
356
641
|
document.body.dataset.paletteMeasured = 'false';
|
|
357
|
-
el.foot.textContent = `agent
|
|
642
|
+
el.foot.textContent = `agent identity Not measured — ${p.reason}`;
|
|
358
643
|
}
|
|
359
644
|
})
|
|
360
|
-
.catch(() => { /*
|
|
645
|
+
.catch(() => { /* every agent is drawn as unrecognised, which is what is known */ });
|
|
361
646
|
|
|
362
|
-
let inspectorTab = '
|
|
647
|
+
let inspectorTab = 'overview';
|
|
363
648
|
let inspectorNode = null;
|
|
364
649
|
let detailCache = new Map();
|
|
365
650
|
|
|
366
651
|
function showInspector(node) {
|
|
652
|
+
const before = inspectorNode;
|
|
367
653
|
inspectorNode = node;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
654
|
+
if (!node) {
|
|
655
|
+
list.select(null);
|
|
656
|
+
// Closing the inspector gives the panel back to the conversation, when one is open.
|
|
657
|
+
if (right === 'inspector') setRight(restRight());
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
setRight('inspector');
|
|
661
|
+
// A tab is a question being asked of the node. Another node of the same kind is asked the same question.
|
|
662
|
+
if (before?.kind !== node.kind) inspectorTab = 'overview';
|
|
375
663
|
paintInspector();
|
|
376
664
|
if (node.kind === 'agent') loadDetail(node.id, node.status);
|
|
377
|
-
|
|
665
|
+
// Gates and Stats are the session's, whichever node is selected in it.
|
|
666
|
+
if (current) loadKit(current);
|
|
378
667
|
}
|
|
379
668
|
|
|
380
669
|
// Keyed by status as well as id: a report fetched while the agent was still
|
|
@@ -394,6 +683,7 @@ async function loadDetail(agentId, status) {
|
|
|
394
683
|
detailCache.set(key, { measured: false, reason: e.message, transient: true });
|
|
395
684
|
}
|
|
396
685
|
if (inspectorNode?.id === agentId) paintInspector();
|
|
686
|
+
if (timeline.selected === agentId) timeline.paintDrawer();
|
|
397
687
|
}
|
|
398
688
|
|
|
399
689
|
function metaRows(n) {
|
|
@@ -406,10 +696,7 @@ function metaRows(n) {
|
|
|
406
696
|
} else {
|
|
407
697
|
row('type', n.agentType ?? 'unknown'); row('status', n.status);
|
|
408
698
|
row('depth', n.spawnDepth); row('model', n.model);
|
|
409
|
-
row('turns', n.turns); row('
|
|
410
|
-
row('tokens', n.tokens?.toLocaleString());
|
|
411
|
-
row('duration', n.durationMs != null ? fmtDur(n.durationMs) : null);
|
|
412
|
-
row('errors', n.errors || null);
|
|
699
|
+
row('turns', n.turns); row('last tool', n.lastTool);
|
|
413
700
|
row('agent id', n.id);
|
|
414
701
|
}
|
|
415
702
|
const dl = document.createElement('dl');
|
|
@@ -421,121 +708,236 @@ function metaRows(n) {
|
|
|
421
708
|
return dl;
|
|
422
709
|
}
|
|
423
710
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
711
|
+
/** A titled block of the inspector. */
|
|
712
|
+
function isection(title, ...children) {
|
|
713
|
+
const s = node('section', 'isec');
|
|
714
|
+
s.append(node('h4', 'isec-h', title), ...children);
|
|
715
|
+
return s;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** The agent transcript, as far as it has been read: null when it is there to use. */
|
|
719
|
+
function detailGap(n, detail) {
|
|
720
|
+
if (!detail || detail.loading) return node('div', 'ihint', 'Reading the agent transcript…');
|
|
721
|
+
if (detail.measured !== false) return null;
|
|
722
|
+
const hint = node('div', 'ihint');
|
|
723
|
+
hint.append(node('strong', null, 'Not measured'), node('div', null, detail.reason || 'no reason given'));
|
|
724
|
+
if (detail.transient) {
|
|
725
|
+
const retry = node('button', 'btn sm', 'Retry');
|
|
726
|
+
retry.type = 'button';
|
|
727
|
+
retry.addEventListener('click', () => {
|
|
728
|
+
detailCache.delete(`${n.id}:${n.status ?? '?'}`);
|
|
729
|
+
loadDetail(n.id, n.status);
|
|
730
|
+
});
|
|
731
|
+
hint.append(retry);
|
|
732
|
+
}
|
|
733
|
+
return hint;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Where a node's transcript is on disk, when this page was told. */
|
|
737
|
+
function transcriptPathOf(n, detail) {
|
|
738
|
+
if (n.kind === 'session') return findSessionRow(current)?.session?.file ?? null;
|
|
739
|
+
return detail?.transcriptPath ?? null;
|
|
427
740
|
}
|
|
428
741
|
|
|
429
742
|
function paintInspector() {
|
|
430
743
|
const n = inspectorNode;
|
|
431
744
|
if (!n) return;
|
|
745
|
+
const isSession = n.kind === 'session';
|
|
432
746
|
const detail = n.kind === 'agent' ? detailCache.get(`${n.id}:${n.status ?? '?'}`) : null;
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
head
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
wide.title = 'Widen';
|
|
442
|
-
wide.addEventListener('click', () => { el.inspector.classList.toggle('wide'); paintInspector(); });
|
|
443
|
-
// The inspector floats over the graph, so it needs a way out that is not
|
|
444
|
-
// "click the node again and hope you hit it".
|
|
445
|
-
const close = node('button', 'ghost iwide', '×');
|
|
446
|
-
close.title = 'Close';
|
|
747
|
+
const st = canvas.statusOf(n);
|
|
748
|
+
|
|
749
|
+
const head = node('div', 'ihead');
|
|
750
|
+
const close = node('button', 'btn icon sm ghost');
|
|
751
|
+
close.type = 'button';
|
|
752
|
+
close.setAttribute('aria-label', 'Close');
|
|
753
|
+
close.title = 'Close (Esc)';
|
|
754
|
+
close.append(icon(ICON.close));
|
|
447
755
|
close.addEventListener('click', () => { canvas.clearSelection(); showInspector(null); });
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
756
|
+
// On a phone the inspector is a page of its own, and the way out of a page is back.
|
|
757
|
+
const back = node('button', 'btn icon sm ghost ihead-back');
|
|
758
|
+
back.type = 'button';
|
|
759
|
+
back.setAttribute('aria-label', 'Back to the list');
|
|
760
|
+
back.append(icon(ICON.back));
|
|
761
|
+
back.addEventListener('click', () => { canvas.clearSelection(); showInspector(null); });
|
|
762
|
+
close.classList.add('ihead-close');
|
|
763
|
+
const name = isSession ? 'Session' : n.kind === 'workflow' ? (n.workflowId ?? 'Workflow run') : (n.agentType ?? 'unknown agent');
|
|
764
|
+
head.append(back, canvas.tileFor(n), node('h3', 'iname', name), close);
|
|
765
|
+
|
|
766
|
+
const meta = node('div', 'imeta');
|
|
767
|
+
const pill = node('span', 'pill');
|
|
768
|
+
// A state nobody read is said, not left as a blank pill.
|
|
769
|
+
pill.append(dot(st.tone), node('span', null, st.word ?? 'State not measured'));
|
|
770
|
+
meta.append(pill, node('span', 'sub', timeLine(n, serverNow())));
|
|
771
|
+
|
|
772
|
+
const task = node('div', 'itask', isSession
|
|
773
|
+
? (sessionLabel(current) ?? n.sessionId ?? '')
|
|
774
|
+
: n.kind === 'workflow' ? `${n.members ?? 0} agents` : (n.description ?? ''));
|
|
775
|
+
|
|
776
|
+
const tabs = node('div', 'itabs');
|
|
777
|
+
tabs.setAttribute('role', 'tablist');
|
|
778
|
+
for (const t of INSPECTOR_TABS) {
|
|
779
|
+
const b = node('button', 'itab', TAB_WORD[t]);
|
|
780
|
+
b.type = 'button';
|
|
781
|
+
b.setAttribute('role', 'tab');
|
|
782
|
+
b.setAttribute('aria-selected', String(inspectorTab === t));
|
|
459
783
|
b.addEventListener('click', () => { inspectorTab = t; paintInspector(); });
|
|
460
784
|
tabs.append(b);
|
|
461
785
|
}
|
|
462
786
|
|
|
463
|
-
const body =
|
|
464
|
-
body.
|
|
787
|
+
const body = node('div', 'ibody');
|
|
788
|
+
body.setAttribute('role', 'tabpanel');
|
|
789
|
+
if (inspectorTab === 'gates') paintGates(body);
|
|
790
|
+
else if (inspectorTab === 'stats') paintStats(body);
|
|
791
|
+
else if (inspectorTab === 'conversation') paintTranscript(body, n, detail);
|
|
792
|
+
else if (isSession) paintSessionOverview(body, n);
|
|
793
|
+
else paintAgentOverview(body, n, detail);
|
|
794
|
+
|
|
795
|
+
const foot = node('div', 'ifoot');
|
|
796
|
+
const open = node('button', 'btn', 'Open conversation');
|
|
797
|
+
open.type = 'button';
|
|
798
|
+
open.addEventListener('click', () => { if (current) openConversation(current); });
|
|
799
|
+
const file = transcriptPathOf(n, detail);
|
|
800
|
+
const copy = node('button', 'btn icon');
|
|
801
|
+
copy.type = 'button';
|
|
802
|
+
copy.append(icon(ICON.copy));
|
|
803
|
+
copy.setAttribute('aria-label', 'Copy the transcript path');
|
|
804
|
+
// A path nobody handed over is not built from parts here: the button says it has none.
|
|
805
|
+
copy.disabled = !file;
|
|
806
|
+
copy.title = file ? `Copy the transcript path\n${file}` : 'Transcript path not read yet';
|
|
807
|
+
copy.addEventListener('click', () => copyText(file, 'Copied'));
|
|
808
|
+
foot.append(open, copy);
|
|
809
|
+
|
|
810
|
+
el.inspector.replaceChildren(head, meta, task, tabs, body, foot);
|
|
811
|
+
}
|
|
465
812
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
try { await navigator.clipboard.writeText(detail.report); copy.textContent = 'copied'; }
|
|
498
|
-
catch { copy.textContent = 'blocked'; }
|
|
499
|
-
setTimeout(() => { copy.textContent = 'copy'; }, 1400);
|
|
500
|
-
});
|
|
501
|
-
bar.append(copy);
|
|
502
|
-
const md = node('div', 'md');
|
|
503
|
-
md.innerHTML = renderMarkdown(detail.report);
|
|
504
|
-
body.append(bar, md);
|
|
505
|
-
} else {
|
|
506
|
-
// An agent still working has narration but no conclusion. Showing the
|
|
507
|
-
// narration as a report would be inventing a result it never gave.
|
|
508
|
-
body.append(node('div', 'ihint',
|
|
509
|
-
n.status === 'running'
|
|
510
|
-
? 'Still working — no report yet. What it has said so far is under Activity.'
|
|
511
|
-
: 'This agent produced no closing report.'));
|
|
512
|
-
}
|
|
513
|
-
} else if (inspectorTab === 'activity') {
|
|
514
|
-
const list = node('div', 'itimeline');
|
|
515
|
-
if (!detail.timeline?.length) list.append(node('div', 'ihint', 'No tool calls recorded.'));
|
|
516
|
-
for (const [i, step] of (detail.timeline ?? []).entries()) {
|
|
517
|
-
const r = node('div', 'istep');
|
|
518
|
-
r.append(node('span', 'istep-n', String(i + 1)));
|
|
519
|
-
r.append(node('span', 'istep-tool', step.name));
|
|
520
|
-
r.append(node('span', 'istep-label', step.label ?? ''));
|
|
521
|
-
list.append(r);
|
|
522
|
-
}
|
|
523
|
-
if (detail.narration?.length) {
|
|
524
|
-
list.append(node('div', 'ihint', `${detail.narration.length} progress note(s)`));
|
|
525
|
-
for (const t of detail.narration) {
|
|
526
|
-
const md = node('div', 'md md-narration');
|
|
527
|
-
md.innerHTML = renderMarkdown(t);
|
|
528
|
-
list.append(md);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
body.append(list);
|
|
532
|
-
} else if (inspectorTab === 'prompt') {
|
|
533
|
-
const md = node('div', 'md');
|
|
534
|
-
md.innerHTML = renderMarkdown(detail.prompt ?? '');
|
|
535
|
-
body.append(detail.prompt ? md : node('div', 'ihint', 'No prompt recorded.'));
|
|
813
|
+
function paintAgentOverview(body, n, detail) {
|
|
814
|
+
const grid = node('div', 'itiles');
|
|
815
|
+
for (const t of tiles(n, serverNow())) {
|
|
816
|
+
const tile = node('div', 'itile');
|
|
817
|
+
const value = node('strong', null, t.value);
|
|
818
|
+
if (!t.measured) { tile.classList.add('unmeasured'); tile.title = `Not measured — ${t.why}`; }
|
|
819
|
+
if (t.bad) value.classList.add('bad');
|
|
820
|
+
tile.append(node('span', 'itile-k', t.label), value);
|
|
821
|
+
grid.append(tile);
|
|
822
|
+
}
|
|
823
|
+
body.append(grid);
|
|
824
|
+
|
|
825
|
+
const gap = n.kind === 'agent' ? detailGap(n, detail) : null;
|
|
826
|
+
const read = n.kind === 'agent' && !gap ? detail : null;
|
|
827
|
+
|
|
828
|
+
const now = rightNow(n, read);
|
|
829
|
+
const box = node('div', 'inow');
|
|
830
|
+
if (now.tool) box.append(node('span', 'inow-tool', now.tool));
|
|
831
|
+
box.append(node('span', 'inow-text', now.text || (gap ? '' : '—')));
|
|
832
|
+
body.append(isection(now.heading, box));
|
|
833
|
+
|
|
834
|
+
if (n.kind !== 'agent') { body.append(metaRows(n)); return; }
|
|
835
|
+
|
|
836
|
+
const skills = read ? skillsOf(read) : [];
|
|
837
|
+
const chips = node('div', 'ichips');
|
|
838
|
+
for (const s of skills) {
|
|
839
|
+
const chip = node('button', 'ichip', s);
|
|
840
|
+
chip.type = 'button';
|
|
841
|
+
chip.title = 'Copy the skill name';
|
|
842
|
+
chip.addEventListener('click', () => copyText(s, `Copied ${s}`));
|
|
843
|
+
chips.append(chip);
|
|
536
844
|
}
|
|
845
|
+
body.append(isection('Skills applied',
|
|
846
|
+
gap ?? (skills.length ? chips : node('div', 'ihint', 'None — this agent invoked no skill.'))));
|
|
847
|
+
|
|
848
|
+
const by = delegatedBy(n, [...canvas.nodes.values()]);
|
|
849
|
+
if (by) {
|
|
850
|
+
const link = node('button', 'ilink', by.text);
|
|
851
|
+
link.type = 'button';
|
|
852
|
+
link.addEventListener('click', () => canvas.focus(by.id));
|
|
853
|
+
body.append(isection('Delegated by', link));
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (gap) { body.append(isection('Report', node('div', 'ihint', 'Reading the agent transcript…'))); return; }
|
|
857
|
+
const rep = reportOf(n, read);
|
|
858
|
+
if (!rep.present) { body.append(isection('Report', node('div', 'ihint', rep.text))); return; }
|
|
859
|
+
const md = node('div', 'md');
|
|
860
|
+
md.innerHTML = renderMarkdown(rep.text);
|
|
861
|
+
const copy = node('button', 'btn sm ghost', 'Copy');
|
|
862
|
+
copy.type = 'button';
|
|
863
|
+
copy.addEventListener('click', () => copyText(rep.text, 'Copied the report'));
|
|
864
|
+
const sec = isection('Report', md);
|
|
865
|
+
sec.querySelector('.isec-h').append(copy);
|
|
866
|
+
body.append(sec);
|
|
867
|
+
}
|
|
537
868
|
|
|
538
|
-
|
|
869
|
+
function paintSessionOverview(body, n) {
|
|
870
|
+
if (current && !ownedIds.has(current)) {
|
|
871
|
+
// Said in the place the missing controls would have been: there is no approval dock for this session, and
|
|
872
|
+
// no way to write to it, and this is why.
|
|
873
|
+
const banner = node('div', 'ibanner');
|
|
874
|
+
banner.append(node('p', null, 'This session was not started here, so Studio can read it but not write to it.'));
|
|
875
|
+
const acts = node('div', 'ibanner-acts');
|
|
876
|
+
const cont = node('button', 'btn sm primary', 'Continue here');
|
|
877
|
+
cont.type = 'button';
|
|
878
|
+
cont.title = 'Start a new session holding a copy of this conversation. It does not write into the original.';
|
|
879
|
+
cont.addEventListener('click', () => continueHere(cont));
|
|
880
|
+
const term = node('button', 'btn sm', 'Open in terminal');
|
|
881
|
+
term.type = 'button';
|
|
882
|
+
term.addEventListener('click', (e) => { e.stopPropagation(); offerTerminal(term, current); });
|
|
883
|
+
acts.append(cont, term);
|
|
884
|
+
banner.append(acts);
|
|
885
|
+
body.append(banner);
|
|
886
|
+
}
|
|
887
|
+
body.append(metaRows(n));
|
|
888
|
+
|
|
889
|
+
const b = kitData?.board;
|
|
890
|
+
let board;
|
|
891
|
+
if (!kitData) board = node('div', 'ihint', 'Reading Crewforth…');
|
|
892
|
+
else if (!b?.measured) board = unmeasured(b?.reason);
|
|
893
|
+
else if (!b.present) board = node('div', 'ihint', b.text);
|
|
894
|
+
else board = node('pre', 'md-code', b.text);
|
|
895
|
+
body.append(isection('Board', board));
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** The agent's own transcript, read-only: what it was asked, what it did, what it said. */
|
|
899
|
+
function paintTranscript(body, n, detail) {
|
|
900
|
+
const full = node('button', 'btn sm', 'Open full conversation');
|
|
901
|
+
full.type = 'button';
|
|
902
|
+
full.addEventListener('click', () => { if (current) openConversation(current); });
|
|
903
|
+
|
|
904
|
+
if (n.kind !== 'agent') {
|
|
905
|
+
body.append(node('div', 'ihint', n.kind === 'session'
|
|
906
|
+
? 'The session\'s conversation is in the conversation panel.'
|
|
907
|
+
: 'A workflow run has no transcript of its own; its agents do.'), full);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const gap = detailGap(n, detail);
|
|
911
|
+
if (gap) { body.append(gap, full); return; }
|
|
912
|
+
|
|
913
|
+
const prompt = node('div', 'md');
|
|
914
|
+
prompt.innerHTML = renderMarkdown(detail.prompt ?? '');
|
|
915
|
+
body.append(isection('Asked', detail.prompt ? prompt : node('div', 'ihint', 'No prompt recorded.')));
|
|
916
|
+
|
|
917
|
+
const list = node('div', 'itimeline');
|
|
918
|
+
if (!detail.timeline?.length) list.append(node('div', 'ihint', 'No tool calls recorded.'));
|
|
919
|
+
for (const [i, step] of (detail.timeline ?? []).entries()) {
|
|
920
|
+
const r = node('div', 'istep');
|
|
921
|
+
r.append(node('span', 'istep-n', String(i + 1)), node('span', 'istep-tool', step.name), node('span', 'istep-label', step.label ?? ''));
|
|
922
|
+
list.append(r);
|
|
923
|
+
}
|
|
924
|
+
body.append(isection('Did', list));
|
|
925
|
+
|
|
926
|
+
const said = node('div', 'isaid');
|
|
927
|
+
for (const t of detail.narration ?? []) {
|
|
928
|
+
const md = node('div', 'md md-narration');
|
|
929
|
+
md.innerHTML = renderMarkdown(t);
|
|
930
|
+
said.append(md);
|
|
931
|
+
}
|
|
932
|
+
const rep = reportOf(n, detail);
|
|
933
|
+
if (rep.present) {
|
|
934
|
+
const md = node('div', 'md');
|
|
935
|
+
md.innerHTML = renderMarkdown(rep.text);
|
|
936
|
+
said.append(md);
|
|
937
|
+
} else {
|
|
938
|
+
said.append(node('div', 'ihint', rep.text));
|
|
939
|
+
}
|
|
940
|
+
body.append(isection('Said', said), full);
|
|
539
941
|
}
|
|
540
942
|
|
|
541
943
|
/* ------------------------------------------------------------------ kit
|
|
@@ -555,7 +957,7 @@ async function loadKit(sessionId) {
|
|
|
555
957
|
} catch (e) {
|
|
556
958
|
kitData = { measured: false, reason: e.message };
|
|
557
959
|
}
|
|
558
|
-
if (inspectorNode
|
|
960
|
+
if (inspectorNode) paintInspector();
|
|
559
961
|
}
|
|
560
962
|
|
|
561
963
|
function unmeasured(reason) {
|
|
@@ -566,71 +968,137 @@ function unmeasured(reason) {
|
|
|
566
968
|
return d;
|
|
567
969
|
}
|
|
568
970
|
|
|
569
|
-
function
|
|
971
|
+
function paintStats(body) {
|
|
570
972
|
if (!kitData) { body.append(node('div', 'ihint', 'Reading Crewforth…')); return; }
|
|
571
|
-
|
|
572
|
-
if (
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
973
|
+
const s = kitData.stats;
|
|
974
|
+
if (!s?.measured) {
|
|
975
|
+
const reason = s?.reason ?? kitData.reason ?? '';
|
|
976
|
+
const box = node('div', 'ihint');
|
|
977
|
+
box.append(node('strong', null, 'Not measured'), node('div', null, reason || 'no reason given'),
|
|
978
|
+
node('div', 'why', 'This is not a zero: there is nothing to read here.'));
|
|
979
|
+
// The script that measures this comes with a full install. That is said only when its absence is the
|
|
980
|
+
// reason, and no command is typed here: the page offers commands the server gave it, and it gave none.
|
|
981
|
+
if (/session-stats\.sh is not present|not installed/.test(reason)) {
|
|
982
|
+
box.append(node('div', 'why', 'A full install of Crewforth in this project adds it.'));
|
|
581
983
|
}
|
|
582
|
-
body.append(
|
|
583
|
-
body.append(node('div', 'ihint', 'session-stats.sh --raw, over this transcript.'));
|
|
984
|
+
body.append(box);
|
|
584
985
|
return;
|
|
585
986
|
}
|
|
987
|
+
const dl = document.createElement('dl');
|
|
988
|
+
for (const [k, v] of Object.entries(s.metrics)) {
|
|
989
|
+
const dt = node('dt', null, k.replace(/_/g, ' '));
|
|
990
|
+
const dd = node('dd', null, v.toLocaleString());
|
|
991
|
+
if (v > 0 && /runaway|errors|interrupts/.test(k)) dd.classList.add('bad');
|
|
992
|
+
dl.append(dt, dd);
|
|
993
|
+
}
|
|
994
|
+
body.append(dl);
|
|
995
|
+
body.append(node('div', 'ihint', 'session-stats.sh --raw, over this transcript.'));
|
|
996
|
+
}
|
|
586
997
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
998
|
+
async function revokeAllowance(tool, button) {
|
|
999
|
+
const sessionId = current;
|
|
1000
|
+
button.disabled = true;
|
|
1001
|
+
let res;
|
|
1002
|
+
try {
|
|
1003
|
+
const r = await fetch(api(`/api/owned/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(tool)}`), {
|
|
1004
|
+
method: 'DELETE', headers: writeHeaders,
|
|
1005
|
+
});
|
|
1006
|
+
res = await r.json();
|
|
1007
|
+
} catch (e) {
|
|
1008
|
+
res = { ok: false, reason: e.message };
|
|
1009
|
+
}
|
|
1010
|
+
if (!res.ok) {
|
|
1011
|
+
toast(`Not revoked — ${res.reason ?? 'no reason given'}`);
|
|
1012
|
+
button.disabled = false;
|
|
595
1013
|
return;
|
|
596
1014
|
}
|
|
1015
|
+
const sn = owned.get(sessionId);
|
|
1016
|
+
if (sn) owned.set(sessionId, { ...sn, alwaysAllowed: res.always ?? [] });
|
|
1017
|
+
toast(res.revoked ? `${tool} asks again from the next call` : `${tool} was already asking`);
|
|
1018
|
+
if (inspectorNode) paintInspector();
|
|
1019
|
+
}
|
|
597
1020
|
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
if (
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1021
|
+
function paintGates(body) {
|
|
1022
|
+
const mine = current ? owned.get(current) : null;
|
|
1023
|
+
|
|
1024
|
+
// What the viewer widened, first: it is the one thing on this tab that can be taken back.
|
|
1025
|
+
if (mine?.gated) {
|
|
1026
|
+
const allowed = mine.alwaysAllowed ?? [];
|
|
1027
|
+
const list = node('div', 'iallow');
|
|
1028
|
+
for (const tool of allowed) {
|
|
1029
|
+
const row = node('div', 'iallow-row');
|
|
1030
|
+
const revoke = node('button', 'btn sm', 'Revoke');
|
|
1031
|
+
revoke.type = 'button';
|
|
1032
|
+
revoke.setAttribute('aria-label', `Revoke ${tool}`);
|
|
1033
|
+
revoke.addEventListener('click', () => revokeAllowance(tool, revoke));
|
|
1034
|
+
row.append(node('span', 'iallow-tool', tool), node('span', 'sub', 'not asked about here'), node('span', 'row-fill'), revoke);
|
|
1035
|
+
list.append(row);
|
|
1036
|
+
}
|
|
1037
|
+
// What a session allowance is and is not: the dock stops asking, and nothing more. A call Claude Code
|
|
1038
|
+
// would itself have asked a person about is still refused, because nobody saw it.
|
|
1039
|
+
if (allowed.length) list.append(node('div', 'ihint', 'Studio does not ask about these again. Claude Code\'s own checks still apply to each call.'));
|
|
1040
|
+
body.append(isection('Allowed for this session',
|
|
1041
|
+
allowed.length ? list : node('div', 'ihint', 'Nothing — every tool call asks first.')));
|
|
1042
|
+
} else if (mine) {
|
|
1043
|
+
body.append(isection('Allowed for this session',
|
|
1044
|
+
node('div', 'ihint', 'This session runs without the approval gate, so nothing asks and nothing was allowed here.')));
|
|
607
1045
|
} else {
|
|
608
|
-
body.append(
|
|
1046
|
+
body.append(isection('Allowed for this session',
|
|
1047
|
+
node('div', 'ihint', 'This session was not started here, so its tool calls do not wait for an answer in Studio.')));
|
|
609
1048
|
}
|
|
610
1049
|
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
1050
|
+
// Happened: what was seen with a time on it. The answers given to requests come from this page; the hook
|
|
1051
|
+
// runs come from the session's own stream.
|
|
1052
|
+
const answers = (current ? outcomes.get(current) : null) ?? [];
|
|
1053
|
+
const live = mine?.gateEvents ?? chat.panes.get(current)?.session?.gateEvents ?? [];
|
|
1054
|
+
if (answers.length || live.length) {
|
|
1055
|
+
const rows = [];
|
|
1056
|
+
for (const a of answers.slice(-12)) {
|
|
617
1057
|
const row = node('div', 'gate-row');
|
|
618
|
-
row.append(node('span', 'gate-when', new Date(
|
|
1058
|
+
row.append(node('span', 'gate-when', new Date(a.at - clock.offset).toLocaleTimeString()));
|
|
1059
|
+
row.append(node('span', 'gate-name', `${a.toolName}${a.agentType ? ` · ${a.agentType}` : ''}`));
|
|
1060
|
+
const v = node('span', 'gate-verdict', OUTCOME_WORD[a.outcome]);
|
|
1061
|
+
v.dataset.verdict = a.outcome.startsWith('allowed') ? 'ALLOW' : a.outcome === 'answered-elsewhere' ? 'ASK' : 'BLOCK';
|
|
1062
|
+
row.append(v);
|
|
1063
|
+
rows.push({ at: a.at, row });
|
|
1064
|
+
}
|
|
1065
|
+
for (const e of live.slice(-12)) {
|
|
1066
|
+
const row = node('div', 'gate-row');
|
|
1067
|
+
row.append(node('span', 'gate-when', new Date(e.at - clock.offset).toLocaleTimeString()));
|
|
619
1068
|
row.append(node('span', 'gate-name', e.name ?? e.event ?? 'hook'));
|
|
620
1069
|
const v = node('span', 'gate-verdict', e.phase === 'started' ? 'ran' : (e.exitCode === 2 ? 'blocked' : e.outcome ?? 'done'));
|
|
621
1070
|
v.dataset.verdict = e.exitCode === 2 ? 'BLOCK' : 'ALLOW';
|
|
622
1071
|
row.append(v);
|
|
623
|
-
|
|
1072
|
+
rows.push({ at: e.at, row });
|
|
624
1073
|
}
|
|
1074
|
+
// One list, newest first: both kinds carry the server's time.
|
|
1075
|
+
rows.sort((x, y) => y.at - x.at);
|
|
1076
|
+
body.append(isection('Happened', ...rows.map((r) => r.row)));
|
|
625
1077
|
}
|
|
626
1078
|
|
|
627
|
-
if (!
|
|
1079
|
+
if (!kitData) { body.append(node('div', 'ihint', 'Reading Crewforth…')); return; }
|
|
1080
|
+
const log = kitData.log;
|
|
1081
|
+
const rep = kitData.report;
|
|
1082
|
+
|
|
1083
|
+
const observed = [];
|
|
1084
|
+
if (rep?.measured) {
|
|
1085
|
+
const head = node('div', 'kit-sum');
|
|
1086
|
+
head.append(node('span', 'cv-bit', `${rep.rules} rules`));
|
|
1087
|
+
head.append(node('span', 'cv-bit', `${(rep.decisions ?? 0).toLocaleString()} decisions`));
|
|
1088
|
+
observed.push(head);
|
|
1089
|
+
} else {
|
|
1090
|
+
observed.push(unmeasured(rep?.reason ?? kitData.reason));
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (!log?.measured) {
|
|
1094
|
+
observed.push(unmeasured(log?.reason ?? kitData.reason));
|
|
1095
|
+
body.append(isection('Observed', ...observed));
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
628
1098
|
|
|
629
|
-
const h = node('h4', 'md-h', 'Observed');
|
|
630
|
-
body.append(h);
|
|
631
1099
|
// The distinction is the point: this file has no timestamp column, so these
|
|
632
1100
|
// are decisions found in the log, not decisions seen happening.
|
|
633
|
-
|
|
1101
|
+
observed.push(node('div', 'ihint',
|
|
634
1102
|
`${log.total.toLocaleString()} in the tail of gate-log.tsv${log.truncated ? ' (truncated)' : ''} · `
|
|
635
1103
|
+ 'no timestamps in this format, so these are what the log holds, not when they ran'
|
|
636
1104
|
+ (log.commandsRecorded ? '' : ' · commands not recorded (CREW_GATE_LOG_CMD=1 records them)')));
|
|
@@ -641,7 +1109,7 @@ function paintKit(body, tab) {
|
|
|
641
1109
|
b.dataset.verdict = k;
|
|
642
1110
|
counts.append(b);
|
|
643
1111
|
}
|
|
644
|
-
|
|
1112
|
+
observed.push(counts);
|
|
645
1113
|
|
|
646
1114
|
for (const e of (log.entries ?? []).slice(0, 40)) {
|
|
647
1115
|
const row = node('div', 'gate-row');
|
|
@@ -650,11 +1118,16 @@ function paintKit(body, tab) {
|
|
|
650
1118
|
row.append(v);
|
|
651
1119
|
row.append(node('span', 'gate-name', e.rule ?? '—'));
|
|
652
1120
|
if (e.section) row.append(node('span', 'gate-sec', e.section));
|
|
653
|
-
|
|
1121
|
+
observed.push(row);
|
|
654
1122
|
}
|
|
1123
|
+
body.append(isection('Observed', ...observed));
|
|
655
1124
|
}
|
|
656
1125
|
|
|
657
|
-
/*
|
|
1126
|
+
/* ------------------------------------------------------------ navigator
|
|
1127
|
+
One search box, three tabs. Projects is everything on this machine; Live is
|
|
1128
|
+
what is working or waiting right now, whichever project it is in; Machines
|
|
1129
|
+
is what is known about the others. The words and the rules are in nav.js —
|
|
1130
|
+
this is where they are drawn. */
|
|
658
1131
|
|
|
659
1132
|
function node(tag, cls, text) {
|
|
660
1133
|
const n = document.createElement(tag);
|
|
@@ -680,62 +1153,443 @@ function shortPath(p, max = 34) {
|
|
|
680
1153
|
return `${s.slice(0, keep)}…${s.slice(-keep)}`;
|
|
681
1154
|
}
|
|
682
1155
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1156
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
1157
|
+
function icon(d) {
|
|
1158
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
1159
|
+
svg.setAttribute('class', 'ic');
|
|
1160
|
+
svg.setAttribute('viewBox', '0 0 16 16');
|
|
1161
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
1162
|
+
const path = document.createElementNS(SVG_NS, 'path');
|
|
1163
|
+
path.setAttribute('d', d);
|
|
1164
|
+
svg.append(path);
|
|
1165
|
+
return svg;
|
|
1166
|
+
}
|
|
1167
|
+
const ICON = {
|
|
1168
|
+
close: 'M4 4l8 8M12 4l-8 8',
|
|
1169
|
+
back: 'M10 4l-4 4 4 4',
|
|
1170
|
+
copy: 'M5.5 5.5v-2A1.5 1.5 0 0 1 7 2h5.5A1.5 1.5 0 0 1 14 3.5V9a1.5 1.5 0 0 1-1.5 1.5h-2M3.5 5.5H9A1.5 1.5 0 0 1 10.5 7v5.5A1.5 1.5 0 0 1 9 14H3.5A1.5 1.5 0 0 1 2 12.5V7a1.5 1.5 0 0 1 1.5-1.5z',
|
|
1171
|
+
chevron: 'M6 4l4 4-4 4',
|
|
1172
|
+
more: 'M3.5 8h.01M8 8h.01M12.5 8h.01',
|
|
1173
|
+
machine: 'M3.5 3h9A1.5 1.5 0 0 1 14 4.5v5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 9.5v-5A1.5 1.5 0 0 1 3.5 3zM6 14h4M8 11v3',
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
/** A status dot. No tone is the hollow ring: something over, or something nobody has a colour for. */
|
|
1177
|
+
function dot(tone) {
|
|
1178
|
+
const d = node('span', 'dot');
|
|
1179
|
+
d.dataset.tone = tone ?? 'none';
|
|
1180
|
+
return d;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** `text` with the part that matches the search marked. */
|
|
1184
|
+
function marked(text, cls) {
|
|
1185
|
+
const span = node('span', cls);
|
|
1186
|
+
for (const part of highlight(text, filterText)) {
|
|
1187
|
+
span.append(part.hit ? node('mark', null, part.text) : document.createTextNode(part.text));
|
|
1188
|
+
}
|
|
1189
|
+
return span;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/* A short line that says something happened and goes away. Not a dialog: it
|
|
1193
|
+
takes no focus and needs no answer. */
|
|
1194
|
+
let toastTimer = null;
|
|
1195
|
+
function toast(text) {
|
|
1196
|
+
el.toast.textContent = text;
|
|
1197
|
+
el.toast.hidden = false;
|
|
1198
|
+
clearTimeout(toastTimer);
|
|
1199
|
+
toastTimer = setTimeout(() => { el.toast.hidden = true; }, 3200);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function copyText(text, said) {
|
|
1203
|
+
try {
|
|
1204
|
+
await navigator.clipboard.writeText(text);
|
|
1205
|
+
toast(said);
|
|
1206
|
+
} catch {
|
|
1207
|
+
// A page without clipboard access still has to hand the text over.
|
|
1208
|
+
toast(`Could not copy — ${text}`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/* What the viewer changed about the list, kept in this browser. A label is a
|
|
1213
|
+
name for a row here; the transcript is not touched. */
|
|
1214
|
+
function stored(key, fallback) {
|
|
1215
|
+
try { return JSON.parse(store.get(key) ?? 'null') ?? fallback; } catch { return fallback; }
|
|
1216
|
+
}
|
|
1217
|
+
const labels = {
|
|
1218
|
+
map: stored('crewforth-studio-labels', {}),
|
|
1219
|
+
all() { return this.map; },
|
|
1220
|
+
set(id, text) {
|
|
1221
|
+
if (text) this.map[id] = text; else delete this.map[id];
|
|
1222
|
+
store.set('crewforth-studio-labels', JSON.stringify(this.map));
|
|
1223
|
+
},
|
|
1224
|
+
};
|
|
1225
|
+
const hiddenIds = new Set(stored('crewforth-studio-hidden', []));
|
|
1226
|
+
function setHidden(id, hidden) {
|
|
1227
|
+
if (hidden) hiddenIds.add(id); else hiddenIds.delete(id);
|
|
1228
|
+
store.set('crewforth-studio-hidden', JSON.stringify([...hiddenIds]));
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/* ----------------------------------------------------------------- tabs */
|
|
1232
|
+
|
|
1233
|
+
const TABS = { projects: el.sessions, live: el.fleet, machines: el.reach };
|
|
1234
|
+
let navTab = 'projects';
|
|
1235
|
+
|
|
1236
|
+
function setTab(tab) {
|
|
1237
|
+
navTab = tab in TABS ? tab : 'projects';
|
|
1238
|
+
for (const [name, panel] of Object.entries(TABS)) panel.hidden = name !== navTab;
|
|
1239
|
+
for (const b of document.querySelectorAll('[data-tab]')) {
|
|
1240
|
+
b.setAttribute('aria-selected', String(b.dataset.tab === navTab));
|
|
1241
|
+
}
|
|
1242
|
+
for (const b of document.querySelectorAll('[data-rail-tab]')) {
|
|
1243
|
+
b.classList.toggle('on', b.dataset.railTab === navTab);
|
|
1244
|
+
}
|
|
1245
|
+
// The short list of other machines belongs under the projects; on its own
|
|
1246
|
+
// tab it would be saying the same thing twice.
|
|
1247
|
+
paintOtherMachines();
|
|
1248
|
+
store.set('crewforth-studio-nav-tab', navTab);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
for (const b of document.querySelectorAll('[data-tab]')) b.addEventListener('click', () => setTab(b.dataset.tab));
|
|
1252
|
+
// On the rail a tab is also the way back in: it opens the navigator on that tab.
|
|
1253
|
+
for (const b of document.querySelectorAll('[data-rail-tab]')) {
|
|
1254
|
+
b.addEventListener('click', () => { setTab(b.dataset.railTab); setSideHidden(false); });
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/* ----------------------------------------------------------------- menu
|
|
1258
|
+
The few things a session row can do besides being opened. A menu, not a
|
|
1259
|
+
dialog: it closes on the next click anywhere and on Esc. */
|
|
1260
|
+
|
|
1261
|
+
function closeMenu() { el.menu.hidden = true; el.menu.replaceChildren(); }
|
|
1262
|
+
|
|
1263
|
+
function openMenu(anchor, items) {
|
|
1264
|
+
el.menu.replaceChildren(...items.map((it) => {
|
|
1265
|
+
if (it.note) return node('div', 'menu-note', it.note);
|
|
1266
|
+
const b = node('button', `menu-item${it.primary ? ' primary' : ''}`);
|
|
1267
|
+
b.type = 'button';
|
|
1268
|
+
if ('tone' in it) b.append(dot(it.tone));
|
|
1269
|
+
b.append(document.createTextNode(it.label));
|
|
1270
|
+
if (it.checked !== undefined) {
|
|
1271
|
+
b.setAttribute('role', 'menuitemcheckbox');
|
|
1272
|
+
b.setAttribute('aria-checked', String(it.checked));
|
|
1273
|
+
} else {
|
|
1274
|
+
b.setAttribute('role', 'menuitem');
|
|
1275
|
+
}
|
|
1276
|
+
b.addEventListener('click', (e) => { e.stopPropagation(); closeMenu(); it.run(); });
|
|
1277
|
+
return b;
|
|
1278
|
+
}));
|
|
1279
|
+
const r = anchor.getBoundingClientRect();
|
|
1280
|
+
el.menu.hidden = false;
|
|
1281
|
+
el.menu.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - 280))}px`;
|
|
1282
|
+
el.menu.style.top = `${Math.min(r.bottom + 4, window.innerHeight - 8 - el.menu.offsetHeight)}px`;
|
|
1283
|
+
el.menu.querySelector('button')?.focus();
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
document.addEventListener('click', (e) => { if (!el.menu.hidden && !el.menu.contains(e.target)) closeMenu(); });
|
|
1287
|
+
|
|
1288
|
+
/** Hand a session to a real terminal. The command is shown first and runs only on the second click. */
|
|
1289
|
+
async function offerTerminal(anchor, sessionId) {
|
|
1290
|
+
let plan = null;
|
|
1291
|
+
try {
|
|
1292
|
+
plan = (await getJson(`/api/session/${encodeURIComponent(sessionId)}/terminal`)).plan;
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
toast(`Could not read the terminal command — ${e.message}`);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
if (!plan) { toast('Not measured — this session recorded no working directory to open a terminal in.'); return; }
|
|
1298
|
+
openMenu(anchor, [
|
|
1299
|
+
{ note: `This will run in ${plan.via ?? 'a terminal'}:` },
|
|
1300
|
+
{ note: plan.line },
|
|
1301
|
+
{
|
|
1302
|
+
label: 'Open terminal',
|
|
1303
|
+
primary: true,
|
|
1304
|
+
run: async () => {
|
|
1305
|
+
try {
|
|
1306
|
+
const r = await fetch(api(`/api/session/${encodeURIComponent(sessionId)}/terminal`), {
|
|
1307
|
+
method: 'POST', headers: { ...writeHeaders, 'content-type': 'application/json' }, body: '{}',
|
|
1308
|
+
});
|
|
1309
|
+
const out = await r.json();
|
|
1310
|
+
toast(out.ok ? `Opened in ${plan.via ?? 'a terminal'}` : `Could not open a terminal — ${out.reason ?? 'no reason given'}`);
|
|
1311
|
+
} catch (e) {
|
|
1312
|
+
toast(`Could not open a terminal — ${e.message}`);
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
},
|
|
1316
|
+
{ label: 'Cancel', run: () => {} },
|
|
1317
|
+
]);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/* ------------------------------------------------------------- projects */
|
|
1321
|
+
|
|
1322
|
+
let projectsData = null;
|
|
1323
|
+
let fleetData = null;
|
|
1324
|
+
let expanded = new Set();
|
|
1325
|
+
let filterText = '';
|
|
1326
|
+
let showMissing = false;
|
|
1327
|
+
let showHidden = false;
|
|
1328
|
+
let renaming = null; // the session whose name is being typed
|
|
1329
|
+
|
|
1330
|
+
el.filter.addEventListener('input', () => {
|
|
1331
|
+
filterText = el.filter.value.trim().toLowerCase();
|
|
1332
|
+
paintProjects();
|
|
1333
|
+
paintLive();
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
function findSessionRow(sessionId) {
|
|
1337
|
+
for (const project of projectsData?.projects ?? []) {
|
|
1338
|
+
const session = project.sessions.find((x) => x.sessionId === sessionId);
|
|
1339
|
+
if (session) return { project, session };
|
|
1340
|
+
}
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function renderSessions(data) {
|
|
1345
|
+
if (!data.measured) {
|
|
1346
|
+
renderNote(el.sessions, {
|
|
1347
|
+
kind: 'unmeasured',
|
|
1348
|
+
title: 'No transcripts here',
|
|
1349
|
+
body: 'Nothing was read.',
|
|
1350
|
+
why: data.reason,
|
|
702
1351
|
});
|
|
703
1352
|
return;
|
|
704
1353
|
}
|
|
1354
|
+
projectsData = data;
|
|
1355
|
+
// The project you are standing in starts open; the rest stay folded, or a
|
|
1356
|
+
// machine with 175 projects buries the one you are working in.
|
|
1357
|
+
if (!expanded.size) {
|
|
1358
|
+
const cur = data.projects.find((p) => p.current) ?? data.projects[0];
|
|
1359
|
+
if (cur) expanded.add(cur.key);
|
|
1360
|
+
}
|
|
1361
|
+
paintProjects();
|
|
1362
|
+
paintCrumb();
|
|
1363
|
+
paintFirstRun();
|
|
1364
|
+
}
|
|
705
1365
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
el.
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
el.
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const nm = node('div', 'name');
|
|
722
|
-
nm.append(document.createTextNode(p.name));
|
|
723
|
-
nm.append(node('span', 'origin', 'remote'));
|
|
724
|
-
who.append(nm);
|
|
725
|
-
who.append(node('div', 'path', p.note || p.kind || ''));
|
|
726
|
-
|
|
727
|
-
const stat = node('div', 'stat');
|
|
728
|
-
stat.append(node('span', 'status', p.status ?? '?'));
|
|
729
|
-
row.append(ring, who, stat);
|
|
730
|
-
// There is nothing to open: the transcript is on that machine.
|
|
731
|
-
row.title = `${p.name} [${p.ref}] — on another machine. Its transcript lives there, so the panel can list it but not draw it.`;
|
|
732
|
-
return row;
|
|
733
|
-
}));
|
|
1366
|
+
/* The first run: the transcripts were read and there are none. The stage says what Studio is waiting for and
|
|
1367
|
+
offers the one thing that can be done about it here. */
|
|
1368
|
+
function paintFirstRun() {
|
|
1369
|
+
const first = isFirstRun(projectsData);
|
|
1370
|
+
el.sessions.removeAttribute('aria-busy');
|
|
1371
|
+
el.stage.dataset.firstRun = String(first);
|
|
1372
|
+
// No "New session" here, although the design has one: a session is started in a project, and on a machine
|
|
1373
|
+
// with no transcripts this panel knows of no project to start it in.
|
|
1374
|
+
if (first && !el.firstRun.firstChild) {
|
|
1375
|
+
el.firstRun.append(
|
|
1376
|
+
node('strong', null, 'No Claude Code sessions yet'),
|
|
1377
|
+
node('p', null, 'Studio reads the sessions Claude Code saves on this machine. Start one in a terminal in any project and it shows up here within a few seconds.'),
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
el.firstRun.hidden = !first;
|
|
734
1381
|
}
|
|
735
1382
|
|
|
1383
|
+
function versionBadge(kit) {
|
|
1384
|
+
const b = badgeFor(kit);
|
|
1385
|
+
const tag = node(b.copy ? 'button' : 'span', `badge badge-${b.tone}`, b.text);
|
|
1386
|
+
tag.title = b.title;
|
|
1387
|
+
if (b.copy) {
|
|
1388
|
+
tag.type = 'button';
|
|
1389
|
+
tag.addEventListener('click', (e) => { e.stopPropagation(); copyText(b.copy, `Copied: ${b.copy}`); });
|
|
1390
|
+
}
|
|
1391
|
+
return tag;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function sessionRow(project, sn) {
|
|
1395
|
+
const status = sessionStatus(sn.sessionId, fleetData);
|
|
1396
|
+
const name = sessionName(sn, labels.all());
|
|
1397
|
+
const row = node('div', 'srow');
|
|
1398
|
+
row.dataset.id = sn.sessionId;
|
|
1399
|
+
row.tabIndex = 0;
|
|
1400
|
+
row.setAttribute('role', 'treeitem');
|
|
1401
|
+
row.setAttribute('aria-current', String(sn.sessionId === current));
|
|
1402
|
+
if (ownedIds.has(sn.sessionId)) row.classList.add('owned');
|
|
1403
|
+
|
|
1404
|
+
const top = node('span', 'srow-top');
|
|
1405
|
+
const d = dot(status.tone);
|
|
1406
|
+
// The word travels with the colour; where there is no status to give, say why.
|
|
1407
|
+
d.title = status.key === 'unmeasured' ? 'Status not measured — the session list could not be read' : (status.word ?? '');
|
|
1408
|
+
top.append(d);
|
|
1409
|
+
|
|
1410
|
+
if (renaming === sn.sessionId) {
|
|
1411
|
+
const input = node('input', 'srow-rename');
|
|
1412
|
+
input.value = name;
|
|
1413
|
+
input.setAttribute('aria-label', `Rename ${name}`);
|
|
1414
|
+
const done = (save) => {
|
|
1415
|
+
if (renaming !== sn.sessionId) return;
|
|
1416
|
+
renaming = null;
|
|
1417
|
+
if (save) labels.set(sn.sessionId, input.value.trim() === (sn.title ?? '') ? '' : input.value.trim());
|
|
1418
|
+
paintProjects();
|
|
1419
|
+
paintCrumb();
|
|
1420
|
+
};
|
|
1421
|
+
input.addEventListener('click', (e) => e.stopPropagation());
|
|
1422
|
+
input.addEventListener('keydown', (e) => {
|
|
1423
|
+
e.stopPropagation();
|
|
1424
|
+
if (e.key === 'Enter') done(true);
|
|
1425
|
+
if (e.key === 'Escape') done(false);
|
|
1426
|
+
});
|
|
1427
|
+
input.addEventListener('blur', () => done(true));
|
|
1428
|
+
top.append(input);
|
|
1429
|
+
queueMicrotask(() => { input.focus(); input.select(); });
|
|
1430
|
+
} else {
|
|
1431
|
+
top.append(marked(name, 'nm'));
|
|
1432
|
+
}
|
|
1433
|
+
top.append(node('span', 'row-fill'));
|
|
1434
|
+
top.append(node('span', 'sub', ago(sn.modifiedAt, Date.now())));
|
|
1435
|
+
|
|
1436
|
+
const more = node('button', 'row-more');
|
|
1437
|
+
more.type = 'button';
|
|
1438
|
+
more.setAttribute('aria-label', `More for ${name}`);
|
|
1439
|
+
more.setAttribute('aria-haspopup', 'menu');
|
|
1440
|
+
more.append(icon(ICON.more));
|
|
1441
|
+
more.addEventListener('click', (e) => {
|
|
1442
|
+
e.stopPropagation();
|
|
1443
|
+
openMenu(more, [
|
|
1444
|
+
{ label: 'Rename', run: () => { renaming = sn.sessionId; paintProjects(); } },
|
|
1445
|
+
{ label: 'Copy session id', run: () => copyText(sn.sessionId, 'Copied the session id') },
|
|
1446
|
+
{ label: 'Open in terminal', run: () => offerTerminal(more, sn.sessionId) },
|
|
1447
|
+
hiddenIds.has(sn.sessionId)
|
|
1448
|
+
? { label: 'Show in list', run: () => { setHidden(sn.sessionId, false); paintProjects(); } }
|
|
1449
|
+
: { label: 'Hide from list', run: () => { setHidden(sn.sessionId, true); paintProjects(); } },
|
|
1450
|
+
]);
|
|
1451
|
+
});
|
|
1452
|
+
top.append(more);
|
|
1453
|
+
row.append(top);
|
|
1454
|
+
// The same menu from a right click, where a menu for a row is expected to be.
|
|
1455
|
+
row.addEventListener('contextmenu', (e) => { e.preventDefault(); more.click(); });
|
|
1456
|
+
|
|
1457
|
+
const subText = sessionSub(sn, status) + (ownedIds.has(sn.sessionId) ? ' · started here' : '');
|
|
1458
|
+
if (subText) row.append(marked(subText, 'sub srow-sub'));
|
|
1459
|
+
|
|
1460
|
+
row.title = `${sn.sessionId}${sn.title ? `\n${sn.title}` : ''}`;
|
|
1461
|
+
const open = () => selectSession(sn.sessionId);
|
|
1462
|
+
row.addEventListener('click', open);
|
|
1463
|
+
row.addEventListener('keydown', (e) => {
|
|
1464
|
+
if (e.target !== row) return;
|
|
1465
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); }
|
|
1466
|
+
});
|
|
1467
|
+
return row;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function paintProjects() {
|
|
1471
|
+
const data = projectsData;
|
|
1472
|
+
if (!data || renaming && document.activeElement?.classList?.contains('srow-rename')) return;
|
|
1473
|
+
|
|
1474
|
+
// Typing a search is an explicit request, so it looks everywhere — including
|
|
1475
|
+
// the projects whose directories are gone. Hiding a result someone asked for
|
|
1476
|
+
// by name is worse than showing a dead path.
|
|
1477
|
+
const pool = (showMissing || filterText) ? data.projects : data.projects.filter((p) => p.exists);
|
|
1478
|
+
const missing = data.projects.length - pool.length;
|
|
1479
|
+
|
|
1480
|
+
const frag = document.createDocumentFragment();
|
|
1481
|
+
let shown = 0;
|
|
1482
|
+
let hiddenCount = 0;
|
|
1483
|
+
|
|
1484
|
+
for (const p of pool) {
|
|
1485
|
+
const projectHit = filterText && p.label.toLowerCase().includes(filterText);
|
|
1486
|
+
const sessions = p.sessions.filter((sn) => {
|
|
1487
|
+
if (hiddenIds.has(sn.sessionId) && !showHidden) { hiddenCount += 1; return false; }
|
|
1488
|
+
return !filterText || projectHit || matchesSession(filterText, p, sn, labels.all());
|
|
1489
|
+
});
|
|
1490
|
+
if (filterText && !projectHit && !sessions.length) continue;
|
|
1491
|
+
shown += 1;
|
|
1492
|
+
|
|
1493
|
+
const open = expanded.has(p.key) || Boolean(filterText);
|
|
1494
|
+
const group = node('div', 'proj');
|
|
1495
|
+
const head = node('div', `proj-head${p.current ? ' current' : ''}${p.exists ? '' : ' gone'}`);
|
|
1496
|
+
head.tabIndex = 0;
|
|
1497
|
+
head.setAttribute('role', 'treeitem');
|
|
1498
|
+
head.setAttribute('aria-expanded', String(open));
|
|
1499
|
+
head.dataset.project = p.key;
|
|
1500
|
+
const caret = icon(ICON.chevron);
|
|
1501
|
+
caret.classList.add('proj-caret');
|
|
1502
|
+
head.append(caret, marked(p.label, 'proj-name'));
|
|
1503
|
+
// Where a project lives is part of its identity once more than one machine
|
|
1504
|
+
// is in view.
|
|
1505
|
+
if (p.origin && p.local === false) head.append(node('span', 'origin', p.origin));
|
|
1506
|
+
head.append(node('span', 'row-fill'), versionBadge(p.kit));
|
|
1507
|
+
head.title = (p.cwd ?? p.dir) + (p.exists ? '' : ' — directory no longer exists');
|
|
1508
|
+
const toggle = () => {
|
|
1509
|
+
if (expanded.has(p.key)) expanded.delete(p.key); else expanded.add(p.key);
|
|
1510
|
+
paintProjects();
|
|
1511
|
+
};
|
|
1512
|
+
head.addEventListener('click', toggle);
|
|
1513
|
+
head.addEventListener('keydown', (e) => {
|
|
1514
|
+
if (e.target !== head) return;
|
|
1515
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
|
|
1516
|
+
});
|
|
1517
|
+
group.append(head);
|
|
1518
|
+
|
|
1519
|
+
if (open) {
|
|
1520
|
+
for (const sn of sessions) group.append(sessionRow(p, sn));
|
|
1521
|
+
if (p.total > p.sessions.length) {
|
|
1522
|
+
group.append(node('div', 'nav-line', `${p.total - p.sessions.length} older session(s) not listed`));
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
frag.append(group);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
if (!shown) {
|
|
1529
|
+
renderNote(el.sessions, filterText
|
|
1530
|
+
? { title: 'No match', body: `Nothing here is called “${filterText}”.` }
|
|
1531
|
+
: { title: 'No projects', body: 'Measured — no transcript was found on this machine.' });
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
for (const o of (data.origins ?? []).filter((x) => x.ok === false)) {
|
|
1536
|
+
frag.append(node('div', 'nav-line bad', `${o.name} unreachable — ${o.reason}`));
|
|
1537
|
+
}
|
|
1538
|
+
const lineWith = (text, label, run) => {
|
|
1539
|
+
const line = node('div', 'nav-line');
|
|
1540
|
+
line.append(node('span', null, text));
|
|
1541
|
+
const b = node('button', 'link', label);
|
|
1542
|
+
b.type = 'button';
|
|
1543
|
+
b.addEventListener('click', run);
|
|
1544
|
+
line.append(b);
|
|
1545
|
+
return line;
|
|
1546
|
+
};
|
|
1547
|
+
if (hiddenIds.size && !filterText) {
|
|
1548
|
+
frag.append(lineWith(
|
|
1549
|
+
showHidden ? `${hiddenIds.size} hidden session(s) shown` : `${hiddenCount} session(s) hidden from this list`,
|
|
1550
|
+
showHidden ? 'hide' : 'show',
|
|
1551
|
+
() => { showHidden = !showHidden; paintProjects(); },
|
|
1552
|
+
));
|
|
1553
|
+
}
|
|
1554
|
+
if (missing > 0 && !filterText) {
|
|
1555
|
+
frag.append(lineWith(
|
|
1556
|
+
`${missing} project(s) hidden — their directories no longer exist`, 'show',
|
|
1557
|
+
() => { showMissing = true; paintProjects(); },
|
|
1558
|
+
));
|
|
1559
|
+
} else if (showMissing && !filterText) {
|
|
1560
|
+
frag.append(lineWith('Showing projects whose directories no longer exist', 'hide',
|
|
1561
|
+
() => { showMissing = false; paintProjects(); }));
|
|
1562
|
+
}
|
|
1563
|
+
el.sessions.replaceChildren(frag);
|
|
1564
|
+
|
|
1565
|
+
if (!current) {
|
|
1566
|
+
const cur = data.projects.find((x) => x.current) ?? data.projects[0];
|
|
1567
|
+
const best = cur?.sessions.find((x) => x.agentCount > 0) ?? cur?.sessions[0];
|
|
1568
|
+
if (best) selectSession(best.sessionId);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/* ------------------------------------------------------- live and machines */
|
|
1573
|
+
|
|
736
1574
|
function renderFleet(data) {
|
|
1575
|
+
fleetData = data;
|
|
1576
|
+
paintLive();
|
|
1577
|
+
paintMachines();
|
|
1578
|
+
// A session's dot comes from this answer, so the project list follows it, and
|
|
1579
|
+
// so does the pill on the session's own card.
|
|
1580
|
+
paintProjects();
|
|
1581
|
+
if (current) canvas.setSessionState(sessionStateNow());
|
|
1582
|
+
timeline.setLive(sessionIsLive());
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function paintLive() {
|
|
1586
|
+
const data = fleetData;
|
|
1587
|
+
if (!data) return;
|
|
1588
|
+
|
|
737
1589
|
if (!data.measured) {
|
|
738
|
-
el.
|
|
1590
|
+
el.liveCount.hidden = false;
|
|
1591
|
+
el.liveCount.textContent = '?';
|
|
1592
|
+
el.liveCount.title = 'Not measured';
|
|
739
1593
|
renderNote(el.fleet, {
|
|
740
1594
|
kind: 'unmeasured',
|
|
741
1595
|
title: 'Not measured',
|
|
@@ -745,65 +1599,274 @@ function renderFleet(data) {
|
|
|
745
1599
|
return;
|
|
746
1600
|
}
|
|
747
1601
|
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
el.
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
el.fleetMeta.title = origins
|
|
756
|
-
.map((o) => `${o.name}${o.local ? ' (this machine)' : ''}: ${o.ok === false ? `unreachable — ${o.reason}` : 'ok'}`)
|
|
757
|
-
.join('\n');
|
|
1602
|
+
const live = liveSessions(data);
|
|
1603
|
+
el.liveCount.hidden = live.length === 0;
|
|
1604
|
+
el.liveCount.textContent = String(live.length);
|
|
1605
|
+
el.liveCount.title = `${live.length} working or waiting`;
|
|
1606
|
+
|
|
1607
|
+
const rows = live.filter((s) => !filterText
|
|
1608
|
+
|| [s.name, s.cwd, s.sessionId, s.origin].some((v) => typeof v === 'string' && v.toLowerCase().includes(filterText)));
|
|
758
1609
|
|
|
759
|
-
if (!
|
|
760
|
-
|
|
1610
|
+
if (!live.length) {
|
|
1611
|
+
const open = (data.sessions ?? []).length;
|
|
1612
|
+
renderNote(el.fleet, {
|
|
1613
|
+
title: 'No sessions running',
|
|
1614
|
+
body: open
|
|
1615
|
+
? `Measured — ${open} open on this machine, none of them working or waiting.`
|
|
1616
|
+
: 'Measured — the machine has none open.',
|
|
1617
|
+
});
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (!rows.length) {
|
|
1621
|
+
renderNote(el.fleet, { title: 'No match', body: `No live session is called “${filterText}”.` });
|
|
761
1622
|
return;
|
|
762
1623
|
}
|
|
763
1624
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const row = node('div', '
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
who.append(nm);
|
|
778
|
-
who.append(node('div', 'path', shortPath(s.cwd) || '—'));
|
|
779
|
-
if (s.waitingFor) who.append(node('div', 'waiting-for', `⏸ ${s.waitingFor}`));
|
|
780
|
-
|
|
781
|
-
const stat = node('div', 'stat');
|
|
782
|
-
stat.append(node('span', 'status', s.status));
|
|
783
|
-
row.append(ring, who, stat);
|
|
1625
|
+
el.fleet.replaceChildren(...rows.map((s) => {
|
|
1626
|
+
const status = sessionStatus(s.sessionId, { measured: true, sessions: [{ ...s, local: true }] });
|
|
1627
|
+
const known = findSessionRow(s.sessionId);
|
|
1628
|
+
const name = known ? sessionName(known.session, labels.all()) : (s.name || s.sessionId.slice(0, 8));
|
|
1629
|
+
const row = node('div', 'srow');
|
|
1630
|
+
const top = node('span', 'srow-top');
|
|
1631
|
+
top.append(dot(status.tone), marked(name, 'nm'));
|
|
1632
|
+
if (s.origin && s.local === false) top.append(node('span', 'origin', s.origin));
|
|
1633
|
+
top.append(node('span', 'row-fill'), node('span', 'sub', ago(s.startedAt, Date.now())));
|
|
1634
|
+
row.append(top);
|
|
1635
|
+
const where = known?.project.label ?? shortPath(s.cwd);
|
|
1636
|
+
const said = status.key === 'waiting' && s.waitingFor ? `Needs you · ${s.waitingFor}` : (status.word ?? s.status);
|
|
1637
|
+
row.append(marked([where, said].filter(Boolean).join(' · '), 'sub srow-sub'));
|
|
784
1638
|
|
|
785
|
-
// A live session in the fleet is the one most likely to be wanted, so
|
|
786
|
-
// clicking it does what clicking it anywhere else does: opens it.
|
|
787
1639
|
if (s.local !== false && s.sessionId) {
|
|
788
|
-
row.
|
|
1640
|
+
row.tabIndex = 0;
|
|
1641
|
+
row.dataset.id = s.sessionId;
|
|
789
1642
|
row.setAttribute('aria-current', String(s.sessionId === current));
|
|
790
|
-
row.title = `Open ${
|
|
791
|
-
|
|
1643
|
+
row.title = `Open ${name}`;
|
|
1644
|
+
const open = () => selectSession(s.sessionId);
|
|
1645
|
+
row.addEventListener('click', open);
|
|
1646
|
+
row.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
|
|
792
1647
|
} else {
|
|
793
1648
|
// A session on another machine has no transcript here to open.
|
|
794
|
-
row.
|
|
1649
|
+
row.classList.add('remote');
|
|
1650
|
+
row.title = `${name} — on ${s.origin}. Its transcript is on that machine.`;
|
|
795
1651
|
}
|
|
796
1652
|
return row;
|
|
797
1653
|
}));
|
|
798
1654
|
}
|
|
799
1655
|
|
|
1656
|
+
function machineRow(m) {
|
|
1657
|
+
const row = node('div', 'srow remote');
|
|
1658
|
+
const top = node('span', 'srow-top');
|
|
1659
|
+
top.append(icon(ICON.machine), node('span', 'mname', m.name), node('span', 'row-fill'));
|
|
1660
|
+
if (m.kind === 'snapshot') {
|
|
1661
|
+
// Recorded by a session, not asked just now: the label says which.
|
|
1662
|
+
top.append(node('span', 'badge', 'snapshot'));
|
|
1663
|
+
row.append(top);
|
|
1664
|
+
const seen = seenAgo(m.seenAt, Date.now());
|
|
1665
|
+
row.append(node('div', 'sub srow-sub', [m.status, m.note, seen].filter(Boolean).join(' · ')));
|
|
1666
|
+
row.title = `${m.name} — on another machine. Its transcript lives there, so the panel can list it but not draw it.`;
|
|
1667
|
+
} else {
|
|
1668
|
+
top.append(node('span', 'badge', 'peer'));
|
|
1669
|
+
row.append(top);
|
|
1670
|
+
const sub = node('div', `sub srow-sub${m.ok ? '' : ' bad'}`,
|
|
1671
|
+
m.ok ? `${m.sessions ?? 0} session(s) · asked just now` : `unreachable — ${m.reason ?? 'no reason given'}`);
|
|
1672
|
+
row.append(sub);
|
|
1673
|
+
}
|
|
1674
|
+
return row;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function paintMachines() {
|
|
1678
|
+
const m = machines(fleetData);
|
|
1679
|
+
const self = (fleetData?.origins ?? []).find((o) => o.local);
|
|
1680
|
+
const frag = document.createDocumentFragment();
|
|
1681
|
+
|
|
1682
|
+
if (self) {
|
|
1683
|
+
const row = node('div', 'srow remote');
|
|
1684
|
+
const top = node('span', 'srow-top');
|
|
1685
|
+
top.append(icon(ICON.machine), node('span', 'mname', self.name), node('span', 'row-fill'), node('span', 'badge', 'this machine'));
|
|
1686
|
+
row.append(top);
|
|
1687
|
+
frag.append(row);
|
|
1688
|
+
}
|
|
1689
|
+
for (const p of m.peers) frag.append(machineRow(p));
|
|
1690
|
+
for (const r of m.remote) frag.append(machineRow(r));
|
|
1691
|
+
|
|
1692
|
+
if (!m.remote.length) {
|
|
1693
|
+
// A roster nobody has recorded is not "no other machines". Say which it is.
|
|
1694
|
+
const note = node('div', 'nav-note');
|
|
1695
|
+
renderNote(note, {
|
|
1696
|
+
kind: m.rosterMeasured ? '' : 'unmeasured',
|
|
1697
|
+
title: m.rosterMeasured ? 'None reachable' : 'Not measured',
|
|
1698
|
+
body: m.rosterMeasured
|
|
1699
|
+
? 'A connected session looked and found no machines besides this one.'
|
|
1700
|
+
: 'No session here has recorded a list of other machines yet.',
|
|
1701
|
+
why: m.rosterMeasured ? null : m.rosterReason,
|
|
1702
|
+
});
|
|
1703
|
+
frag.append(note);
|
|
1704
|
+
}
|
|
1705
|
+
el.reach.replaceChildren(frag);
|
|
1706
|
+
paintOtherMachines();
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/** The short form under the project list: the first two, and the way to the rest. */
|
|
1710
|
+
function paintOtherMachines() {
|
|
1711
|
+
const m = machines(fleetData);
|
|
1712
|
+
const others = [...m.peers, ...m.remote];
|
|
1713
|
+
el.otherMachines.hidden = navTab !== 'projects' || others.length === 0;
|
|
1714
|
+
if (el.otherMachines.hidden) return;
|
|
1715
|
+
const label = node('button', 'nav-foot-label', 'Other machines');
|
|
1716
|
+
label.type = 'button';
|
|
1717
|
+
label.addEventListener('click', () => setTab('machines'));
|
|
1718
|
+
const rows = others.slice(0, 2).map(machineRow);
|
|
1719
|
+
const more = others.length > 2 ? [node('div', 'nav-line', `${others.length - 2} more in Machines`)] : [];
|
|
1720
|
+
el.otherMachines.replaceChildren(label, ...rows, ...more);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
800
1723
|
/* ------------------------------------------------------------- sessions */
|
|
801
1724
|
|
|
802
1725
|
let current = null;
|
|
803
1726
|
let source = null;
|
|
1727
|
+
let statusFilter = null;
|
|
1728
|
+
let lastStats = null;
|
|
1729
|
+
|
|
1730
|
+
/** The breadcrumb: which project, which session. The project is the way back to its list. */
|
|
1731
|
+
function paintCrumb() {
|
|
1732
|
+
const found = current ? findSessionRow(current) : null;
|
|
1733
|
+
if (!found) { el.crumb.replaceChildren(); return; }
|
|
1734
|
+
const proj = node('button', 'crumb-project', found.project.label);
|
|
1735
|
+
proj.type = 'button';
|
|
1736
|
+
proj.title = `Show ${found.project.label} in the navigator`;
|
|
1737
|
+
proj.addEventListener('click', () => showProject(found.project.key));
|
|
1738
|
+
const sep = icon(ICON.chevron);
|
|
1739
|
+
sep.classList.add('crumb-sep');
|
|
1740
|
+
const name = node('span', 'crumb-session', sessionName(found.session, labels.all()));
|
|
1741
|
+
// The branch is on the session's row and on its card; here it is one hover away.
|
|
1742
|
+
name.title = found.session.branch ? `on ${found.session.branch}` : '';
|
|
1743
|
+
el.crumb.replaceChildren(proj, sep, name);
|
|
1744
|
+
fitBar();
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function showProject(key) {
|
|
1748
|
+
el.filter.value = '';
|
|
1749
|
+
filterText = '';
|
|
1750
|
+
expanded.add(key);
|
|
1751
|
+
setTab('projects');
|
|
1752
|
+
setSideHidden(false);
|
|
1753
|
+
paintProjects();
|
|
1754
|
+
el.sessions.querySelector(`[data-project="${CSS.escape(key)}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/** The summary: one chip per status, each a filter on the canvas. Clicking the lit one puts everything back. */
|
|
1758
|
+
function paintSummary(stats, extra = []) {
|
|
1759
|
+
lastStats = stats;
|
|
1760
|
+
const chips = summaryChips(stats?.byStatus);
|
|
1761
|
+
// A filter on a status nothing has any more would hide every agent with no chip left to undo it.
|
|
1762
|
+
if (statusFilter && !chips.some((c) => c.status === statusFilter)) setStatusFilter(null, false);
|
|
1763
|
+
const toggle = (c) => setStatusFilter(statusFilter === c.status ? null : c.status);
|
|
1764
|
+
const full = chips.map((c) => {
|
|
1765
|
+
const b = node('button', 'pill chip');
|
|
1766
|
+
b.type = 'button';
|
|
1767
|
+
b.setAttribute('aria-pressed', String(statusFilter === c.status));
|
|
1768
|
+
b.title = statusFilter === c.status ? 'Show every agent' : `Show only ${c.text.replace(/^\d+ /, '')}`;
|
|
1769
|
+
b.append(dot(c.tone), document.createTextNode(c.text));
|
|
1770
|
+
b.addEventListener('click', () => toggle(c));
|
|
1771
|
+
return b;
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
// The same chips as one: dots and counts, for a bar too narrow to spell them
|
|
1775
|
+
// out. The words are in its label and in the menu it opens, where each line
|
|
1776
|
+
// is the same filter the full chip is.
|
|
1777
|
+
const compact = [];
|
|
1778
|
+
if (chips.length) {
|
|
1779
|
+
const words = [...chips.map((c) => c.text), ...extra].join(', ');
|
|
1780
|
+
const b = node('button', 'pill chip chip-compact');
|
|
1781
|
+
b.type = 'button';
|
|
1782
|
+
b.setAttribute('aria-label', `Session summary: ${words}. Open details`);
|
|
1783
|
+
b.setAttribute('aria-haspopup', 'menu');
|
|
1784
|
+
b.setAttribute('aria-pressed', String(Boolean(statusFilter)));
|
|
1785
|
+
b.title = words;
|
|
1786
|
+
for (const c of chips) b.append(dot(c.tone), document.createTextNode(String(c.count)));
|
|
1787
|
+
b.append(icon('M4 6l4 4 4-4'));
|
|
1788
|
+
b.addEventListener('click', (e) => {
|
|
1789
|
+
e.stopPropagation();
|
|
1790
|
+
openMenu(b, [
|
|
1791
|
+
...chips.map((c) => ({ label: c.text, tone: c.tone, checked: statusFilter === c.status, run: () => toggle(c) })),
|
|
1792
|
+
...extra.map((t) => ({ note: t })),
|
|
1793
|
+
]);
|
|
1794
|
+
});
|
|
1795
|
+
compact.push(b);
|
|
1796
|
+
}
|
|
1797
|
+
el.summary.replaceChildren(...full, ...extra.map((t) => node('span', 'pill note-pill', t)), ...compact);
|
|
1798
|
+
fitBar();
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/** Spell the summary out while it fits; fold it into one chip the moment the bar would overflow. Measured, not
|
|
1802
|
+
* guessed from the window's width: how much room the chips need depends on how many statuses are in play. */
|
|
1803
|
+
function fitBar() {
|
|
1804
|
+
delete el.bar.dataset.compact;
|
|
1805
|
+
if (el.bar.scrollWidth > el.bar.clientWidth) el.bar.dataset.compact = 'true';
|
|
1806
|
+
}
|
|
1807
|
+
window.addEventListener('resize', fitBar);
|
|
1808
|
+
|
|
1809
|
+
function setStatusFilter(status, repaint = true) {
|
|
1810
|
+
statusFilter = status;
|
|
1811
|
+
show = 'all';
|
|
1812
|
+
tbValue.show.textContent = status ? 'One status' : SHOW.all.word;
|
|
1813
|
+
canvas.setFilter(status ? [status] : null);
|
|
1814
|
+
timeline.setFilter(status ? [status] : null);
|
|
1815
|
+
if (repaint) paintSummary(lastStats, lastStats?.malformed ? [`${lastStats.malformed} malformed`] : []);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/** The Show menu: the same filter the chips are, by what the viewer is looking for rather than by one status. */
|
|
1819
|
+
function setShow(key) {
|
|
1820
|
+
show = key in SHOW ? key : 'all';
|
|
1821
|
+
statusFilter = null;
|
|
1822
|
+
tbValue.show.textContent = SHOW[show].word;
|
|
1823
|
+
canvas.setFilter(SHOW[show].statuses, { keepWaiting: Boolean(SHOW[show].waiting) });
|
|
1824
|
+
timeline.setFilter(SHOW[show].statuses, { keepWaiting: Boolean(SHOW[show].waiting) });
|
|
1825
|
+
paintSummary(lastStats, lastStats?.malformed ? [`${lastStats.malformed} malformed`] : []);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/* ------------------------------------------------------ needs attention */
|
|
1829
|
+
|
|
1830
|
+
let attentionIds = [];
|
|
1831
|
+
let attentionAt = -1;
|
|
1832
|
+
|
|
1833
|
+
/** The strip above the canvas: who is waiting on the viewer, then who failed. Absent when nobody is. */
|
|
1834
|
+
function paintAttention(nodes) {
|
|
1835
|
+
const list = attention(nodes ?? [], canvas.waiting);
|
|
1836
|
+
attentionIds = list.map((a) => a.id);
|
|
1837
|
+
// The strip takes 44px from the canvas when it appears and gives them back when it goes.
|
|
1838
|
+
const was = el.attention.hidden;
|
|
1839
|
+
el.attention.hidden = list.length === 0;
|
|
1840
|
+
if (was !== el.attention.hidden) canvas.fitIfUntouched();
|
|
1841
|
+
if (!list.length) { el.attention.replaceChildren(); attentionAt = -1; return; }
|
|
1842
|
+
const MAX = 5;
|
|
1843
|
+
const chips = list.slice(0, MAX).map((a) => {
|
|
1844
|
+
const b = node('button', 'att');
|
|
1845
|
+
b.type = 'button';
|
|
1846
|
+
b.append(dot(a.tone), node('span', 'att-name', a.name), node('span', 'att-says', a.says));
|
|
1847
|
+
b.addEventListener('click', () => { attentionAt = attentionIds.indexOf(a.id); goTo(a.id); });
|
|
1848
|
+
return b;
|
|
1849
|
+
});
|
|
1850
|
+
if (list.length > MAX) {
|
|
1851
|
+
const more = node('button', 'att', `+${list.length - MAX} more`);
|
|
1852
|
+
more.type = 'button';
|
|
1853
|
+
more.addEventListener('click', () => setShow('attention'));
|
|
1854
|
+
chips.push(more);
|
|
1855
|
+
}
|
|
1856
|
+
el.attention.replaceChildren(
|
|
1857
|
+
node('span', 'attention-label', 'Needs attention'), ...chips,
|
|
1858
|
+
node('span', 'row-fill'), node('span', 'sub attention-hint', 'Click one to go to it'),
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
function stepAttention(by) {
|
|
1863
|
+
if (!attentionIds.length) return;
|
|
1864
|
+
attentionAt = (attentionAt + by + attentionIds.length) % attentionIds.length;
|
|
1865
|
+
goTo(attentionIds[attentionAt]);
|
|
1866
|
+
}
|
|
804
1867
|
|
|
805
1868
|
// Selecting a session points the graph at it. It does NOT open the
|
|
806
|
-
// conversation: the
|
|
1869
|
+
// conversation: the navigator is for choosing what to look at, and having a
|
|
807
1870
|
// reading panel appear on every click there made choosing expensive. The
|
|
808
1871
|
// conversation is opened from the session node on the canvas, which is the
|
|
809
1872
|
// thing that represents it.
|
|
@@ -811,29 +1874,44 @@ function selectSession(sessionId) {
|
|
|
811
1874
|
if (current === sessionId) return;
|
|
812
1875
|
current = sessionId;
|
|
813
1876
|
canvas.setSession(sessionId);
|
|
1877
|
+
timeline.setSession(sessionId);
|
|
1878
|
+
list.setSession(sessionId);
|
|
1879
|
+
timeline.setFilter(null);
|
|
1880
|
+
timeline.setOwned(owned.get(sessionId) ?? null);
|
|
1881
|
+
statusFilter = null;
|
|
1882
|
+
show = 'all';
|
|
1883
|
+
tbValue.show.textContent = SHOW.all.word;
|
|
1884
|
+
canvas.setFilter(null);
|
|
1885
|
+
canvas.setSessionState(sessionStateNow());
|
|
1886
|
+
paintAttention(null);
|
|
814
1887
|
showInspector(null);
|
|
1888
|
+
paintWaiting(true);
|
|
815
1889
|
|
|
816
|
-
|
|
1890
|
+
// The session being looked at is never inside a folded project: the row that
|
|
1891
|
+
// says "you are here" has to be on screen.
|
|
1892
|
+
const home = findSessionRow(sessionId);
|
|
1893
|
+
if (home && !expanded.has(home.project.key)) { expanded.add(home.project.key); paintProjects(); }
|
|
1894
|
+
for (const r of document.querySelectorAll('.srow[data-id]')) {
|
|
817
1895
|
r.setAttribute('aria-current', String(r.dataset.id === sessionId));
|
|
818
1896
|
}
|
|
1897
|
+
paintCrumb();
|
|
1898
|
+
paintSummary(null);
|
|
819
1899
|
// A new session means the cached agent reports belong to someone else.
|
|
820
1900
|
detailCache.clear();
|
|
821
1901
|
|
|
822
|
-
const any = chat.ids.length > 0;
|
|
823
|
-
el.chat.hidden = !any;
|
|
824
|
-
el.chatSplit.hidden = !any;
|
|
825
|
-
shell.classList.toggle('no-chat', !any);
|
|
826
|
-
// Offered only where it means something: a session the panel already owns is
|
|
827
|
-
// already here, and there is nothing to continue.
|
|
828
|
-
el.continueSession.hidden = ownedIds.has(sessionId);
|
|
829
|
-
el.continueSession.title = `Continue ${sessionId.slice(0, 8)} in the panel — forks it, so the original transcript is not written to`;
|
|
830
|
-
|
|
831
1902
|
if (source) source.close();
|
|
832
1903
|
source = new EventSource(api(`/api/stream?session=${encodeURIComponent(sessionId)}`));
|
|
833
1904
|
|
|
834
1905
|
source.addEventListener('graph', (e) => {
|
|
1906
|
+
heardNow();
|
|
835
1907
|
const g = JSON.parse(e.data);
|
|
836
1908
|
canvas.render(g);
|
|
1909
|
+
lastNodes = g.nodes;
|
|
1910
|
+
timeline.setNodes(g.nodes);
|
|
1911
|
+
list.setNodes(g.nodes);
|
|
1912
|
+
paintAttention(g.nodes);
|
|
1913
|
+
// The conversation's delegation cards and its context figure are the graph's.
|
|
1914
|
+
chat.refresh(sessionId, { contextTokens: g.nodes.find((n) => n.kind === 'session')?.tokens ?? null });
|
|
837
1915
|
// The inspector holds a node object from an earlier frame; refresh it so
|
|
838
1916
|
// status, tokens and tool counts keep moving while it is open.
|
|
839
1917
|
if (inspectorNode) {
|
|
@@ -845,27 +1923,25 @@ function selectSession(sessionId) {
|
|
|
845
1923
|
if (changed && fresh.kind === 'agent') loadDetail(fresh.id, fresh.status);
|
|
846
1924
|
}
|
|
847
1925
|
}
|
|
848
|
-
|
|
849
|
-
// Every status is named, so the parts add up to the total. A summary that
|
|
1926
|
+
// Every status is named, so the chips add up to the total. A summary that
|
|
850
1927
|
// reports "250 agents · 7 done" and stops invites the reader to assume the
|
|
851
|
-
// other 243 failed.
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
if (s.malformed) parts.push(`${s.malformed} malformed`);
|
|
857
|
-
el.summary.textContent = parts.join(' · ');
|
|
858
|
-
setPulse('on', 'live');
|
|
859
|
-
el.foot.textContent = `graph updated ${new Date().toLocaleTimeString()}`;
|
|
1928
|
+
// other 243 failed. A count of records that could not be read stays beside
|
|
1929
|
+
// them: it is not a status, and it is not nothing.
|
|
1930
|
+
const s = g.stats ?? {};
|
|
1931
|
+
paintSummary(s, s.malformed ? [`${s.malformed} malformed`] : []);
|
|
1932
|
+
el.foot.textContent = '';
|
|
860
1933
|
});
|
|
861
1934
|
|
|
862
|
-
source.addEventListener('idle',
|
|
1935
|
+
source.addEventListener('idle', heardNow);
|
|
863
1936
|
|
|
864
1937
|
source.addEventListener('waiting', (e) => {
|
|
1938
|
+
heardNow();
|
|
865
1939
|
let reason = '';
|
|
866
1940
|
try { reason = JSON.parse(e.data).reason ?? ''; } catch { /* keep default */ }
|
|
867
|
-
|
|
868
|
-
|
|
1941
|
+
paintSummary(null);
|
|
1942
|
+
paintAttention(null);
|
|
1943
|
+
timeline.setNodes([]);
|
|
1944
|
+
list.setNodes([]);
|
|
869
1945
|
el.foot.textContent = reason;
|
|
870
1946
|
canvas.render({ nodes: [], edges: [] });
|
|
871
1947
|
});
|
|
@@ -876,206 +1952,320 @@ function selectSession(sessionId) {
|
|
|
876
1952
|
source.addEventListener('fault', (e) => {
|
|
877
1953
|
let reason = 'unknown';
|
|
878
1954
|
try { reason = JSON.parse(e.data).reason ?? reason; } catch { /* keep default */ }
|
|
879
|
-
setPulse('off', 'fault');
|
|
880
1955
|
el.foot.textContent = `stream fault: ${reason}`;
|
|
881
1956
|
source.close();
|
|
882
1957
|
source = null;
|
|
883
1958
|
});
|
|
884
1959
|
|
|
1960
|
+
// A dropped stream reconnects by itself, and the two polls say within two
|
|
1961
|
+
// seconds whether the server is gone. So this only names what happened; it
|
|
1962
|
+
// does not decide Offline.
|
|
885
1963
|
source.onerror = () => {
|
|
886
1964
|
if (!source) return; // already closed by a fault
|
|
887
|
-
setPulse('off', 'reconnecting');
|
|
888
1965
|
el.foot.textContent = 'stream dropped — reconnecting';
|
|
889
1966
|
};
|
|
890
1967
|
}
|
|
891
1968
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1969
|
+
/* ------------------------------------------------------- live indicator */
|
|
1970
|
+
|
|
1971
|
+
const pulse = {
|
|
1972
|
+
dot: el.pulse.querySelector('.dot'),
|
|
1973
|
+
word: el.pulse.querySelector('strong'),
|
|
1974
|
+
detail: el.pulse.querySelector('.live-detail'),
|
|
1975
|
+
};
|
|
1976
|
+
function paintPulse() {
|
|
1977
|
+
const l = liveness(Date.now(), heard.okAt, heard.failed);
|
|
1978
|
+
el.pulse.dataset.state = l.state;
|
|
1979
|
+
pulse.dot.dataset.tone = l.tone ?? 'none';
|
|
1980
|
+
pulse.word.textContent = l.word;
|
|
1981
|
+
pulse.detail.textContent = l.detail ? `· ${l.detail}` : '';
|
|
895
1982
|
}
|
|
1983
|
+
paintPulse();
|
|
1984
|
+
setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); }, 1000);
|
|
896
1985
|
|
|
897
|
-
|
|
898
|
-
let expanded = new Set();
|
|
899
|
-
let filterText = '';
|
|
900
|
-
let showMissing = false;
|
|
1986
|
+
/* -------------------------------------------------------------- home, keys */
|
|
901
1987
|
|
|
902
|
-
|
|
903
|
-
|
|
1988
|
+
// The mark goes back to where the panel opens: the session that moved last.
|
|
1989
|
+
el.home.addEventListener('click', (e) => {
|
|
1990
|
+
e.preventDefault();
|
|
1991
|
+
let latest = null;
|
|
1992
|
+
for (const p of projectsData?.projects ?? []) {
|
|
1993
|
+
for (const sn of p.sessions) if (!latest || sn.modifiedAt > latest.sn.modifiedAt) latest = { p, sn };
|
|
1994
|
+
}
|
|
1995
|
+
setTab('projects');
|
|
1996
|
+
if (!latest) return;
|
|
1997
|
+
expanded.add(latest.p.key);
|
|
904
1998
|
paintProjects();
|
|
1999
|
+
selectSession(latest.sn.sessionId);
|
|
905
2000
|
});
|
|
906
2001
|
|
|
907
|
-
|
|
908
|
-
if (
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
body: 'Nothing was read.',
|
|
920
|
-
why: data.reason,
|
|
921
|
-
});
|
|
2002
|
+
document.addEventListener('keydown', (e) => {
|
|
2003
|
+
if (e.key === 'Escape') {
|
|
2004
|
+
if (!el.menu.hidden) { closeMenu(); return; }
|
|
2005
|
+
if (document.activeElement === el.filter) {
|
|
2006
|
+
if (el.filter.value) { el.filter.value = ''; filterText = ''; paintProjects(); paintLive(); }
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
if (shell.classList.contains('side-open')) { setDrawer(false); return; }
|
|
2010
|
+
if (newPanel.isOpen) { newPanel.close(); return; }
|
|
2011
|
+
if (view === 'timeline' && timeline.selected) { timeline.select(null); return; }
|
|
2012
|
+
// Close the inspector and let go of the selection.
|
|
2013
|
+
if (!el.inspector.hidden) { canvas.clearSelection(); showInspector(null); }
|
|
922
2014
|
return;
|
|
923
2015
|
}
|
|
924
|
-
|
|
925
|
-
const
|
|
926
|
-
if (
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
el.
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
if (
|
|
938
|
-
|
|
939
|
-
|
|
2016
|
+
if (e.metaKey || e.ctrlKey) return;
|
|
2017
|
+
const t = e.target;
|
|
2018
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
|
2019
|
+
if (e.key === '/') {
|
|
2020
|
+
e.preventDefault();
|
|
2021
|
+
setSideHidden(false);
|
|
2022
|
+
el.filter.focus();
|
|
2023
|
+
} else if (e.key === '[') {
|
|
2024
|
+
setSideHidden(true);
|
|
2025
|
+
} else if (e.key === ']') {
|
|
2026
|
+
setSideHidden(false);
|
|
2027
|
+
} else if (e.key === 'g') {
|
|
2028
|
+
setView('graph');
|
|
2029
|
+
} else if (e.key === 't') {
|
|
2030
|
+
setView('timeline');
|
|
2031
|
+
} else if (e.key === 'l') {
|
|
2032
|
+
setView('list');
|
|
2033
|
+
} else if (e.key === 'j') {
|
|
2034
|
+
stepAttention(1);
|
|
2035
|
+
} else if (e.key === 'k') {
|
|
2036
|
+
stepAttention(-1);
|
|
2037
|
+
} else if (e.key === '?') {
|
|
2038
|
+
canvas.showLegend(true);
|
|
940
2039
|
}
|
|
941
|
-
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
/* ------------------------------------------------------------ approvals
|
|
2043
|
+
A session started here asks before every tool call. What is waiting, in every
|
|
2044
|
+
such session, is one queue, drawn in one place: the dock under the canvas. The
|
|
2045
|
+
rules are in approvals.js; this is where they meet the server and the page. */
|
|
2046
|
+
|
|
2047
|
+
const clock = new ServerClock();
|
|
2048
|
+
const serverNow = () => clock.now(Date.now());
|
|
2049
|
+
const owned = new Map(); // sessionId -> its summary, as /api/owned last gave it
|
|
2050
|
+
const decided = new Map(); // request key -> the verdict this page sent
|
|
2051
|
+
const answered = new Set(); // request keys the server has taken an answer for; the hook picks it up within a poll
|
|
2052
|
+
const outcomes = new Map(); // sessionId -> what became of its requests, as far as this page saw
|
|
2053
|
+
let waitingNow = [];
|
|
2054
|
+
let waitingSig = '';
|
|
2055
|
+
let lastNodes = null;
|
|
2056
|
+
|
|
2057
|
+
function sessionLabel(id) {
|
|
2058
|
+
const known = findSessionRow(id);
|
|
2059
|
+
return known ? sessionName(known.session, labels.all()) : null;
|
|
942
2060
|
}
|
|
943
2061
|
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
2062
|
+
const dock = new Dock(el.dock, {
|
|
2063
|
+
now: serverNow,
|
|
2064
|
+
onDecide: decideRequest,
|
|
2065
|
+
onLocate: (item) => {
|
|
2066
|
+
if (item.sessionId !== current) selectSession(item.sessionId);
|
|
2067
|
+
if (item.agentId) goTo(item.agentId);
|
|
2068
|
+
},
|
|
2069
|
+
where: (item) => (item.sessionId === current ? null : item.sessionName),
|
|
2070
|
+
});
|
|
947
2071
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
2072
|
+
/** Is the session being looked at still going? The fleet says so, and so does a session started here. */
|
|
2073
|
+
function sessionIsLive() {
|
|
2074
|
+
if (!current) return false;
|
|
2075
|
+
const mine = owned.get(current);
|
|
2076
|
+
if (mine && ['starting', 'idle', 'working'].includes(mine.state)) return true;
|
|
2077
|
+
const st = sessionStatus(current, fleetData);
|
|
2078
|
+
return st.known && st.key !== 'ended';
|
|
2079
|
+
}
|
|
953
2080
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
2081
|
+
/** The session card's state: waiting on the viewer outranks what the fleet says, as it does for an agent. */
|
|
2082
|
+
function sessionStateNow() {
|
|
2083
|
+
const own = waitingNow.some((r) => r.sessionId === current && !r.agentId);
|
|
2084
|
+
if (own) return { key: 'waiting', word: 'Needs you', tone: 'waiting', known: true };
|
|
2085
|
+
return sessionStatus(current, fleetData);
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/** Tell the canvas who is parked on an approval, so the card, its wire, the map and the strip all say it. */
|
|
2089
|
+
function paintWaiting(force = false) {
|
|
2090
|
+
const here = waitingNow.filter((r) => r.sessionId === current);
|
|
2091
|
+
const sig = `${current}|${here.map((r) => r.agentId ?? 'session').sort().join(',')}`;
|
|
2092
|
+
if (!force && sig === waitingSig) return;
|
|
2093
|
+
waitingSig = sig;
|
|
2094
|
+
canvas.setWaiting(here.filter((r) => r.agentId).map((r) => r.agentId));
|
|
2095
|
+
timeline.setWaiting(here.filter((r) => r.agentId).map((r) => r.agentId));
|
|
2096
|
+
if (current) canvas.setSessionState(sessionStateNow());
|
|
2097
|
+
if (lastNodes) paintAttention(lastNodes);
|
|
2098
|
+
if (inspectorNode) paintInspector();
|
|
2099
|
+
}
|
|
961
2100
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
2101
|
+
function paintApprovals() {
|
|
2102
|
+
// A request the server has recorded an answer for is over, as far as the viewer is concerned: the hook reads
|
|
2103
|
+
// the answer on its next poll, and until it does the request would sit here with nothing left to press.
|
|
2104
|
+
const listed = queue([...owned.values()], sessionLabel);
|
|
2105
|
+
for (const key of [...answered]) if (!listed.some((r) => r.key === key)) answered.delete(key);
|
|
2106
|
+
const next = listed.filter((r) => !answered.has(r.key));
|
|
2107
|
+
for (const s of settled(waitingNow, next, decided, serverNow())) {
|
|
2108
|
+
decided.delete(s.key);
|
|
2109
|
+
const log = outcomes.get(s.sessionId) ?? [];
|
|
2110
|
+
log.push(s);
|
|
2111
|
+
if (log.length > 40) log.shift();
|
|
2112
|
+
outcomes.set(s.sessionId, log);
|
|
2113
|
+
// An allowed call simply goes ahead; a refused one is said, because the agent has already been told.
|
|
2114
|
+
if (s.outcome === 'denied' || s.outcome === 'timed-out') dock.say(s.outcome, next.length > 0);
|
|
2115
|
+
}
|
|
2116
|
+
waitingNow = next;
|
|
2117
|
+
// The dock takes its height from the canvas when it appears and gives it back when it goes.
|
|
2118
|
+
const was = el.dock.hidden;
|
|
2119
|
+
dock.render(next);
|
|
2120
|
+
list.setQueue(next);
|
|
2121
|
+
chat.setWaiting(next);
|
|
2122
|
+
// The Timeline's waiting periods are the server's record of this session's approvals; a session Studio did
|
|
2123
|
+
// not start has none, and the Timeline says so.
|
|
2124
|
+
timeline.setOwned(current ? owned.get(current) ?? null : null);
|
|
2125
|
+
timeline.setLive(sessionIsLive());
|
|
2126
|
+
measureDock();
|
|
2127
|
+
if (was !== el.dock.hidden) canvas.fitIfUntouched();
|
|
2128
|
+
paintWaiting();
|
|
2129
|
+
if (inspectorNode && inspectorTab === 'gates') paintInspector();
|
|
2130
|
+
}
|
|
966
2131
|
|
|
967
|
-
|
|
968
|
-
|
|
2132
|
+
async function decideRequest(item, verdict) {
|
|
2133
|
+
decided.set(item.key, verdict);
|
|
2134
|
+
let res;
|
|
2135
|
+
try {
|
|
2136
|
+
const r = await fetch(
|
|
2137
|
+
api(`/api/owned/${encodeURIComponent(item.sessionId)}/permissions/${encodeURIComponent(item.toolUseId)}`),
|
|
2138
|
+
{ method: 'POST', headers: { 'content-type': 'application/json', ...writeHeaders }, body: JSON.stringify({ verdict }) },
|
|
2139
|
+
);
|
|
2140
|
+
res = await r.json();
|
|
2141
|
+
} catch (e) {
|
|
2142
|
+
res = { ok: false, reason: e.message };
|
|
2143
|
+
}
|
|
2144
|
+
if (!res.ok) {
|
|
2145
|
+
// Nothing reached the hook, so nothing was decided: the buttons come back and the clock is still running.
|
|
2146
|
+
decided.delete(item.key);
|
|
2147
|
+
dock.release(item.key);
|
|
2148
|
+
list.release(item.key);
|
|
2149
|
+
toast(`Decision not recorded — ${res.reason ?? 'no reason given'}`);
|
|
969
2150
|
return;
|
|
970
2151
|
}
|
|
2152
|
+
answered.add(item.key);
|
|
2153
|
+
paintApprovals();
|
|
2154
|
+
pollOwned();
|
|
2155
|
+
}
|
|
971
2156
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
2157
|
+
// The dock's height and form follow its own size. A floating inspector stops above it, so the three answers are
|
|
2158
|
+
// never under a panel; and a dock too narrow for one row goes to two, whatever the window's width.
|
|
2159
|
+
const DOCK_ONE_ROW_PX = 760;
|
|
2160
|
+
let dockSize = '';
|
|
2161
|
+
function measureDock() {
|
|
2162
|
+
const h = el.dock.hidden ? 0 : el.dock.offsetHeight;
|
|
2163
|
+
const form = el.dock.hidden ? '' : (el.dock.offsetWidth < DOCK_ONE_ROW_PX ? 'stacked' : 'row');
|
|
2164
|
+
// Written only when it changed: this runs from a resize observer, and a write that changes nothing would
|
|
2165
|
+
// still ask for another layout.
|
|
2166
|
+
const size = `${h}|${form}`;
|
|
2167
|
+
if (size === dockSize) return;
|
|
2168
|
+
dockSize = size;
|
|
2169
|
+
shell.style.setProperty('--dock-h', `${h}px`);
|
|
2170
|
+
if (form) el.dock.dataset.form = form;
|
|
2171
|
+
}
|
|
2172
|
+
// A frame later, so the write is not made inside the observer's own delivery.
|
|
2173
|
+
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(measureDock)).observe(el.dock);
|
|
2174
|
+
|
|
2175
|
+
// The sessions this panel owns: which ones, what each is waiting for, and what time the server makes it.
|
|
2176
|
+
// They survive a page reload as long as the server does.
|
|
2177
|
+
let ownedBusy = false;
|
|
2178
|
+
async function pollOwned() {
|
|
2179
|
+
if (ownedBusy) return;
|
|
2180
|
+
ownedBusy = true;
|
|
2181
|
+
try {
|
|
2182
|
+
const r = await getJson('/api/owned');
|
|
2183
|
+
clock.sync(r.now, Date.now());
|
|
2184
|
+
if (Array.isArray(r.modes)) offeredModes = r.modes;
|
|
2185
|
+
const seen = new Set();
|
|
2186
|
+
for (const sn of r.sessions ?? []) { ownedIds.add(sn.sessionId); owned.set(sn.sessionId, sn); seen.add(sn.sessionId); }
|
|
2187
|
+
for (const id of [...owned.keys()]) if (!seen.has(id)) owned.delete(id);
|
|
2188
|
+
paintApprovals();
|
|
2189
|
+
} catch { /* the Live indicator says so; the dock keeps what it last showed, clock included */ }
|
|
2190
|
+
finally { ownedBusy = false; }
|
|
2191
|
+
}
|
|
975
2192
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
k.classList.add('ahead');
|
|
1004
|
-
k.textContent = p.kit.version;
|
|
1005
|
-
k.title = `Crewforth ${p.kit.version} is ahead of the published ${p.kit.latest}`;
|
|
1006
|
-
} else {
|
|
1007
|
-
k.classList.add('ok');
|
|
1008
|
-
k.textContent = p.kit.version;
|
|
1009
|
-
k.title = `Crewforth ${p.kit.version} — current`;
|
|
1010
|
-
}
|
|
1011
|
-
counts.prepend(k);
|
|
1012
|
-
}
|
|
1013
|
-
if (!p.exists) head.classList.add('gone');
|
|
1014
|
-
head.title = (p.cwd ?? p.dir) + (p.exists ? '' : ' — directory no longer exists');
|
|
1015
|
-
head.addEventListener('click', () => {
|
|
1016
|
-
if (expanded.has(p.key)) expanded.delete(p.key); else expanded.add(p.key);
|
|
1017
|
-
paintProjects();
|
|
1018
|
-
});
|
|
1019
|
-
frag.append(head);
|
|
1020
|
-
|
|
1021
|
-
if (!open) continue;
|
|
1022
|
-
|
|
1023
|
-
for (const sn of p.sessions) {
|
|
1024
|
-
const row = node('div', 'srow');
|
|
1025
|
-
row.dataset.id = sn.sessionId;
|
|
1026
|
-
row.setAttribute('role', 'treeitem');
|
|
1027
|
-
row.setAttribute('aria-current', String(sn.sessionId === current));
|
|
1028
|
-
// The name people actually gave the session, with the id as the fallback
|
|
1029
|
-
// it always was.
|
|
1030
|
-
row.append(node('span', 'sname', sn.title || sn.sessionId.slice(0, 8)));
|
|
1031
|
-
const right = node('span', 'sright');
|
|
1032
|
-
if (sn.agentCount) right.append(node('span', 'sbadge', `${sn.agentCount} ▸`));
|
|
1033
|
-
right.append(node('span', 'sagents', fmtSize(sn.bytes)));
|
|
1034
|
-
row.append(right);
|
|
1035
|
-
if (ownedIds.has(sn.sessionId)) row.classList.add('owned');
|
|
1036
|
-
row.title = `${sn.sessionId}\n${sn.title ?? ''}`;
|
|
1037
|
-
row.addEventListener('click', () => selectSession(sn.sessionId));
|
|
1038
|
-
frag.append(row);
|
|
1039
|
-
}
|
|
2193
|
+
// A pane's stream says the moment a session's queue changes. It replays what it has already said when it
|
|
2194
|
+
// reconnects, so it is taken as a reason to ask, not as the answer.
|
|
2195
|
+
chat.onPermissions = () => pollOwned();
|
|
2196
|
+
|
|
2197
|
+
/* ------------------------------------------------------- the stage's note
|
|
2198
|
+
One line above the view, for two things that are about the whole stage: the server is not answering, or this
|
|
2199
|
+
view is being shown in a window too narrow for it. Offline is said first. */
|
|
2200
|
+
|
|
2201
|
+
let nextPollAt = null;
|
|
2202
|
+
const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', 'Retry now') };
|
|
2203
|
+
stageNote.button.type = 'button';
|
|
2204
|
+
stageNote.button.addEventListener('click', () => retryNow());
|
|
2205
|
+
el.stageNote.append(stageNote.text, stageNote.retry, node('span', 'row-fill'), stageNote.button);
|
|
2206
|
+
|
|
2207
|
+
function paintStageNote() {
|
|
2208
|
+
const l = liveness(Date.now(), heard.okAt, heard.failed);
|
|
2209
|
+
const off = offlineNote(l.state, heard.okAt, nextPollAt, Date.now());
|
|
2210
|
+
const warn = narrowWarning(view, phoneBand.matches);
|
|
2211
|
+
// The last picture stays on screen while the server is away, and is drawn as old.
|
|
2212
|
+
el.stage.dataset.offline = String(Boolean(off));
|
|
2213
|
+
el.stageNote.hidden = !off && !warn;
|
|
2214
|
+
el.stageNote.dataset.kind = off ? 'offline' : 'narrow';
|
|
2215
|
+
stageNote.text.textContent = off ? off.text : (warn ?? '');
|
|
2216
|
+
stageNote.retry.textContent = off ? off.retry : '';
|
|
2217
|
+
stageNote.retry.hidden = !off;
|
|
2218
|
+
stageNote.button.hidden = !off;
|
|
2219
|
+
}
|
|
1040
2220
|
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
for (const o of (data.origins ?? []).filter((x) => x.ok === false)) {
|
|
1046
|
-
const w = node('div', 'ptrunc pdown');
|
|
1047
|
-
w.textContent = `${o.name} unreachable — ${o.reason}`;
|
|
1048
|
-
frag.append(w);
|
|
1049
|
-
}
|
|
2221
|
+
function retryNow() {
|
|
2222
|
+
nextPollAt = Date.now() + FLEET_POLL_MS;
|
|
2223
|
+
pollFleet(); pollSessions(); pollOwned();
|
|
2224
|
+
}
|
|
1050
2225
|
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
? `hiding nothing — ${hidden} directory(ies) no longer exist`
|
|
1055
|
-
: `${hidden} project(s) hidden — their directories no longer exist`;
|
|
1056
|
-
const b = node('button', 'ghost', showMissing ? 'hide' : 'show');
|
|
1057
|
-
b.addEventListener('click', () => { showMissing = !showMissing; paintProjects(); });
|
|
1058
|
-
t2.append(b);
|
|
1059
|
-
frag.append(t2);
|
|
1060
|
-
}
|
|
1061
|
-
el.sessions.replaceChildren(frag);
|
|
2226
|
+
/* ---------------------------------------------------------------- phone
|
|
2227
|
+
Under 640px the bar has room for the mark and three controls. The session's name and its summary move under
|
|
2228
|
+
it, above the view; the navigator is a drawer the menu button opens. */
|
|
1062
2229
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
2230
|
+
const barFill = el.bar.querySelector('.bar-fill');
|
|
2231
|
+
const afterSummary = el.summary.nextElementSibling;
|
|
2232
|
+
|
|
2233
|
+
function setDrawer(open) {
|
|
2234
|
+
shell.classList.toggle('side-open', open);
|
|
2235
|
+
el.menuBtn.setAttribute('aria-expanded', String(open));
|
|
2236
|
+
el.menuBtn.setAttribute('aria-label', open ? 'Close the navigator' : 'Open the navigator');
|
|
2237
|
+
}
|
|
2238
|
+
el.menuBtn.addEventListener('click', () => setDrawer(!shell.classList.contains('side-open')));
|
|
2239
|
+
// Choosing a session in the drawer is why it was opened; it closes behind the choice.
|
|
2240
|
+
el.sessions.addEventListener('click', (e) => { if (phoneBand.matches && e.target.closest?.('.srow')) setDrawer(false); });
|
|
2241
|
+
el.fleet.addEventListener('click', (e) => { if (phoneBand.matches && e.target.closest?.('.srow, .live-row')) setDrawer(false); });
|
|
2242
|
+
el.sideHide.addEventListener('click', () => { if (phoneBand.matches) setDrawer(false); });
|
|
2243
|
+
|
|
2244
|
+
let headOnPhone = false;
|
|
2245
|
+
function applyPhoneBand(initial = false) {
|
|
2246
|
+
// Moved only when the band changes: the bar is where they are written in the page.
|
|
2247
|
+
if (phoneBand.matches && !headOnPhone) {
|
|
2248
|
+
el.phoneHead.append(el.crumb, el.summary);
|
|
2249
|
+
headOnPhone = true;
|
|
2250
|
+
} else if (!phoneBand.matches && headOnPhone) {
|
|
2251
|
+
el.bar.insertBefore(el.crumb, barFill);
|
|
2252
|
+
el.bar.insertBefore(el.summary, afterSummary);
|
|
2253
|
+
headOnPhone = false;
|
|
2254
|
+
setDrawer(false);
|
|
1067
2255
|
}
|
|
2256
|
+
// A window that crosses the line takes that width's default view, unless the viewer has chosen one.
|
|
2257
|
+
if (!initial && !store.get('crewforth-studio-view')) setView(defaultView(null, phoneBand.matches), null, false);
|
|
2258
|
+
paintStageNote();
|
|
2259
|
+
fitBar();
|
|
1068
2260
|
}
|
|
2261
|
+
phoneBand.addEventListener('change', () => applyPhoneBand());
|
|
1069
2262
|
|
|
1070
2263
|
/* --------------------------------------------------------------- polling */
|
|
1071
2264
|
|
|
1072
2265
|
async function pollFleet() {
|
|
1073
2266
|
try {
|
|
1074
|
-
|
|
1075
|
-
renderFleet(fleet);
|
|
1076
|
-
renderReach(fleet.roster);
|
|
2267
|
+
renderFleet(await getJson('/api/fleet'));
|
|
1077
2268
|
} catch (e) {
|
|
1078
|
-
el.fleetMeta.textContent = 'offline';
|
|
1079
2269
|
renderNote(el.fleet, { kind: 'unmeasured', title: 'Server unreachable', why: e.message });
|
|
1080
2270
|
}
|
|
1081
2271
|
}
|
|
@@ -1083,16 +2273,18 @@ async function pollFleet() {
|
|
|
1083
2273
|
async function pollSessions() {
|
|
1084
2274
|
try {
|
|
1085
2275
|
renderSessions(await getJson('/api/projects'));
|
|
1086
|
-
} catch
|
|
1087
|
-
el.sessionsMeta.textContent = 'offline';
|
|
1088
|
-
}
|
|
2276
|
+
} catch { /* the Live indicator says so; the list keeps what it last showed */ }
|
|
1089
2277
|
}
|
|
1090
2278
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
2279
|
+
setTab(store.get('crewforth-studio-nav-tab') ?? 'projects');
|
|
2280
|
+
// The view the viewer chose, or the one this window opens on: the List on a phone, the graph elsewhere.
|
|
2281
|
+
applyPhoneBand(true);
|
|
2282
|
+
{
|
|
2283
|
+
const stored = store.get('crewforth-studio-view');
|
|
2284
|
+
setView(defaultView(stored, phoneBand.matches), null, Boolean(stored));
|
|
2285
|
+
}
|
|
2286
|
+
pollFleet(); pollSessions(); pollOwned();
|
|
2287
|
+
setInterval(() => { nextPollAt = Date.now() + FLEET_POLL_MS; pollFleet(); }, FLEET_POLL_MS);
|
|
2288
|
+
nextPollAt = Date.now() + FLEET_POLL_MS;
|
|
2289
|
+
setInterval(pollOwned, FLEET_POLL_MS);
|
|
1098
2290
|
setInterval(pollSessions, SESSION_POLL_MS);
|