crewforth 3.0.4 → 3.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +3 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +86 -34
  5. package/kit/CLAUDE.md +1 -1
  6. package/kit/README.md +1 -1
  7. package/kit/agents/crew-commit-agent.md +7 -3
  8. package/kit/agents/crew-frontend-expert.md +4 -4
  9. package/kit/agents/crew-performance-expert.md +1 -1
  10. package/kit/agents/crew-review-agent.md +1 -1
  11. package/kit/eval/doctor.sh +57 -3
  12. package/kit/eval/golden-routing.txt +13 -0
  13. package/kit/eval/lib/git-bash.sh +11 -2
  14. package/kit/eval/parser-conformance.sh +1 -0
  15. package/kit/eval/smoke-test.sh +2572 -373
  16. package/kit/hooks/commit-msg +1 -1
  17. package/kit/hooks/context-usage.sh +7 -1
  18. package/kit/hooks/guard-bash.sh +1545 -104
  19. package/kit/hooks/guard-commit-scan.sh +75 -19
  20. package/kit/hooks/guard-powershell.sh +65 -15
  21. package/kit/hooks/guard-write.sh +74 -22
  22. package/kit/hooks/no-bash-guard.ps1 +71 -0
  23. package/kit/hooks/pre-commit +5 -2
  24. package/kit/hooks/prompt-approval.sh +484 -0
  25. package/kit/hooks/route-hint.sh +13 -5
  26. package/kit/hooks/session-rehydrate.sh +18 -0
  27. package/kit/hooks/skill-trust.sh +17 -2
  28. package/kit/owned-blobs.tsv +43 -0
  29. package/kit/settings.json +20 -4
  30. package/kit/skills/a11y/SKILL.md +1 -0
  31. package/kit/skills/crew-doctor/SKILL.md +2 -2
  32. package/kit/skills/frontend/SKILL.md +3 -3
  33. package/kit/skills/frontend-flutter/SKILL.md +124 -0
  34. package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +81 -0
  35. package/kit/skills/frontend-flutter/references/platform-async-release.md +100 -0
  36. package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
  37. package/kit/skills/frontend-flutter/references/sensitive-data.md +54 -0
  38. package/kit/studio/server/hooks/studio-gate.sh +35 -1
  39. package/kit/studio/server/index.js +14 -4
  40. package/kit/studio/server/lib/graph.js +64 -3
  41. package/kit/studio/server/lib/kit.js +16 -2
  42. package/kit/studio/server/lib/permissions.js +77 -2
  43. package/kit/studio/server/lib/projects.js +60 -7
  44. package/kit/studio/server/lib/session.js +16 -2
  45. package/kit/studio/web/app.js +1765 -573
  46. package/kit/studio/web/approvals.js +124 -0
  47. package/kit/studio/web/canvas.js +932 -885
  48. package/kit/studio/web/chat.js +322 -145
  49. package/kit/studio/web/convo.js +174 -0
  50. package/kit/studio/web/dock.js +221 -0
  51. package/kit/studio/web/graph-plan.js +457 -0
  52. package/kit/studio/web/icons/builtin.svg +1 -0
  53. package/kit/studio/web/index.html +116 -50
  54. package/kit/studio/web/inspect.js +110 -0
  55. package/kit/studio/web/list-plan.js +112 -0
  56. package/kit/studio/web/list.js +157 -0
  57. package/kit/studio/web/liveness.js +66 -0
  58. package/kit/studio/web/nav.js +237 -0
  59. package/kit/studio/web/newsession.js +235 -0
  60. package/kit/studio/web/press.js +65 -0
  61. package/kit/studio/web/style.css +1281 -1014
  62. package/kit/studio/web/theme.js +43 -0
  63. package/kit/studio/web/timeline-plan.js +291 -0
  64. package/kit/studio/web/timeline.js +400 -0
  65. package/kit/studio/web/tokens.css +131 -0
  66. package/kit/studio/web/toolbar-fit.js +35 -0
  67. package/package.json +5 -4
