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
@@ -1,4 +1,4 @@
1
- // Conversations with sessions the panel owns.
1
+ // Conversations with sessions the panel owns, and readings of the ones it does not.
2
2
  //
3
3
  // Each session gets a pane that keeps its own stream open whether or not it is
4
4
  // on screen, so a session working in the background is still working when you
@@ -8,8 +8,14 @@
8
8
  // while it is being written, and the complete `assistant` record once it is
9
9
  // done. Rendering both would double every message, so deltas only ever feed a
10
10
  // provisional bubble that the authoritative record replaces.
11
+ //
12
+ // What a message is made of — a delegation, a tool row and its summary, the
13
+ // strip above the conversation — is decided in convo.js. This file draws it.
11
14
 
12
15
  import { renderMarkdown } from './md.js';
16
+ import {
17
+ toolBlock, resultsIn, isDelegation, summaryOf, outputOf, delegationCard, reminderOf, headerOf, refusals,
18
+ } from './convo.js';
13
19
 
14
20
  const el = (tag, cls, text) => {
15
21
  const n = document.createElement(tag);
@@ -45,10 +51,14 @@ export function quickReplies(text) {
45
51
  /* ========================================================== one session === */
46
52
 
47
53
  class Pane {
48
- constructor(session, { api, headers, onChange, readOnly = false }) {
54
+ constructor(session, { api, headers, onChange, onPermissions, hooks = {}, readOnly = false }) {
49
55
  this.api = api;
50
56
  this.headers = headers;
51
57
  this.onChange = onChange;
58
+ this.onPermissions = onPermissions ?? (() => {});
59
+ // What the page lends a pane: who an agent is on the graph, and what the two ways out of a read-only
60
+ // conversation do. A pane does not know the canvas or the navigator.
61
+ this.hooks = hooks;
52
62
  this.readOnly = readOnly;
53
63
  this.session = session;
54
64
  this.id = session.sessionId;
@@ -57,28 +67,41 @@ class Pane {
57
67
  this.streaming = null;
58
68
  this.permissions = [];
59
69
  this.unread = 0;
70
+ this.contextTokens = null;
71
+ this.toolRows = new Map(); // tool_use id -> { block, row, sum, out }
72
+ this.cards = []; // { block, node } for every delegation drawn
73
+ this.refused = new Set(); // tool_use ids already said to have been refused after an allowance
60
74
 
61
75
  this.root = el('div', 'pane');
62
- this.root.innerHTML = `
63
- <div class="chat-log"></div>
64
- <div class="perm-queue" hidden></div>
65
- <form class="chat-form">
66
- <textarea class="chat-input" rows="2"
67
- placeholder="Message this session… (Enter to send, Shift+Enter for a newline)"></textarea>
68
- <button class="chat-send" type="submit">send</button>
69
- </form>
70
- <div class="chat-ro" hidden>
71
- <strong>Read-only.</strong> This session belongs to a terminal, and a Claude Code session
72
- takes input from the process that owns it — the panel has no way in, and two writers on one
73
- transcript would corrupt it. <strong>⑂ fork &amp; continue</strong> makes a copy the panel
74
- <em>does</em> own; messages there stay there and never reach your terminal.
75
- </div>`;
76
-
77
- this.logEl = this.root.querySelector('.chat-log');
78
- this.permEl = this.root.querySelector('.perm-queue');
79
- this.formEl = this.root.querySelector('.chat-form');
80
- this.inputEl = this.root.querySelector('.chat-input');
81
- this.roEl = this.root.querySelector('.chat-ro');
76
+
77
+ this.headEl = el('div', 'pane-head');
78
+ this.logEl = el('div', 'chat-log');
79
+
80
+ // A request waiting in this session, said where the conversation is being read.
81
+ this.remindEl = el('div', 'chat-remind');
82
+ this.remindEl.hidden = true;
83
+ this.remindText = el('span', 'chat-remind-text');
84
+ const review = el('button', 'chat-review', 'Review');
85
+ review.type = 'button';
86
+ review.addEventListener('click', () => this.hooks.onReview?.(this));
87
+ this.remindEl.append(el('span', 'dot'), this.remindText, review);
88
+ this.remindEl.firstChild.dataset.tone = 'waiting';
89
+
90
+ this.formEl = el('form', 'chat-form');
91
+ this.inputEl = el('textarea', 'chat-input');
92
+ this.inputEl.rows = 3;
93
+ this.inputEl.placeholder = 'Message this session';
94
+ this.inputEl.setAttribute('aria-label', 'Message this session');
95
+ const formFoot = el('div', 'chat-form-foot');
96
+ const send = el('button', 'btn sm primary chat-send', 'Send');
97
+ send.type = 'submit';
98
+ formFoot.append(el('span', 'sub', 'Enter to send · Shift+Enter for a new line'), el('span', 'row-fill'), send);
99
+ this.formEl.append(this.inputEl, formFoot);
100
+
101
+ this.roEl = el('div', 'chat-ro');
102
+ this.roEl.hidden = true;
103
+
104
+ this.root.append(this.headEl, this.logEl, this.remindEl, this.formEl, this.roEl);
82
105
 
83
106
  this.formEl.addEventListener('submit', (e) => { e.preventDefault(); this.send(); });
84
107
  this.inputEl.addEventListener('keydown', (e) => {
@@ -89,9 +112,11 @@ class Pane {
89
112
  this.formEl.hidden = true;
90
113
  this.roEl.hidden = false;
91
114
  this.root.classList.add('pane-readonly');
115
+ this.paintReadOnly();
92
116
  } else {
93
117
  this.connect();
94
118
  }
119
+ this.paintHead();
95
120
  this.loadHistory();
96
121
  }
97
122
 
@@ -122,7 +147,7 @@ class Pane {
122
147
  if (conv.truncated) {
123
148
  frag.append(el('div', 'chat-note', `${conv.total - conv.messages.length} earlier message(s) not shown`));
124
149
  }
125
- for (const m of conv.messages ?? []) frag.append(this.renderMessage(m));
150
+ for (const m of conv.messages ?? []) frag.append(this.renderMessage(m, { quick: false }));
126
151
  if (!this.readOnly) {
127
152
  // A visible seam, so nobody reads the history as part of this run — and
128
153
  // named for which of the two things actually happened. A fork that calls
@@ -179,16 +204,20 @@ class Pane {
179
204
  if (rec.parent_tool_use_id) return;
180
205
 
181
206
  if (rec.type === 'permissions') {
207
+ // The requests themselves are drawn by the approval dock, under the canvas: one place for every session,
208
+ // whichever conversation is in front. This pane keeps the count for its tab.
182
209
  this.permissions = rec.pending ?? [];
183
- this.paintPermissions();
210
+ this.onPermissions(this, rec);
184
211
  this.onChange(this);
185
212
  return;
186
213
  }
187
214
 
188
215
  if (rec.type === 'user' && rec.message?.content) {
189
- const text = typeof rec.message.content === 'string'
190
- ? rec.message.content
191
- : rec.message.content.filter((c) => c?.type === 'text').map((c) => c.text).join('');
216
+ const c = rec.message.content;
217
+ // What a tool came back with arrives as a user record. It is not something the viewer said: it goes to
218
+ // the row of the call it answers.
219
+ for (const [id, result] of resultsIn(c)) this.setResult(id, result);
220
+ const text = typeof c === 'string' ? c : c.filter((x) => x?.type === 'text').map((x) => x.text).join('');
192
221
  if (text.trim()) { this.clearPending(); this.push({ role: 'user', text }); }
193
222
  return;
194
223
  }
@@ -198,14 +227,7 @@ class Pane {
198
227
  const blocks = [];
199
228
  for (const c of rec.message.content) {
200
229
  if (c?.type === 'text' && c.text?.trim()) blocks.push({ kind: 'text', text: c.text });
201
- else if (c?.type === 'tool_use') {
202
- const i = c.input ?? {};
203
- blocks.push({
204
- kind: 'tool',
205
- name: c.name ?? 'tool',
206
- label: i.description ?? i.file_path ?? i.command ?? i.pattern ?? i.query ?? null,
207
- });
208
- }
230
+ else if (c?.type === 'tool_use') blocks.push(toolBlock(c));
209
231
  }
210
232
  if (blocks.length) {
211
233
  this.push({ role: 'assistant', blocks });
@@ -214,7 +236,12 @@ class Pane {
214
236
  return;
215
237
  }
216
238
 
217
- if (rec.type === 'result') { this.streaming = null; this.paintStreaming(); return; }
239
+ if (rec.type === 'result') {
240
+ this.streaming = null;
241
+ this.paintStreaming();
242
+ this.noteRefusals(rec.permission_denials);
243
+ return;
244
+ }
218
245
  if (rec.type === 'fault' || rec.type === 'stderr') this.note(rec.reason ?? rec.text, 'bad');
219
246
  }
220
247
 
@@ -228,52 +255,166 @@ class Pane {
228
255
 
229
256
  /* --------------------------------------------------------- rendering */
230
257
 
231
- renderMessage(msg) {
258
+ renderMessage(msg, { quick = true } = {}) {
232
259
  const wrap = el('div', `msg msg-${msg.role}`);
233
- wrap.append(el('div', 'msg-role', msg.role === 'user' ? 'you' : 'claude'));
234
260
 
235
- const body = el('div', 'msg-body');
236
261
  if (msg.role === 'user') {
237
- body.append(el('div', 'msg-text', msg.text));
238
- } else {
239
- for (const b of msg.blocks) {
240
- if (b.kind === 'text') {
241
- const md = el('div', 'md');
242
- md.innerHTML = renderMarkdown(b.text);
243
- body.append(md);
244
- } else {
245
- const card = el('div', 'tool-card');
246
- card.append(el('span', 'tool-name', b.name));
247
- if (b.label) card.append(el('span', 'tool-label', String(b.label).slice(0, 200)));
248
- body.append(card);
249
- }
262
+ wrap.append(el('div', 'msg-body msg-text', msg.text));
263
+ return wrap;
264
+ }
265
+
266
+ wrap.append(el('div', 'msg-role', 'Session'));
267
+ const body = el('div', 'msg-body');
268
+ for (const b of msg.blocks) {
269
+ if (b.kind === 'text') {
270
+ const md = el('div', 'md');
271
+ md.innerHTML = renderMarkdown(b.text);
272
+ body.append(md);
273
+ } else if (isDelegation(b)) {
274
+ body.append(this.renderDelegation(b));
275
+ } else {
276
+ body.append(this.renderTool(b));
250
277
  }
251
278
  }
252
279
  wrap.append(body);
253
280
 
254
- if (msg.role === 'assistant') {
281
+ // Options are offered on the reply that is waiting for an answer, not on every list in the history.
282
+ if (quick && !this.readOnly) {
255
283
  const last = msg.blocks?.filter((b) => b.kind === 'text').pop();
256
284
  const opts = quickReplies(last?.text);
257
285
  if (opts.length) {
258
286
  const row = el('div', 'quick');
259
- row.append(el('span', 'quick-hint', 'reply with'));
260
287
  for (const o of opts) {
261
- const b = el('button', 'quick-btn', o);
262
- b.addEventListener('click', () => { row.remove(); this.inputEl.value = o; this.send(); });
288
+ const b = el('button', 'btn sm quick-btn', o);
289
+ b.type = 'button';
290
+ b.addEventListener('click', () => { row.remove(); this.sendText(o); });
263
291
  row.append(b);
264
292
  }
293
+ row.append(el('span', 'sub quick-hint', 'Clicking an option sends it as your reply.'));
265
294
  wrap.append(row);
266
295
  }
267
296
  }
268
297
  return wrap;
269
298
  }
270
299
 
300
+ /** A tool call on one line: the tool, what it was run on, and how it ended. The output opens under it. */
301
+ renderTool(block) {
302
+ const box = el('div', 'tool');
303
+ const row = el('button', 'tool-row');
304
+ row.type = 'button';
305
+ row.setAttribute('aria-expanded', 'false');
306
+ const sum = el('span', 'tool-sum');
307
+ row.append(el('span', 'tool-name', block.name));
308
+ if (block.label) row.append(el('span', 'tool-label', String(block.label).slice(0, 200)));
309
+ row.append(sum);
310
+ const out = el('pre', 'tool-out');
311
+ out.hidden = true;
312
+ row.addEventListener('click', () => {
313
+ const text = outputOf(block);
314
+ // A call that has not come back has nothing to open, and says which it is rather than opening an empty box.
315
+ out.textContent = text ?? (this.readOnly ? 'Output not read from the transcript.' : 'Not returned yet.');
316
+ out.hidden = !out.hidden;
317
+ row.setAttribute('aria-expanded', String(!out.hidden));
318
+ });
319
+ box.append(row, out);
320
+ const entry = { block, row, sum, out };
321
+ if (block.id) this.toolRows.set(block.id, entry);
322
+ this.paintTool(entry);
323
+ return box;
324
+ }
325
+
326
+ paintTool({ block, row, sum, out }) {
327
+ const s = summaryOf(block);
328
+ row.dataset.state = s.state;
329
+ sum.textContent = s.text ?? '';
330
+ sum.hidden = s.text == null;
331
+ if (!out.hidden) out.textContent = outputOf(block) ?? '';
332
+ }
333
+
334
+ /** Say which calls were allowed in the dock and refused by Claude Code after it. Each is said once. */
335
+ noteRefusals(denials) {
336
+ const list = refusals(
337
+ denials,
338
+ (toolUseId, toolName) => !this.refused.has(toolUseId) && Boolean(this.hooks.wasAllowed?.(this.id, toolUseId, toolName)),
339
+ (toolUseId) => this.toolRows.get(toolUseId)?.block.result ?? null,
340
+ );
341
+ for (const r of list) {
342
+ this.refused.add(r.id);
343
+ const row = this.toolRows.get(r.id)?.row;
344
+ if (row) row.dataset.state = 'refused';
345
+ this.note(r.text, 'bad');
346
+ }
347
+ }
348
+
349
+ /** A tool's result has arrived for a call already on screen. */
350
+ setResult(id, result) {
351
+ const entry = this.toolRows.get(id);
352
+ if (!entry) return;
353
+ entry.block.result = result;
354
+ this.paintTool(entry);
355
+ }
356
+
357
+ /** The session handing work to an agent: a card that goes to that agent on the graph. */
358
+ renderDelegation(block) {
359
+ const node = el('button', 'deleg');
360
+ node.type = 'button';
361
+ node.addEventListener('click', () => {
362
+ const card = delegationCard(block, this.hooks.agentsOf?.(this.id));
363
+ if (card.agentId) this.hooks.onAgent?.(this, card.agentId);
364
+ });
365
+ const entry = { block, node };
366
+ this.cards.push(entry);
367
+ this.paintCard(entry);
368
+ return node;
369
+ }
370
+
371
+ paintCard({ block, node }) {
372
+ const card = delegationCard(block, this.hooks.agentsOf?.(this.id));
373
+ const sig = `${card.type}|${card.task}|${card.agentId}|${card.status}`;
374
+ if (node.sig === sig) return;
375
+ node.sig = sig;
376
+ const text = el('span', 'deleg-text');
377
+ text.append(el('span', 'deleg-type', card.type), el('span', 'deleg-task', card.task));
378
+ const parts = [];
379
+ const tile = card.agentId ? this.hooks.tileOf?.(this.id, card.agentId) : null;
380
+ if (tile) parts.push(tile);
381
+ parts.push(text);
382
+ // The agent's state is the graph's. Before the graph has seen the agent there is none to show.
383
+ const st = card.agentId ? this.hooks.statusOf?.(this.id, card.agentId) : null;
384
+ if (st?.word) {
385
+ const pill = el('span', 'pill');
386
+ const d = el('span', 'dot');
387
+ d.dataset.tone = st.tone ?? 'none';
388
+ pill.append(d, el('span', null, st.word));
389
+ parts.push(pill);
390
+ }
391
+ node.replaceChildren(...parts);
392
+ node.disabled = !card.agentId;
393
+ node.title = card.agentId ? 'Show this agent on the graph' : 'This agent is not on the graph being shown';
394
+ }
395
+
396
+ /** The graph moved: the cards say what their agents are doing now. */
397
+ refreshAgents() { for (const c of this.cards) this.paintCard(c); }
398
+
399
+ /** Which of this session's tool calls are waiting on the viewer. */
400
+ setWaiting(items) {
401
+ const text = reminderOf(items);
402
+ this.remindEl.hidden = !text;
403
+ this.remindText.textContent = text ?? '';
404
+ }
405
+
406
+ setContext(tokens) {
407
+ if (tokens === this.contextTokens) return;
408
+ this.contextTokens = tokens;
409
+ this.paintHead();
410
+ }
411
+
271
412
  paintStreaming() {
272
413
  let node = this.logEl.querySelector('.msg-streaming');
273
414
  if (!this.streaming) { node?.remove(); return; }
274
415
  if (!node) {
275
416
  node = el('div', 'msg msg-assistant msg-streaming');
276
- node.append(el('div', 'msg-role', 'claude'));
417
+ node.append(el('div', 'msg-role', 'Session'));
277
418
  node.append(el('div', 'msg-body'));
278
419
  this.logEl.append(node);
279
420
  }
@@ -287,40 +428,75 @@ class Pane {
287
428
  this.scroll();
288
429
  }
289
430
 
290
- paintPermissions() {
291
- const list = this.permissions ?? [];
292
- this.permEl.hidden = list.length === 0;
293
- if (!list.length) { this.permEl.replaceChildren(); return; }
294
-
295
- const wait = this.session?.gateWaitSeconds ?? null;
296
- const frag = document.createDocumentFragment();
297
- for (const r of list) {
298
- const card = el('div', 'perm');
299
- const head = el('div', 'perm-head');
300
- head.append(el('span', 'perm-tool', r.toolName));
301
- // The clock is part of the decision: silence becomes a denial, and the
302
- // reader should see that coming rather than discover it.
303
- if (wait) {
304
- const left = Math.max(0, wait - Math.round((Date.now() - r.askedAt) / 1000));
305
- head.append(el('span', 'perm-clock', `${left}s → deny`));
306
- }
307
- card.append(head);
308
- if (r.detail) card.append(el('pre', 'perm-detail', String(r.detail).slice(0, 600)));
309
-
310
- const acts = el('div', 'perm-acts');
311
- for (const [verdict, label, cls] of [
312
- ['allow', 'allow once', 'ok'],
313
- ['always', `always allow ${r.toolName}`, ''],
314
- ['deny', 'deny', 'bad'],
315
- ]) {
316
- const b = el('button', `perm-btn ${cls}`, label);
317
- b.addEventListener('click', () => this.decide(r.toolUseId, verdict, card));
318
- acts.append(b);
319
- }
320
- card.append(acts);
321
- frag.append(card);
431
+ /** The strip above the conversation: started here or read only, the mode, how far it has gone, and Stop. */
432
+ paintHead() {
433
+ const s = this.session;
434
+ const h = headerOf(s, { readOnly: this.readOnly, contextTokens: this.contextTokens });
435
+ const badge = el('span', 'pill pane-badge');
436
+ const d = el('span', 'dot');
437
+ d.dataset.tone = h.tone;
438
+ badge.append(d, el('span', null, h.badge));
439
+ const parts = [badge];
440
+ for (const p of h.parts) parts.push(el('span', 'sub', p));
441
+ if (h.ungated) {
442
+ const warn = el('span', 'pill pane-ungated', 'No approval gate');
443
+ warn.title = 'This session was started without the approval gate: its tool calls do not wait for you.';
444
+ parts.push(warn);
322
445
  }
323
- this.permEl.replaceChildren(frag);
446
+ parts.push(el('span', 'row-fill'));
447
+ if (!this.readOnly && s?.costUsd) {
448
+ const cost = el('span', 'sub pane-cost', `$${s.costUsd.toFixed(4)}`);
449
+ cost.title = 'What this session has cost, as the CLI reported it';
450
+ parts.push(cost);
451
+ }
452
+ // Stopping ends the process. It is offered for as long as there is one.
453
+ if (!this.readOnly && s && s.state !== 'exited' && s.state !== 'failed') {
454
+ const stop = el('button', 'btn sm chat-stop');
455
+ stop.type = 'button';
456
+ stop.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="4" width="8" height="8" rx="1.5"/></svg>';
457
+ stop.append(el('span', null, 'Stop'));
458
+ stop.addEventListener('click', () => this.stop());
459
+ parts.push(stop);
460
+ }
461
+ this.headEl.replaceChildren(...parts);
462
+ }
463
+
464
+ /**
465
+ * The foot of a conversation Studio only reads: why there is no message box, and the two ways on.
466
+ *
467
+ * What "Continue here" does depends on something this page learns only by doing it: a session nothing else
468
+ * holds is continued itself; one still open somewhere is copied. Both are said, because a copy that reads as
469
+ * "you are now typing into that session" is the one thing it is not.
470
+ */
471
+ paintReadOnly() {
472
+ const lead = el('p', 'chat-ro-lead');
473
+ lead.append(el('strong', null, 'This session was not started here.'),
474
+ ' Studio can read it but cannot write to it or answer its approvals.');
475
+
476
+ const cont = el('button', 'btn sm primary', 'Continue here');
477
+ cont.type = 'button';
478
+ cont.addEventListener('click', () => this.hooks.onContinue?.(this, cont));
479
+ const contRow = el('div', 'chat-ro-row');
480
+ contRow.append(cont, el('span', 'sub',
481
+ 'Continues it in Studio. If it is still open somewhere else, Studio starts a copy instead: '
482
+ + 'the original does not see this, and messages here never reach your terminal.'));
483
+
484
+ const term = el('button', 'btn sm', 'Open in terminal');
485
+ term.type = 'button';
486
+ term.addEventListener('click', (e) => { e.stopPropagation(); this.hooks.onTerminal?.(this, term); });
487
+ const line = el('code', 'chat-ro-cmd');
488
+ const termText = el('span', 'sub');
489
+ termText.append('Shows the command first, then opens a terminal: ', line);
490
+ const termRow = el('div', 'chat-ro-row');
491
+ termRow.append(term, termText);
492
+
493
+ this.roEl.replaceChildren(lead, contRow, termRow);
494
+
495
+ // The command is the server's, read before anything runs. One that could not be worked out is said so.
496
+ fetch(this.api(`/api/session/${encodeURIComponent(this.id)}/terminal`), { cache: 'no-store' })
497
+ .then((r) => r.json())
498
+ .then((r) => { line.textContent = r?.plan?.line ?? 'not measured — no working directory recorded'; })
499
+ .catch((e) => { line.textContent = `not read — ${e.message}`; });
324
500
  }
325
501
 
326
502
  paintState() {
@@ -328,6 +504,7 @@ class Pane {
328
504
  const dead = s.state === 'exited' || s.state === 'failed';
329
505
  this.formEl.hidden = false;
330
506
  this.inputEl.disabled = dead;
507
+ this.paintHead();
331
508
  }
332
509
 
333
510
  note(text, kind = '') { this.logEl.append(el('div', `chat-note ${kind}`, text)); this.scroll(); }
@@ -342,7 +519,6 @@ class Pane {
342
519
  pending(text) {
343
520
  this.clearPending();
344
521
  const wrap = el('div', 'msg msg-user msg-pending');
345
- wrap.append(el('div', 'msg-role', 'you'));
346
522
  wrap.append(el('div', 'msg-body msg-text', text));
347
523
  this.logEl.append(wrap);
348
524
  this.scroll();
@@ -352,10 +528,14 @@ class Pane {
352
528
 
353
529
  /* ------------------------------------------------------------ actions */
354
530
 
355
- async send() {
531
+ send() {
356
532
  const text = this.inputEl.value.trim();
357
- if (!text) return;
533
+ if (!text) return null;
358
534
  this.inputEl.value = '';
535
+ return this.sendText(text);
536
+ }
537
+
538
+ async sendText(text) {
359
539
  // No optimistic bubble. The session is started with --replay-user-messages,
360
540
  // so it echoes what it actually received; drawing our own copy as well
361
541
  // printed every message twice.
@@ -369,18 +549,7 @@ class Pane {
369
549
 
370
550
  if (!res.ok) { this.clearPending(); this.note(`Not sent: ${res.reason}`, 'bad'); }
371
551
  else { this.session = res.session; this.paintState(); this.onChange(this); }
372
- }
373
-
374
- async decide(toolUseId, verdict, card) {
375
- for (const b of card.querySelectorAll('button')) b.disabled = true;
376
- const res = await fetch(
377
- this.api(`/api/owned/${encodeURIComponent(this.id)}/permissions/${encodeURIComponent(toolUseId)}`),
378
- { method: 'POST', headers: { 'content-type': 'application/json', ...this.headers }, body: JSON.stringify({ verdict }) },
379
- ).then((r) => r.json()).catch((e) => ({ ok: false, reason: e.message }));
380
- if (!res.ok) {
381
- this.note(`Decision not recorded: ${res.reason}`, 'bad');
382
- for (const b of card.querySelectorAll('button')) b.disabled = false;
383
- }
552
+ return res;
384
553
  }
385
554
 
386
555
  async stop() {
@@ -393,36 +562,36 @@ class Pane {
393
562
  /* ============================================================== the tabs === */
394
563
 
395
564
  export class Chat {
396
- constructor(root, { api, headers }) {
565
+ /**
566
+ * @param hooks what the page lends the conversation: `nameOf(sessionId)`, `agentsOf(sessionId)`,
567
+ * `tileOf(sessionId, agentId)`, `statusOf(sessionId, agentId)`, `onAgent(pane, agentId)`,
568
+ * `onReview(pane)`, `onContinue(pane, button)`, `onTerminal(pane, button)`
569
+ */
570
+ constructor(root, { api, headers, hooks = {} }) {
397
571
  this.root = root;
398
572
  this.api = api;
399
573
  this.headers = headers;
574
+ this.hooks = hooks;
400
575
  this.panes = new Map();
401
576
  this.activeId = null;
402
577
  this.onActivate = () => {};
578
+ // (pane, { pending }) — a session's waiting requests changed.
579
+ this.onPermissions = () => {};
403
580
 
404
581
  this.root.innerHTML = `
405
582
  <div class="chat-head">
406
- <div class="tabs"></div>
407
- <span class="chat-state"></span>
408
- <span class="chat-cost"></span>
409
- <button class="ghost pin chat-grow" type="button" title="Widen the conversation">⇤</button>
410
- <button class="ghost chat-stop" type="button" hidden>stop</button>
583
+ <div class="tabs" role="tablist" aria-label="Conversations"></div>
584
+ <button class="btn icon sm ghost chat-grow" type="button" aria-label="Widen the conversation" title="Widen the conversation">
585
+ <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M9 4l-4 4 4 4M13 4l-4 4 4 4"/></svg>
586
+ </button>
411
587
  </div>
412
588
  <div class="panes"></div>`;
413
589
 
414
590
  this.tabsEl = this.root.querySelector('.tabs');
415
591
  this.panesEl = this.root.querySelector('.panes');
416
- this.stateEl = this.root.querySelector('.chat-state');
417
- this.costEl = this.root.querySelector('.chat-cost');
418
- this.stopEl = this.root.querySelector('.chat-stop');
419
- this.stopEl.addEventListener('click', () => this.active?.stop?.());
420
592
  this.growEl = this.root.querySelector('.chat-grow');
421
593
  this.onGrow = () => {};
422
594
  this.growEl.addEventListener('click', () => this.onGrow());
423
-
424
- // Countdowns have to move on their own; nothing else re-renders them.
425
- setInterval(() => { if (this.active?.permissions?.length) this.active.paintPermissions(); }, 1000);
426
595
  }
427
596
 
428
597
  get active() { return this.activeId ? this.panes.get(this.activeId) : null; }
@@ -446,7 +615,7 @@ export class Chat {
446
615
  if (!pane) {
447
616
  pane = new Pane(
448
617
  { sessionId, state: 'observed', permissionMode: 'read-only', gated: false, title },
449
- { api: this.api, headers: this.headers, onChange: () => this.paintTabs(), readOnly: true },
618
+ { api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(), readOnly: true },
450
619
  );
451
620
  this.panesEl.append(pane.root);
452
621
  this.panes.set(sessionId, pane);
@@ -457,8 +626,19 @@ export class Chat {
457
626
 
458
627
  open(session) {
459
628
  let pane = this.panes.get(session.sessionId);
629
+ // Continuing a session in place keeps its id. The pane that was only reading it has no stream and no way
630
+ // to send, so it is replaced: left as it was, the session would be owned and still say "read only".
631
+ if (pane?.readOnly) {
632
+ pane.disconnect();
633
+ pane.root.remove();
634
+ this.panes.delete(session.sessionId);
635
+ pane = null;
636
+ }
460
637
  if (!pane) {
461
- pane = new Pane(session, { api: this.api, headers: this.headers, onChange: () => this.paintTabs() });
638
+ pane = new Pane(session, {
639
+ api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(),
640
+ onPermissions: (p, rec) => this.onPermissions(p, rec),
641
+ });
462
642
  this.panesEl.append(pane.root);
463
643
  this.panes.set(session.sessionId, pane);
464
644
  }
@@ -474,8 +654,7 @@ export class Chat {
474
654
  if (pid === id) p.unread = 0;
475
655
  }
476
656
  this.paintTabs();
477
- const a = this.active;
478
- a?.inputEl?.focus();
657
+ this.active?.inputEl?.focus();
479
658
  this.onActivate(id);
480
659
  }
481
660
 
@@ -496,20 +675,35 @@ export class Chat {
496
675
  }
497
676
  }
498
677
 
678
+ /** The graph of one session moved: its pane's delegation cards and context figure follow. */
679
+ refresh(sessionId, { contextTokens = null } = {}) {
680
+ const pane = this.panes.get(sessionId);
681
+ if (!pane) return;
682
+ pane.refreshAgents();
683
+ if (contextTokens != null) pane.setContext(contextTokens);
684
+ }
685
+
686
+ /** What is waiting on the viewer, as the approval queue has it: each pane is told its own share. */
687
+ setWaiting(queue) {
688
+ for (const [id, pane] of this.panes) pane.setWaiting((queue ?? []).filter((r) => r.sessionId === id));
689
+ }
690
+
499
691
  paintTabs() {
500
692
  const frag = document.createDocumentFragment();
501
693
  for (const [id, p] of this.panes) {
502
- const tab = el('button', `tab${id === this.activeId ? ' on' : ''}`);
694
+ const tab = el('button', 'tab');
503
695
  tab.type = 'button';
696
+ tab.setAttribute('role', 'tab');
697
+ tab.setAttribute('aria-selected', String(id === this.activeId));
504
698
  const dot = el('span', 'tab-dot');
505
699
  dot.dataset.state = p.session?.state ?? 'unknown';
506
700
  tab.append(dot);
507
- const label = p.readOnly ? `👁 ${p.session?.title ? String(p.session.title).slice(0, 18) : shortId(id)}`
508
- : (p.session?.resumedFrom ? `↩ ${shortId(p.session.resumedFrom)}` : shortId(id));
509
- tab.append(el('span', 'tab-name', label));
701
+ // The name the navigator gives the session; its id only when it has none yet.
702
+ const name = this.hooks.nameOf?.(id) ?? p.session?.title ?? shortId(id);
703
+ tab.append(el('span', 'tab-name', String(name).slice(0, 28)));
510
704
  if (p.permissions?.length) tab.append(el('span', 'tab-badge warn', String(p.permissions.length)));
511
705
  else if (p.unread) tab.append(el('span', 'tab-badge', String(p.unread)));
512
- tab.title = `${id}\n${p.session?.state ?? ''} · ${p.session?.permissionMode ?? ''}`;
706
+ tab.title = `${name}\n${id}\n${p.readOnly ? 'read only' : `${p.session?.state ?? ''} · ${p.session?.permissionMode ?? ''}`}`;
513
707
  tab.addEventListener('click', () => this.activate(id));
514
708
 
515
709
  const x = el('span', 'tab-x', '×');
@@ -519,24 +713,7 @@ export class Chat {
519
713
  frag.append(tab);
520
714
  }
521
715
  this.tabsEl.replaceChildren(frag);
522
-
523
- const a = this.active;
524
- const s = a?.session;
525
- if (a?.readOnly) {
526
- this.stateEl.textContent = 'observed · read-only';
527
- this.stateEl.dataset.state = 'observed';
528
- this.stateEl.classList.remove('ungated');
529
- this.costEl.textContent = '';
530
- this.stopEl.hidden = true;
531
- return;
532
- }
533
- this.stateEl.textContent = s
534
- ? `${s.state} · ${s.permissionMode}${s.gated ? '' : ' · NO GATE'}`
535
- : 'no session';
536
- this.stateEl.dataset.state = s?.state ?? 'none';
537
- this.stateEl.classList.toggle('ungated', Boolean(s) && !s.gated);
538
- this.costEl.textContent = s?.costUsd ? `$${s.costUsd.toFixed(4)}` : '';
539
- this.stopEl.hidden = !s || s.state === 'exited' || s.state === 'failed';
716
+ for (const p of this.panes.values()) p.paintHead();
540
717
  }
541
718
  }
542
719