crewforth 3.0.3 → 3.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +211 -0
- package/README.md +3 -3
- package/VERSION +1 -1
- package/adopt.sh +86 -34
- package/kit/CLAUDE.md +1 -1
- package/kit/README.md +1 -1
- package/kit/agents/crew-commit-agent.md +7 -3
- package/kit/agents/crew-frontend-expert.md +4 -4
- package/kit/agents/crew-performance-expert.md +1 -1
- package/kit/agents/crew-review-agent.md +1 -1
- package/kit/eval/doctor.sh +57 -3
- package/kit/eval/golden-routing.txt +13 -0
- package/kit/eval/lib/git-bash.sh +11 -2
- package/kit/eval/parser-conformance.sh +1 -0
- package/kit/eval/routing-eval.sh +7 -3
- package/kit/eval/smoke-test.sh +2545 -214
- package/kit/hooks/commit-msg +1 -1
- package/kit/hooks/context-usage.sh +7 -1
- package/kit/hooks/guard-bash.sh +1608 -103
- package/kit/hooks/guard-commit-scan.sh +138 -19
- package/kit/hooks/guard-powershell.sh +89 -15
- package/kit/hooks/guard-write.sh +98 -22
- package/kit/hooks/no-bash-guard.ps1 +71 -0
- package/kit/hooks/pre-commit +5 -2
- package/kit/hooks/prompt-approval.sh +484 -0
- package/kit/hooks/route-hint.sh +13 -5
- package/kit/hooks/session-rehydrate.sh +18 -0
- package/kit/hooks/skill-trust.sh +17 -2
- package/kit/owned-blobs.tsv +43 -0
- package/kit/settings.json +20 -4
- package/kit/skills/a11y/SKILL.md +1 -0
- package/kit/skills/crew-doctor/SKILL.md +2 -2
- package/kit/skills/frontend/SKILL.md +3 -3
- package/kit/skills/frontend-flutter/SKILL.md +94 -0
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
- package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
- package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
- package/kit/studio/server/hooks/studio-gate.sh +35 -1
- package/kit/studio/server/index.js +14 -4
- package/kit/studio/server/lib/graph.js +64 -3
- package/kit/studio/server/lib/kit.js +16 -2
- package/kit/studio/server/lib/permissions.js +77 -2
- package/kit/studio/server/lib/projects.js +60 -7
- package/kit/studio/server/lib/session.js +16 -2
- package/kit/studio/web/app.js +1765 -573
- package/kit/studio/web/approvals.js +124 -0
- package/kit/studio/web/canvas.js +932 -885
- package/kit/studio/web/chat.js +322 -145
- package/kit/studio/web/convo.js +174 -0
- package/kit/studio/web/dock.js +221 -0
- package/kit/studio/web/graph-plan.js +457 -0
- package/kit/studio/web/icons/builtin.svg +1 -0
- package/kit/studio/web/index.html +116 -50
- package/kit/studio/web/inspect.js +110 -0
- package/kit/studio/web/list-plan.js +112 -0
- package/kit/studio/web/list.js +157 -0
- package/kit/studio/web/liveness.js +66 -0
- package/kit/studio/web/nav.js +237 -0
- package/kit/studio/web/newsession.js +235 -0
- package/kit/studio/web/press.js +65 -0
- package/kit/studio/web/style.css +1281 -1014
- package/kit/studio/web/theme.js +43 -0
- package/kit/studio/web/timeline-plan.js +291 -0
- package/kit/studio/web/timeline.js +400 -0
- package/kit/studio/web/tokens.css +131 -0
- package/kit/studio/web/toolbar-fit.js +35 -0
- package/package.json +5 -4
package/kit/studio/web/chat.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.
|
|
78
|
-
this.
|
|
79
|
-
this.
|
|
80
|
-
this.inputEl
|
|
81
|
-
|
|
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.
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
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') {
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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.
|
|
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', '
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
408
|
-
|
|
409
|
-
|
|
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, {
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
tab.append(el('span', 'tab-name',
|
|
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
|
|