@@ -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
- reachHead: document.getElementById('reach-head'),
24
- reachMeta: document.getElementById('reach-meta'),
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
- const r = await fetch(api(p), { cache: 'no-store' });
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
- return r.json();
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
- const savedTheme = store.get('crewforth-studio-theme');
59
- if (savedTheme === 'light' || savedTheme === 'dark') document.documentElement.dataset.theme = savedTheme;
60
-
61
- el.theme.addEventListener('click', () => {
62
- const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
63
- document.documentElement.dataset.theme = next;
64
- store.set('crewforth-studio-theme', next);
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: 260, wide: 460, varName: '--side-w', key: 'crewforth-studio-side-w' },
75
- chat: { min: 280, max: 900, def: 420, wide: 720, varName: '--chat-w', key: 'crewforth-studio-chat-w' },
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
- function setSideHidden(hidden, refit = true) {
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.sideShow.hidden = !hidden;
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
- setSideHidden(store.get('crewforth-studio-side-hidden') === '1', false);
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. Collapsing the side
156
- panels is a separate control on purpose: the two compose, and folding one
157
- into the other would make each less predictable. */
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
- const chat = new Chat(el.chat, { api, headers: writeHeaders });
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
- // Switching tabs points the canvas at that session too: the graph and the
252
- // conversation are two views of one thing.
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
- const any = chat.ids.length > 0;
262
- el.chat.hidden = !any;
263
- el.chatSplit.hidden = !any; shell.classList.toggle('no-chat', !any);
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 row = el.sessions.querySelector(`.srow[data-id="${CSS.escape(sessionId)}"] .sname`);
280
- chat.openReadOnly(sessionId, row?.textContent ?? null);
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. It forks rather than
303
- // writing into the original transcript, so a session still open in a terminal
304
- // somewhere is not being written to by two things at once.
305
- el.continueSession.addEventListener('click', async () => {
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
- el.continueSession.disabled = true;
309
- el.continueSession.textContent = 'continuing…';
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
- el.continueSession.disabled = false;
325
- el.continueSession.textContent = '⑂ fork & continue';
371
+ button.disabled = false;
372
+ button.textContent = 'Continue here';
326
373
  }
327
- });
374
+ }
328
375
 
329
- el.newSession.addEventListener('click', async () => {
330
- const cwd = projectsData?.cwd ?? null;
331
- el.newSession.disabled = true;
332
- el.newSession.textContent = 'starting…';
333
- try {
334
- // `plan` by default: a panel that can start a session must not also be the
335
- // reason one got write access nobody asked for.
336
- const r = await chat.start({ cwd, permissionMode: 'plan' });
337
- if (!r.ok) el.foot.textContent = `could not start a session: ${r.reason}`;
338
- else ownedIds.add(r.session.sessionId);
339
- } finally {
340
- el.newSession.disabled = false;
341
- el.newSession.textContent = '+ session';
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
- const canvas = new Canvas(document.getElementById('canvas'), { onSelect: showInspector });
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
- // Twelve unrecognised agents and zero agents read as the same grey. Which
353
- // one it is has to be said out loud, or the panel is drawing "not measured"
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 colours Not measured — ${p.reason}`;
642
+ el.foot.textContent = `agent identity Not measured — ${p.reason}`;
358
643
  }
359
644
  })
360
- .catch(() => { /* neutral colours; the canvas already defaults safely */ });
645
+ .catch(() => { /* every agent is drawn as unrecognised, which is what is known */ });
361
646
 
362
- let inspectorTab = 'report';
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
- // The session node IS the conversation. Clicking it opened a details panel
369
- // and nothing else, which asked the reader to go and find the talking
370
- // elsewhere.
371
- if (node?.kind === 'session' && current) openConversation(current);
372
- if (!node) { el.inspector.hidden = true; el.inspector.classList.remove('wide'); return; }
373
- el.inspector.hidden = false;
374
- inspectorTab = node.kind === 'session' ? 'meta' : 'report';
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
- if (node.kind === 'session' && current) loadKit(current);
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('tool calls', n.toolCount); row('last tool', n.lastTool);
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
- function fmtDur(ms) {
425
- const sec = Math.round(ms / 1000);
426
- return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m ${sec % 60}s`;
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
- const head = document.createElement('div');
435
- head.className = 'ihead';
436
- const h3 = document.createElement('h3');
437
- h3.textContent = n.kind === 'session' ? 'Session' : (n.agentType ?? 'Agent');
438
- const chip = node('span', 'cv-chip', n.kind === 'session' ? `${n.turns ?? 0} turns` : (n.status ?? '?'));
439
- chip.dataset.status = n.status ?? 'unknown';
440
- const wide = node('button', 'ghost iwide', el.inspector.classList.contains('wide') ? '›' : '‹');
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
- head.append(h3, chip, wide, close);
449
-
450
- const sub = node('div', 'isub', n.description ?? n.cwd ?? '');
451
-
452
- const tabs = document.createElement('div');
453
- tabs.className = 'itabs';
454
- const available = n.kind === 'session'
455
- ? ['meta', 'gates', 'stats', 'board']
456
- : ['report', 'activity', 'prompt', 'meta'];
457
- for (const t of available) {
458
- const b = node('button', `itab${inspectorTab === t ? ' on' : ''}`, t);
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 = document.createElement('div');
464
- body.className = 'ibody';
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
- if (['gates', 'stats', 'board'].includes(inspectorTab)) {
467
- paintKit(body, inspectorTab);
468
- } else if (inspectorTab === 'meta') {
469
- body.append(metaRows(n));
470
- const tools = Object.entries(n.tools ?? {});
471
- if (tools.length) {
472
- const wrap = node('div', 'tools');
473
- for (const [name, c] of tools.sort((a, b) => b[1] - a[1])) wrap.append(node('span', 'cv-bit', `${name} ×${c}`));
474
- body.append(wrap);
475
- }
476
- } else if (!detail) {
477
- body.append(node('div', 'ihint', 'Loading…'));
478
- } else if (detail.loading) {
479
- body.append(node('div', 'ihint', 'Reading the agent transcript…'));
480
- } else if (detail.measured === false) {
481
- const hint = node('div', 'ihint', `Not measured — ${detail.reason}`);
482
- if (detail.transient) {
483
- const retry = node('button', 'ghost', 'retry');
484
- retry.addEventListener('click', () => {
485
- detailCache.delete(`${n.id}:${n.status ?? '?'}`);
486
- loadDetail(n.id, n.status);
487
- });
488
- hint.append(' ', retry);
489
- }
490
- body.append(hint);
491
- } else if (inspectorTab === 'report') {
492
- if (detail.report) {
493
- const bar = node('div', 'ibar');
494
- bar.append(node('span', 'meta', `${detail.report.length.toLocaleString()} chars`));
495
- const copy = node('button', 'ghost', 'copy');
496
- copy.addEventListener('click', async () => {
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
- el.inspector.replaceChildren(head, sub, tabs, body);
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?.kind === 'session') paintInspector();
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 paintKit(body, tab) {
971
+ function paintStats(body) {
570
972
  if (!kitData) { body.append(node('div', 'ihint', 'Reading Crewforth…')); return; }
571
-
572
- if (tab === 'stats') {
573
- const s = kitData.stats;
574
- if (!s?.measured) { body.append(unmeasured(s?.reason)); return; }
575
- const dl = document.createElement('dl');
576
- for (const [k, v] of Object.entries(s.metrics)) {
577
- const dt = node('dt', null, k.replace(/_/g, ' '));
578
- const dd = node('dd', null, v.toLocaleString());
579
- if (v > 0 && /runaway|errors|interrupts/.test(k)) dd.classList.add('bad');
580
- dl.append(dt, dd);
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(dl);
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
- if (tab === 'board') {
588
- const b = kitData.board;
589
- if (!b?.measured) { body.append(unmeasured(b?.reason)); return; }
590
- if (!b.present) {
591
- body.append(node('div', 'ihint', b.text));
592
- return;
593
- }
594
- body.append(node('pre', 'md-code', b.text));
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
- // gates
599
- const log = kitData.log;
600
- const rep = kitData.report;
601
-
602
- if (rep?.measured) {
603
- const head = node('div', 'kit-sum');
604
- head.append(node('span', 'cv-bit', `${rep.rules} rules`));
605
- head.append(node('span', 'cv-bit', `${(rep.decisions ?? 0).toLocaleString()} decisions`));
606
- body.append(head);
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(unmeasured(rep?.reason));
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
- // Owned sessions carry the moment each gate ran; the log does not.
612
- const owned = chat.panes.get(current);
613
- const live = owned?.session?.gateEvents ?? [];
614
- if (live.length) {
615
- body.append(node('h4', 'md-h', 'Happened'));
616
- for (const e of live.slice(-12).reverse()) {
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(e.at).toLocaleTimeString()));
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
- body.append(row);
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 (!log?.measured) { body.append(unmeasured(log?.reason)); return; }
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
- body.append(node('div', 'ihint',
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
- body.append(counts);
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
- body.append(row);
1121
+ observed.push(row);
654
1122
  }
1123
+ body.append(isection('Observed', ...observed));
655
1124
  }
656
1125
 
657
- /* ---------------------------------------------------------------- fleet */
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
- // Sessions on other machines. The panel cannot reach them and does not pretend
684
- // to: their transcripts are on those machines' disks, so there is nothing to
685
- // draw but the roster itself, and even that is a snapshot rather than a feed.
686
- function renderReach(roster) {
687
- if (!roster) { el.reachHead.hidden = true; el.reach.replaceChildren(); return; }
688
-
689
- const remote = (roster.peers ?? []).filter((p) => p.remote);
690
- if (!roster.measured || !remote.length) {
691
- // A roster nobody has recorded is not "no peers". Say which it is.
692
- el.reachHead.hidden = false;
693
- el.reachMeta.textContent = roster.measured ? '0' : 'not measured';
694
- el.reachMeta.className = 'meta warn';
695
- renderNote(el.reach, {
696
- kind: roster.measured ? '' : 'unmeasured',
697
- title: roster.measured ? 'None reachable' : 'Not measured',
698
- body: roster.measured
699
- ? 'A connected session looked and found no machines besides this one.'
700
- : 'No session here has recorded a peer list yet.',
701
- why: roster.measured ? null : roster.reason,
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
- el.reachHead.hidden = false;
707
- const when = new Date(roster.seenAt).toLocaleTimeString();
708
- el.reachMeta.textContent = `${remote.length} · seen ${when}`;
709
- el.reachMeta.className = 'meta';
710
- el.reachMeta.title =
711
- `Recorded by session ${String(roster.seenBy).slice(0, 8)} at ${when}. `
712
- + 'Only a session connected to Remote Control can see these, so this is what one last reported — not a live feed.';
713
-
714
- el.reach.replaceChildren(...remote.map((p) => {
715
- const row = node('div', 'session reach-row');
716
- const ring = node('span', 'ring');
717
- ring.dataset.status = p.status === 'running' ? 'busy' : (p.status === 'offline' ? 'unknown' : 'idle');
718
- ring.title = p.status ?? '';
719
-
720
- const who = node('div', 'who');
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.fleetMeta.textContent = 'not measured';
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 sessions = data.sessions ?? [];
749
- const origins = data.origins ?? [];
750
- const down = origins.filter((o) => o.ok === false);
751
- el.fleetMeta.textContent = origins.length > 1
752
- ? `${sessions.length} · ${origins.length - down.length}/${origins.length} machines`
753
- : `${sessions.length}`;
754
- el.fleetMeta.className = down.length ? 'meta warn' : 'meta';
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 (!sessions.length) {
760
- renderNote(el.fleet, { title: 'No sessions running', body: 'Measured — the machine has none open.' });
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
- const order = { busy: 0, waiting: 1, idle: 2 };
765
- sessions.sort((a, b) => (order[a.status] ?? 3) - (order[b.status] ?? 3));
766
-
767
- el.fleet.replaceChildren(...sessions.map((s) => {
768
- const row = node('div', 'session fleet-row');
769
- const ring = node('span', 'ring');
770
- ring.dataset.status = ['busy', 'waiting', 'idle'].includes(s.status) ? s.status : 'unknown';
771
- ring.title = s.status;
772
-
773
- const who = node('div', 'who');
774
- const nm = node('div', 'name');
775
- nm.append(document.createTextNode(s.name || s.sessionId.slice(0, 8)));
776
- if (s.origin && s.local === false) nm.append(node('span', 'origin', s.origin));
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.classList.add('clickable');
1640
+ row.tabIndex = 0;
1641
+ row.dataset.id = s.sessionId;
789
1642
  row.setAttribute('aria-current', String(s.sessionId === current));
790
- row.title = `Open ${s.name ?? s.sessionId.slice(0, 8)}`;
791
- row.addEventListener('click', () => selectSession(s.sessionId));
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.title = `${s.name ?? ''} — on ${s.origin}. Its transcript is on that machine.`;
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 sidebar is for choosing what to look at, and having a
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
- for (const r of el.sessions.querySelectorAll('.srow')) {
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
- const s = g.stats ?? {};
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
- const parts = [`${s.agents ?? 0} agents`];
853
- if (s.workflows) parts.push(`${s.workflows} workflows`);
854
- for (const [k, v] of Object.entries(s.byStatus ?? {})) if (v) parts.push(`${v} ${k}`);
855
- if (g.contextTokens != null) parts.push(`${(g.contextTokens / 1000).toFixed(0)}k ctx`);
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', () => setPulse('on', 'live'));
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
- setPulse('on', 'waiting');
868
- el.summary.textContent = 'no agents yet';
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
- function setPulse(cls, text) {
893
- el.pulse.className = `pulse ${cls}`;
894
- el.pulse.textContent = text;
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
- let projectsData = null;
898
- let expanded = new Set();
899
- let filterText = '';
900
- let showMissing = false;
1986
+ /* -------------------------------------------------------------- home, keys */
901
1987
 
902
- el.filter.addEventListener('input', () => {
903
- filterText = el.filter.value.trim().toLowerCase();
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
- function fmtSize(bytes) {
908
- if (bytes < 1024) return `${bytes}B`;
909
- if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
910
- return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
911
- }
912
-
913
- function renderSessions(data) {
914
- if (!data.measured) {
915
- el.sessionsMeta.textContent = 'not measured';
916
- renderNote(el.sessions, {
917
- kind: 'unmeasured',
918
- title: 'No transcripts here',
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
- projectsData = data;
925
- const t0 = data.totals ?? {};
926
- if (t0.kitInstalled) {
927
- const bits = [`Crewforth in ${t0.kitInstalled}`];
928
- if (t0.kitOutdated) bits.push(`${t0.kitOutdated} behind ${data.latest?.version ?? '?'}`);
929
- if (t0.kitUncompared) bits.push(`${t0.kitUncompared} not compared`);
930
- el.kit.textContent = bits.join(' · ');
931
- el.kit.className = `kitline${t0.kitOutdated ? ' warn' : ''}`;
932
- } else {
933
- el.kit.textContent = '';
934
- }
935
- // The project you are standing in starts open; the rest stay folded, or a
936
- // machine with 175 projects buries the one you are working in.
937
- if (!expanded.size) {
938
- const cur = data.projects.find((p) => p.current) ?? data.projects[0];
939
- if (cur) expanded.add(cur.key);
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
- paintProjects();
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
- function paintProjects() {
945
- const data = projectsData;
946
- if (!data) return;
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
- // Typing a filter is an explicit request, so it searches everything —
949
- // including the projects whose directories are gone. Hiding a result someone
950
- // asked for by name is worse than showing a dead path.
951
- let projects = (showMissing || filterText) ? data.projects : data.projects.filter((p) => p.exists);
952
- const hidden = data.projects.length - projects.length;
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
- if (filterText) {
955
- projects = projects.filter((p) =>
956
- p.label.toLowerCase().includes(filterText) ||
957
- (p.cwd ?? '').toLowerCase().includes(filterText) ||
958
- (p.origin ?? '').toLowerCase().includes(filterText) ||
959
- p.sessions.some((x) => (x.title ?? '').toLowerCase().includes(filterText)));
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
- const t = data.totals ?? {};
963
- el.sessionsMeta.textContent = filterText
964
- ? `${projects.length}/${t.live ?? 0}`
965
- : `${t.live ?? 0} · ${t.sessions ?? 0} sessions`;
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
- if (!projects.length) {
968
- renderNote(el.sessions, { title: 'Nothing matches', body: `No project matches “${filterText}”.` });
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
- const frag = document.createDocumentFragment();
973
- for (const p of projects) {
974
- const open = expanded.has(p.key) || Boolean(filterText);
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
- const head = node('div', `pgroup${p.current ? ' current' : ''}`);
977
- head.setAttribute('role', 'treeitem');
978
- head.setAttribute('aria-expanded', String(open));
979
- head.append(node('span', 'pcaret', open ? '▾' : '▸'));
980
- const lbl = node('span', 'plabel');
981
- lbl.append(document.createTextNode(p.label));
982
- // Where a project lives is part of its identity once more than one machine
983
- // is in view.
984
- if (p.origin && p.local === false) lbl.append(node('span', 'origin', p.origin));
985
- head.append(lbl);
986
- const counts = node('span', 'pcount');
987
- counts.append(node('span', null, String(p.total)));
988
- if (p.agentTotal) counts.append(node('span', 'sbadge', `${p.agentTotal} ▸`));
989
- head.append(counts);
990
- // Kit version sits with the project, because that is the thing that is or
991
- // is not up to date.
992
- if (p.kit?.installed) {
993
- const k = node('span', 'kitbadge');
994
- if (p.kit.compared === false) {
995
- k.classList.add('unknown');
996
- k.textContent = `${p.kit.version} ?`;
997
- k.title = `Crewforth ${p.kit.version} — not compared: ${p.kit.reason}`;
998
- } else if (p.kit.outdated) {
999
- k.classList.add('old');
1000
- k.textContent = `${p.kit.version} → ${p.kit.latest}`;
1001
- k.title = `Crewforth ${p.kit.version} is behind ${p.kit.latest} — run /crew-update in this project`;
1002
- } else if (p.kit.ahead) {
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
- if (p.total > p.sessions.length) {
1042
- frag.append(node('div', 'ptrunc', `${p.total - p.sessions.length} older session(s) not listed`));
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
- if (hidden > 0 && !filterText) {
1052
- const t2 = node('div', 'ptrunc pmissing');
1053
- t2.textContent = showMissing
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
- if (!current) {
1064
- const cur = data.projects.find((x) => x.current) ?? data.projects[0];
1065
- const best = cur?.sessions.find((x) => x.agentCount > 0) ?? cur?.sessions[0];
1066
- if (best) selectSession(best.sessionId);
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
- const fleet = await getJson('/api/fleet');
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 (e) {
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
- // Sessions this panel owns survive a page reload as long as the server does.
1092
- getJson('/api/owned')
1093
- .then((r) => { for (const sn of r.sessions ?? []) ownedIds.add(sn.sessionId); })
1094
- .catch(() => { /* none yet */ });
1095
-
1096
- pollFleet(); pollSessions();
1097
- setInterval(pollFleet, FLEET_POLL_MS);
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);