crewforth 3.1.0-rc.5 → 3.1.0-rc.7
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 +76 -12
- package/README.md +3 -2
- package/kit/AGENT_TEMPLATE.md +41 -21
- package/kit/CLAUDE.md +9 -7
- package/kit/agents/crew-backend-expert.md +4 -1
- package/kit/agents/crew-commit-agent.md +8 -3
- package/kit/agents/crew-database-expert.md +4 -1
- package/kit/agents/crew-devops-expert.md +4 -1
- package/kit/agents/crew-frontend-expert.md +4 -1
- package/kit/agents/crew-performance-expert.md +4 -1
- package/kit/agents/crew-planner.md +10 -1
- package/kit/agents/crew-privacy-agent.md +4 -1
- package/kit/agents/crew-review-agent.md +4 -1
- package/kit/agents/crew-security-expert.md +4 -1
- package/kit/agents/crew-session-manager.md +4 -1
- package/kit/agents/crew-test-expert.md +4 -1
- package/kit/eval/smoke-test.sh +860 -27
- package/kit/hooks/agent-outcome.sh +437 -0
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-agent-model.sh +494 -60
- package/kit/hooks/guard-bash.sh +69 -8
- package/kit/hooks/guard-schedule.sh +8 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +94 -7
- package/kit/settings.json +43 -0
- package/kit/skills/crew-approve/SKILL.md +4 -1
- package/kit/skills/crew-loosen/SKILL.md +28 -0
- package/kit/studio/server/hooks/studio-gate.sh +90 -4
- package/kit/studio/server/hooks/studio-host.mjs +92 -0
- package/kit/studio/server/index.js +85 -6
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +45 -7
- package/kit/studio/server/lib/model-outcomes.js +287 -0
- package/kit/studio/server/lib/palette.js +5 -18
- package/kit/studio/server/lib/permissions.js +260 -17
- package/kit/studio/server/lib/session.js +41 -1
- package/kit/studio/server/lib/usage.js +8 -1
- package/kit/studio/web/app.js +203 -32
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +91 -42
- package/kit/studio/web/chat.js +11 -1
- package/kit/studio/web/convo.js +9 -0
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +29 -9
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +27 -5
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +30 -0
- package/kit/studio/web/style.css +85 -9
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +26 -7
- package/package.json +1 -1
|
@@ -48,8 +48,9 @@ function rowOf(n, now) {
|
|
|
48
48
|
* @param queue the approval queue (every session started here), as approvals.js orders it
|
|
49
49
|
* @param current the session being looked at
|
|
50
50
|
* @param folded Map sectionKey -> boolean, the viewer's own choices; a section not in it takes its default
|
|
51
|
+
* @param all true after "Expand all", false after "Fold all": what a section not in `folded` is
|
|
51
52
|
*/
|
|
52
|
-
export function sections(nodes, { queue = [], current = null, folded = new Map(), now = 0 } = {}) {
|
|
53
|
+
export function sections(nodes, { queue = [], current = null, folded = new Map(), all = null, now = 0 } = {}) {
|
|
53
54
|
const agents = (nodes ?? []).filter((n) => n.kind === 'agent');
|
|
54
55
|
const out = [];
|
|
55
56
|
|
|
@@ -89,7 +90,9 @@ export function sections(nodes, { queue = [], current = null, folded = new Map()
|
|
|
89
90
|
if (!s.rows.length) continue;
|
|
90
91
|
out.push({
|
|
91
92
|
key: s.key, title: s.title, count: s.rows.length, tone: s.tone, cards: [], rows: s.rows,
|
|
92
|
-
|
|
93
|
+
// As the viewer left it; untouched, it follows the last "Expand all" or "Fold all" (`all`), and before
|
|
94
|
+
// either its own default.
|
|
95
|
+
folded: folded.has(s.key) ? folded.get(s.key) : (typeof all === 'boolean' ? !all : s.foldedByDefault), foldable: true,
|
|
93
96
|
});
|
|
94
97
|
}
|
|
95
98
|
return out;
|
|
@@ -97,12 +100,14 @@ export function sections(nodes, { queue = [], current = null, folded = new Map()
|
|
|
97
100
|
|
|
98
101
|
/** What a request card says under the agent's name. */
|
|
99
102
|
export function wants(item) {
|
|
103
|
+
if (item.ask?.kind === 'questions') return 'asks';
|
|
104
|
+
if (item.ask?.kind === 'plan') return 'wants its plan approved';
|
|
100
105
|
return `wants to run ${item.toolName}`;
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
/** Which view a window opens on when the viewer has not chosen one: the List on a phone, the graph elsewhere. */
|
|
104
109
|
export function defaultView(stored, phone) {
|
|
105
|
-
if (stored === 'graph' || stored === 'timeline' || stored === 'list') return stored;
|
|
110
|
+
if (stored === 'graph' || stored === 'timeline' || stored === 'list' || stored === 'models') return stored;
|
|
106
111
|
return phone ? 'list' : 'graph';
|
|
107
112
|
}
|
|
108
113
|
|
package/kit/studio/web/list.js
CHANGED
|
@@ -6,6 +6,7 @@ import { sections, wants } from './list-plan.js';
|
|
|
6
6
|
import { Press } from './press.js';
|
|
7
7
|
import { hoverOf, modelOf } from './names.js';
|
|
8
8
|
import { remaining, asker, allowSessionLabel, VERDICTS } from './approvals.js';
|
|
9
|
+
import { AskForms } from './ask.js';
|
|
9
10
|
|
|
10
11
|
function mk(tag, cls, text) {
|
|
11
12
|
const n = document.createElement(tag);
|
|
@@ -34,11 +35,27 @@ export class List {
|
|
|
34
35
|
this.folded = new Map();
|
|
35
36
|
this.selected = null;
|
|
36
37
|
this.busy = new Set();
|
|
38
|
+
this.forms = new AskForms((item, verdict, extra) => {
|
|
39
|
+
if (this.busy.has(item.key)) return;
|
|
40
|
+
this.busy.add(item.key);
|
|
41
|
+
this.forms.setSent(item.key, true);
|
|
42
|
+
this.hooks.onDecide?.(item, verdict, extra);
|
|
43
|
+
});
|
|
37
44
|
this.now = () => Date.now();
|
|
38
45
|
this.root.classList.add('ls');
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
setSession(id) { if (id !== this.current) { this.current = id; this.nodes = []; this.folded = new Map(); this.selected = null; this.render(); } }
|
|
48
|
+
setSession(id) { if (id !== this.current) { this.current = id; this.nodes = []; this.folded = new Map(); this.all = null; this.selected = null; this.render(); } }
|
|
49
|
+
|
|
50
|
+
/** Open every section; one that appears later appears open. What is folded by hand afterwards stays folded. */
|
|
51
|
+
expandAll() { this.all = true; this.folded = new Map(); this.render(); this.hooks.onFold?.(); }
|
|
52
|
+
/** Fold every section that can fold. "Needs you" cannot: it is what the view is for. */
|
|
53
|
+
foldAll() { this.all = false; this.folded = new Map(); this.render(); this.hooks.onFold?.(); }
|
|
54
|
+
/** Whether there is anything left for each of the two to do. */
|
|
55
|
+
foldState() {
|
|
56
|
+
const can = (this.lastSections ?? []).filter((s) => s.foldable);
|
|
57
|
+
return { groups: can.length, canExpand: can.some((s) => s.folded), canFold: can.some((s) => !s.folded) };
|
|
58
|
+
}
|
|
42
59
|
setNodes(nodes) { this.nodes = nodes ?? []; this.render(); }
|
|
43
60
|
|
|
44
61
|
setQueue(queue) {
|
|
@@ -50,7 +67,7 @@ export class List {
|
|
|
50
67
|
select(id) { this.selected = id; this.render(); }
|
|
51
68
|
|
|
52
69
|
/** An answer did not reach the server: the buttons come back. */
|
|
53
|
-
release(key) { this.busy.delete(key); this.render(); }
|
|
70
|
+
release(key) { this.busy.delete(key); this.forms.setSent(key, false); this.render(); }
|
|
54
71
|
|
|
55
72
|
/** Once a second: the countdowns and the running times move. */
|
|
56
73
|
tick() { if (!this.root.hidden && (this.queue.length || this.nodes.some((n) => n.status === 'running'))) this.render(); }
|
|
@@ -59,7 +76,9 @@ export class List {
|
|
|
59
76
|
if (this.root.hidden) return;
|
|
60
77
|
if (this.press.defer()) return;
|
|
61
78
|
const now = this.now();
|
|
62
|
-
|
|
79
|
+
this.forms.keep(this.queue.map((r) => r.key));
|
|
80
|
+
const list = sections(this.nodes, { queue: this.queue, current: this.current, folded: this.folded, all: this.all ?? null, now });
|
|
81
|
+
this.lastSections = list;
|
|
63
82
|
if (!list.length) {
|
|
64
83
|
const empty = mk('div', 'ls-empty');
|
|
65
84
|
empty.append(mk('strong', null, 'No agents yet'),
|
|
@@ -86,7 +105,7 @@ export class List {
|
|
|
86
105
|
head.dataset.key = `sec:${s.key}`;
|
|
87
106
|
head.setAttribute('aria-expanded', String(!s.folded));
|
|
88
107
|
head.append(mk('span', 'row-fill'), mk('span', 'sub', s.folded ? 'show' : 'hide'));
|
|
89
|
-
head.addEventListener('click', () => { this.folded.set(s.key, !s.folded); this.render(); });
|
|
108
|
+
head.addEventListener('click', () => { this.folded.set(s.key, !s.folded); this.render(); this.hooks.onFold?.(); });
|
|
90
109
|
}
|
|
91
110
|
sec.append(head);
|
|
92
111
|
for (const item of s.cards) sec.append(this.#card(item, now));
|
|
@@ -106,7 +125,7 @@ export class List {
|
|
|
106
125
|
clock.setAttribute('aria-label', r.known ? `Auto-deny in ${r.left} seconds` : 'Time left not measured');
|
|
107
126
|
head.append(who, mk('span', 'row-fill'), clock);
|
|
108
127
|
card.append(head);
|
|
109
|
-
if (item.detail) card.append(mk('pre', 'ls-cmd', item.detail));
|
|
128
|
+
if (item.detail && !item.ask) card.append(mk('pre', 'ls-cmd', item.detail));
|
|
110
129
|
// A request from another session says which, and goes there.
|
|
111
130
|
if (item.sessionId !== this.current) {
|
|
112
131
|
const where = mk('button', 'ls-where', `in ${item.sessionName}`);
|
|
@@ -114,6 +133,9 @@ export class List {
|
|
|
114
133
|
where.addEventListener('click', () => this.hooks.onLocate?.(item));
|
|
115
134
|
card.append(where);
|
|
116
135
|
}
|
|
136
|
+
// A question or a plan carries its own form instead of the three answers.
|
|
137
|
+
const form = this.forms.get(item);
|
|
138
|
+
if (form) { card.append(form); return card; }
|
|
117
139
|
const sent = this.busy.has(item.key);
|
|
118
140
|
const acts = mk('div', 'ls-acts');
|
|
119
141
|
for (const [verdict, label, cls] of [
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// What the Models view and the inspector say about which model did which work. No page in it.
|
|
2
|
+
//
|
|
3
|
+
// The numbers are Crewforth's own record (server/lib/model-outcomes.js reads it); this file turns them into words.
|
|
4
|
+
// A rate with nothing under it is not written as zero, and a call whose verify command gave no verdict is shown
|
|
5
|
+
// apart from the ones that passed or failed.
|
|
6
|
+
import { roleName, modelName } from './names.js';
|
|
7
|
+
import { fmtCost, fmtCount } from './usage.js';
|
|
8
|
+
|
|
9
|
+
const TIER = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus', fable: 'Fable' };
|
|
10
|
+
export const tierWord = (t) => TIER[t] ?? (t ? String(t) : 'no model');
|
|
11
|
+
const pct = (r) => `${Math.round(r * 100)}%`;
|
|
12
|
+
const plural = (n, word, many = `${word}s`) => `${n} ${n === 1 ? word : many}`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The task card a crew agent's work starts with: three lines at the top of its task text.
|
|
16
|
+
* files: what it may touch · change: the kind of change · verify: the command that checks it, or none
|
|
17
|
+
* Returns null when the text does not begin with a card; a card with a line missing keeps the others.
|
|
18
|
+
*/
|
|
19
|
+
export function cardOf(prompt) {
|
|
20
|
+
if (typeof prompt !== 'string') return null;
|
|
21
|
+
const card = {};
|
|
22
|
+
for (const line of prompt.split('\n').slice(0, 12)) {
|
|
23
|
+
const m = line.match(/^\s*(files|change|verify)\s*:\s*(.*)$/i);
|
|
24
|
+
if (m && card[m[1].toLowerCase()] === undefined) card[m[1].toLowerCase()] = m[2].trim();
|
|
25
|
+
}
|
|
26
|
+
return Object.keys(card).length ? { files: card.files ?? null, change: card.change ?? null, verify: card.verify ?? null } : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const VERIFY_TONE = { pass: 'pass', fail: 'fail' };
|
|
30
|
+
const VERIFY_SAYS = { none: 'no verify command', blocked: 'verify not run: the command was not allowed', timeout: 'verify not finished: the command ran out of time' };
|
|
31
|
+
|
|
32
|
+
/** The card as the inspector lists it: the three lines, each kept as it was written. */
|
|
33
|
+
export function cardLines(card, row) {
|
|
34
|
+
const out = [];
|
|
35
|
+
if (card?.files) out.push({ label: 'files', text: card.files });
|
|
36
|
+
if (card?.change || row?.change) out.push({ label: 'change', text: row?.change ?? card.change });
|
|
37
|
+
if (card?.verify) out.push({ label: 'verify', text: card.verify });
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stepOf(r, again) {
|
|
42
|
+
const on = r.ranOn ? modelName(r.ranOn) : tierWord(r.model);
|
|
43
|
+
const bits = [again ? `Re-run on ${on}` : `Ran on ${on}`];
|
|
44
|
+
if (r.fixes > 0) bits.push(`fixed ${r.fixes === 1 ? 'once' : `${r.fixes} times`} inside the agent`);
|
|
45
|
+
// A verdict is a tag (pass, fail); anything else is said in words, because it is no verdict.
|
|
46
|
+
const tone = VERIFY_TONE[r.verify] ?? null;
|
|
47
|
+
if (!tone) bits.push(VERIFY_SAYS[r.verify] ?? `verify ${r.verify}`);
|
|
48
|
+
return { text: bits.join(' \u00b7 '), verify: tone ? r.verify : null, tone, escalated: again };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How the work went, as steps: the earlier calls of the same card, then this one. `row` is Crewforth's line for
|
|
53
|
+
* the agent with `before` (see server/lib/model-outcomes.js), or null when it has none: it has not stopped, or it
|
|
54
|
+
* is not a crew agent.
|
|
55
|
+
* @returns { risk, steps: [{ text, verify, tone, escalated }], note }
|
|
56
|
+
*/
|
|
57
|
+
export function workSteps(row) {
|
|
58
|
+
if (!row) return { risk: null, steps: [], note: 'Not recorded yet: the line is written when the agent stops.' };
|
|
59
|
+
const steps = [...(row.before ?? []).map((r) => stepOf(r, false)), stepOf(row, Boolean(row.escalatedFrom))];
|
|
60
|
+
if (row.escalatedFrom && !(row.before ?? []).length) {
|
|
61
|
+
steps.unshift({ text: `An earlier call on ${tierWord(row.escalatedFrom)} did not pass; its line is not in the record`, verify: null, tone: null, escalated: false });
|
|
62
|
+
}
|
|
63
|
+
return { risk: row.risk ?? null, steps, note: row.escalatedFrom ? 'A re-run with the same or a lower model is refused by the gate.' : null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What is said about a class's floor. Raised and standing: the tag `raised` and what raised it. Raised and then
|
|
68
|
+
* lowered by the user: the tag `lowered`, and both facts, because the raise is still on record and the floor that
|
|
69
|
+
* holds is the lowered one. `floorTag` is the tag's word, or null when the class has no floor on record.
|
|
70
|
+
*/
|
|
71
|
+
export function floorWords(floor) {
|
|
72
|
+
if (!floor) return { floorTag: null, raised: null, raisedWhy: '' };
|
|
73
|
+
const why = `Raised to ${tierWord(floor.model)} automatically: ${floor.notFirstTry} of ${floor.calls ?? '?'} calls one model down did not pass first time`;
|
|
74
|
+
if (!floor.lowered) return { floorTag: 'raised', raised: tierWord(floor.model), raisedWhy: why };
|
|
75
|
+
return {
|
|
76
|
+
floorTag: 'lowered',
|
|
77
|
+
raised: null,
|
|
78
|
+
raisedWhy: `${why}. You lowered it to ${tierWord(floor.lowered.to)} on ${String(floor.lowered.ts).slice(0, 10)}; its outcomes are counted afresh from then.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The cost cell of a class. `cost` is { cost, costed, runs } for it, or null while costs have not been read.
|
|
84
|
+
* Every call priced: the estimate. Some priced: a floor, marked as one, with how many it is the sum of
|
|
85
|
+
* ("\u2265$0.75 \u00b7 2/7"), because a sum of two calls must not be read as the cost of seven. None: a dash.
|
|
86
|
+
*/
|
|
87
|
+
export function costCell(cost, runs) {
|
|
88
|
+
if (!cost) return { cost: '\u2026', costNote: 'Reading what this class cost\u2026', partial: false };
|
|
89
|
+
if (!cost.costed) return { cost: '\u2014', costNote: 'No agent of this class has a transcript on this machine that could be priced', partial: false };
|
|
90
|
+
const usd = fmtCost(cost.cost);
|
|
91
|
+
if (cost.costed >= runs) return { cost: usd, costNote: `All ${runs} ${runs === 1 ? 'task' : 'tasks'} priced, at API list price. An estimate: what a subscription is charged is not known here.`, partial: false };
|
|
92
|
+
return {
|
|
93
|
+
cost: `\u2265${usd.replace(/^~/, '')} \u00b7 ${cost.costed}/${runs}`,
|
|
94
|
+
costNote: `At least this much: ${cost.costed} of ${runs} tasks could be priced; the others have no transcript on this machine. At API list price.`,
|
|
95
|
+
partial: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One class as the view's row: who and what kind of change, how risky, what ran it, and how it went. */
|
|
100
|
+
export function classRow(c, cost = null) {
|
|
101
|
+
const models = Object.entries(c.models ?? {}).sort((a, b) => b[1].runs - a[1].runs).map(([t, m]) => `${tierWord(t)} ${m.runs}`).join(' \u00b7 ');
|
|
102
|
+
const apart = [
|
|
103
|
+
c.notVerified?.none ? `${c.notVerified.none} with no verify command` : null,
|
|
104
|
+
c.notVerified?.blocked ? `${c.notVerified.blocked} not run` : null,
|
|
105
|
+
c.notVerified?.timeout ? `${c.notVerified.timeout} timed out` : null,
|
|
106
|
+
].filter(Boolean);
|
|
107
|
+
return {
|
|
108
|
+
key: c.key,
|
|
109
|
+
who: roleName(c.agent),
|
|
110
|
+
real: c.agent,
|
|
111
|
+
change: c.change,
|
|
112
|
+
risk: c.risk,
|
|
113
|
+
models,
|
|
114
|
+
ranOn: Object.keys(c.ranOn ?? {}).map(modelName).join(', '),
|
|
115
|
+
// Held one model up by the record of outcomes: said beside the model, with the count that did it.
|
|
116
|
+
...floorWords(c.floor),
|
|
117
|
+
tasks: String(c.runs),
|
|
118
|
+
// The rate and what it is a rate of: "83%" of five calls is not "83%" of five hundred.
|
|
119
|
+
firstTry: c.firstTryRate === null ? '\u2014' : pct(c.firstTryRate),
|
|
120
|
+
firstTryOf: c.firstTryRate === null ? 'No call of this class got a verdict from its verify command' : `${c.firstTry} of ${c.verified} verified calls passed the first time`,
|
|
121
|
+
rate: c.firstTryRate,
|
|
122
|
+
notVerified: apart.join(' \u00b7 '),
|
|
123
|
+
after: [c.fixed ? `${c.fixed} fixed by the agent` : null, c.failed ? `${c.failed} failed` : null].filter(Boolean).join(' \u00b7 '),
|
|
124
|
+
escalated: String(c.escalated ?? 0),
|
|
125
|
+
...costCell(cost, c.runs),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The command that lowers one hold: Crewforth's own, typed by the user in their own session. Studio copies it and
|
|
131
|
+
* runs nothing. Three words, the class as Crewforth's record names it; the model is not in it, because the command
|
|
132
|
+
* lowers by one step and works that out itself. Null when a word is not one the command could take.
|
|
133
|
+
*/
|
|
134
|
+
export function lowerCommand(s) {
|
|
135
|
+
const words = [s?.agent, s?.change, s?.risk];
|
|
136
|
+
return words.every((w) => typeof w === 'string' && /^[A-Za-z0-9._-]+$/.test(w)) ? `/crew-loosen ${words.join(' ')}` : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A hold that could be lowered, in words. */
|
|
140
|
+
export function suggestionText(s) {
|
|
141
|
+
return {
|
|
142
|
+
title: `${roleName(s.agent)} \u00b7 ${s.change}`,
|
|
143
|
+
says: `passed ${s.passed} of ${s.passed} on ${tierWord(s.from)}: try ${tierWord(s.to)} for this class?`,
|
|
144
|
+
how: 'Yours to decide: send the command in your own session. Studio does not write it.',
|
|
145
|
+
command: lowerCommand(s),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** What the view says when there is nothing to list. */
|
|
150
|
+
export function emptyNote(data) {
|
|
151
|
+
if (!data) return 'Reading the record…';
|
|
152
|
+
if (data.measured === false) return `Not measured — ${data.reason}`;
|
|
153
|
+
if (!data.classes?.length) return 'The record is there and has no line yet.';
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const SOURCE = 'Source: .claude/state/model-outcomes.tsv';
|
|
158
|
+
export const LOWER_RULE = 'Lowering a floor is never automatic. Raising it is.';
|
|
159
|
+
|
|
160
|
+
/** What was read for cost, for the header's tooltip; null before it has been. */
|
|
161
|
+
export const costLine = (costs) => (costs?.measured ? `${fmtCount(costs.sessionsRead ?? 0)} ${costs.sessionsRead === 1 ? 'session' : 'sessions'} read for cost` : null);
|
|
162
|
+
|
|
163
|
+
export const totalsLine = (data) => [
|
|
164
|
+
`${plural(data.rows ?? 0, 'task')} in ${plural(data.classes?.length ?? 0, 'class', 'classes')}`,
|
|
165
|
+
data.dropped ? `${data.dropped} ${data.dropped === 1 ? 'line' : 'lines'} not read (${Object.entries(data.droppedWhy ?? {}).map(([why, n]) => `${n}: ${why}`).join('; ') || 'no reason kept'})` : null,
|
|
166
|
+
data.truncated ? 'only the end of a long record was read' : null,
|
|
167
|
+
].filter(Boolean).join(' · ');
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The Models view: which model each kind of task needs in this project, from Crewforth's record of outcomes.
|
|
2
|
+
//
|
|
3
|
+
// A class is an agent, a kind of change and a risk. Each line says what ran it, how many tasks, how many of the
|
|
4
|
+
// verified ones passed the first time, how many were repeated one model up, and what the agents' tokens come to.
|
|
5
|
+
// Under the table: the holds the record could let go of. They are shown, not acted on: the one button copies
|
|
6
|
+
// Crewforth's command for it and lowers nothing.
|
|
7
|
+
import { classRow, suggestionText, emptyNote, totalsLine, costLine, SOURCE, LOWER_RULE } from './models-plan.js';
|
|
8
|
+
|
|
9
|
+
function mk(tag, cls, text) {
|
|
10
|
+
const n = document.createElement(tag);
|
|
11
|
+
if (cls) n.className = cls;
|
|
12
|
+
if (text != null) n.textContent = text;
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const COLUMNS = [['Agent · change', ''], ['Risk', ''], ['Model', ''], ['Tasks', 'mv-num'], ['First try', 'mv-num'], ['Escalated', 'mv-num'], ['Est. cost', 'mv-num']];
|
|
17
|
+
|
|
18
|
+
export class Models {
|
|
19
|
+
/** @param hooks.onCopy(text) put the command on the clipboard */
|
|
20
|
+
constructor(root, hooks = {}) {
|
|
21
|
+
this.root = root;
|
|
22
|
+
this.hooks = hooks;
|
|
23
|
+
this.data = null;
|
|
24
|
+
this.costs = null;
|
|
25
|
+
this.root.classList.add('mv');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setData(data) { this.data = data; this.render(); }
|
|
29
|
+
/** What the classes cost, read apart and less often: { measured, costs: { classKey: { cost, costed, runs } } }. */
|
|
30
|
+
setCosts(costs) { this.costs = costs; this.render(); }
|
|
31
|
+
|
|
32
|
+
render() {
|
|
33
|
+
if (this.root.hidden) return;
|
|
34
|
+
const note = emptyNote(this.data);
|
|
35
|
+
if (note) { this.root.replaceChildren(mk('div', 'mv-empty', note)); return; }
|
|
36
|
+
const d = this.data;
|
|
37
|
+
const pane = mk('section', 'mv-pane');
|
|
38
|
+
const head = mk('header', 'mv-head');
|
|
39
|
+
const source = mk('span', 'sub', SOURCE);
|
|
40
|
+
source.title = [totalsLine(d), costLine(this.costs)].filter(Boolean).join(' \u00b7 ');
|
|
41
|
+
// Lines the hooks could not have written are not counted, and that is said where the count is.
|
|
42
|
+
if (d.dropped) source.textContent = `${SOURCE} \u00b7 ${d.dropped} ${d.dropped === 1 ? 'line' : 'lines'} not read`;
|
|
43
|
+
head.append(mk('h2', 'mv-title', 'Which model each kind of task needs'), mk('span', 'row-fill'), source);
|
|
44
|
+
pane.append(head);
|
|
45
|
+
|
|
46
|
+
const scroll = mk('div', 'mv-scroll');
|
|
47
|
+
const table = mk('table', 'mv-table');
|
|
48
|
+
const hr = mk('tr');
|
|
49
|
+
for (const [h, cls] of COLUMNS) { const th = mk('th', cls, h); th.scope = 'col'; hr.append(th); }
|
|
50
|
+
const thead = mk('thead'); thead.append(hr);
|
|
51
|
+
const tbody = mk('tbody');
|
|
52
|
+
const raisedNotes = [];
|
|
53
|
+
for (const c of d.classes) {
|
|
54
|
+
const r = classRow(c, this.costs?.measured ? (this.costs.costs?.[c.key] ?? { cost: 0, costed: 0, runs: c.runs }) : null);
|
|
55
|
+
const tr = mk('tr');
|
|
56
|
+
const who = mk('td');
|
|
57
|
+
const name = mk('span', null, `${r.who} · ${r.change}`);
|
|
58
|
+
name.title = r.real;
|
|
59
|
+
who.append(name);
|
|
60
|
+
const risk = mk('td');
|
|
61
|
+
const tag = mk('span', 'mv-tag', r.risk);
|
|
62
|
+
tag.dataset.risk = r.risk;
|
|
63
|
+
risk.append(tag);
|
|
64
|
+
const model = mk('td');
|
|
65
|
+
const ran = mk('span', null, r.models);
|
|
66
|
+
ran.title = r.ranOn ? `Ran on: ${r.ranOn}` : '';
|
|
67
|
+
model.append(ran);
|
|
68
|
+
if (r.floorTag) {
|
|
69
|
+
const raised = mk('span', 'mv-tag', r.floorTag);
|
|
70
|
+
raised.dataset.kind = r.floorTag;
|
|
71
|
+
raised.title = r.raisedWhy;
|
|
72
|
+
model.append(raised);
|
|
73
|
+
raisedNotes.push({ tag: r.floorTag, text: `${r.who} · ${r.change}: ${r.raisedWhy}` });
|
|
74
|
+
}
|
|
75
|
+
const first = mk('td', 'mv-num');
|
|
76
|
+
first.title = [r.firstTryOf, r.after, r.notVerified ? `Not verified: ${r.notVerified}` : null].filter(Boolean).join('\n');
|
|
77
|
+
first.append(mk('span', null, r.firstTry));
|
|
78
|
+
if (r.rate !== null) {
|
|
79
|
+
const meter = mk('span', 'mv-meter');
|
|
80
|
+
const fill = mk('span', 'mv-meter-fill');
|
|
81
|
+
fill.style.width = `${Math.round(r.rate * 100)}%`;
|
|
82
|
+
// Under four in five the bar is the colour of something to look at.
|
|
83
|
+
fill.dataset.tone = r.rate >= 0.8 ? 'good' : 'waiting';
|
|
84
|
+
meter.setAttribute('aria-hidden', 'true');
|
|
85
|
+
meter.append(fill);
|
|
86
|
+
first.append(meter);
|
|
87
|
+
}
|
|
88
|
+
const cost = mk('td', 'mv-num', r.cost);
|
|
89
|
+
cost.title = r.costNote;
|
|
90
|
+
if (r.partial) cost.dataset.partial = 'true';
|
|
91
|
+
tr.append(who, risk, model, mk('td', 'mv-num', r.tasks), first, mk('td', 'mv-num', r.escalated), cost);
|
|
92
|
+
tbody.append(tr);
|
|
93
|
+
}
|
|
94
|
+
table.append(thead, tbody);
|
|
95
|
+
scroll.append(table);
|
|
96
|
+
pane.append(scroll);
|
|
97
|
+
|
|
98
|
+
if (d.suggestions?.length) {
|
|
99
|
+
const box = mk('section', 'mv-suggest');
|
|
100
|
+
box.append(mk('h3', 'mv-suggest-h', 'Suggestion · needs your approval'));
|
|
101
|
+
for (const s of d.suggestions) {
|
|
102
|
+
const t = suggestionText(s);
|
|
103
|
+
box.append(mk('p', 'mv-suggest-text', `${t.title} ${t.says}`));
|
|
104
|
+
const row = mk('div', 'mv-suggest-row');
|
|
105
|
+
row.append(mk('span', 'sub', `${LOWER_RULE} ${t.how}`), mk('span', 'row-fill'));
|
|
106
|
+
if (t.command) {
|
|
107
|
+
// It copies the words and does nothing else: the hold is lowered where the user sends them.
|
|
108
|
+
const b = mk('button', 'btn sm', 'Copy command');
|
|
109
|
+
b.type = 'button';
|
|
110
|
+
b.title = 'Copies the command. Nothing is lowered until you send it in your own session.';
|
|
111
|
+
b.addEventListener('click', () => this.hooks.onCopy?.(t.command));
|
|
112
|
+
row.append(mk('code', 'mv-cmd', t.command), b);
|
|
113
|
+
}
|
|
114
|
+
box.append(row);
|
|
115
|
+
}
|
|
116
|
+
pane.append(box);
|
|
117
|
+
}
|
|
118
|
+
for (const note of raisedNotes) {
|
|
119
|
+
const row = mk('div', 'mv-raised');
|
|
120
|
+
const tag = mk('span', 'mv-tag', note.tag);
|
|
121
|
+
tag.dataset.kind = note.tag;
|
|
122
|
+
row.append(tag, mk('span', 'sub', note.text));
|
|
123
|
+
pane.append(row);
|
|
124
|
+
}
|
|
125
|
+
this.root.replaceChildren(pane);
|
|
126
|
+
}
|
|
127
|
+
}
|
package/kit/studio/web/names.js
CHANGED
|
@@ -30,6 +30,23 @@ export function roleName(type) {
|
|
|
30
30
|
export const shownType = (n) => roleName(typeOf(n));
|
|
31
31
|
|
|
32
32
|
/** What hovering an agent says: the type as it is declared, and its task in full, since both are cut on screen. */
|
|
33
|
+
/**
|
|
34
|
+
* Where a Claude Code agent is defined, in words, or null while that has not been read.
|
|
35
|
+
* `origins` is the server's answer: { project, user }, each a map of agent name to file name, or null where the
|
|
36
|
+
* directory was not read. A plugin's agent is told by its name. An agent in none of these is said to be in none:
|
|
37
|
+
* it is built in to Claude Code, or defined somewhere this panel does not look, and which is not known.
|
|
38
|
+
*/
|
|
39
|
+
export function originOf(type, origins) {
|
|
40
|
+
if (!origins || typeof type !== 'string' || !type) return null;
|
|
41
|
+
const at = type.indexOf(':');
|
|
42
|
+
if (at > 0 && at < type.length - 1) return `Defined in the plugin \u201c${type.slice(0, at)}\u201d`;
|
|
43
|
+
if (origins.project && Object.hasOwn(origins.project, type)) return `Defined in this project: .claude/agents/${origins.project[type]}`;
|
|
44
|
+
if (origins.user && Object.hasOwn(origins.user, type)) return `Defined in your own agents: ~/.claude/agents/${origins.user[type]}`;
|
|
45
|
+
const unread = [origins.project ? null : 'the project', origins.user ? null : 'your own'].filter(Boolean);
|
|
46
|
+
return unread.length === 2 ? 'Where it is defined was not read: no agents directory in the project or your own'
|
|
47
|
+
: `No definition file found${unread.length ? ` (${unread[0]} agents directory was not read)` : ''}: built in to Claude Code, or defined where Studio does not look`;
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
export const hoverOf = (n) => [n?.agentType ?? null, n?.description ?? null].filter(Boolean).join('\n');
|
|
34
51
|
|
|
35
52
|
/**
|
|
@@ -96,3 +113,16 @@ export function modelMix(members) {
|
|
|
96
113
|
}
|
|
97
114
|
return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([w, c]) => `${c} ${w}`).join(', ');
|
|
98
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The badge a card carries for its model: the model in full ("Opus 5.5"), at every density. For work that was
|
|
119
|
+
* repeated one model up, the step that was taken ("Sonnet \u2192 Opus"); and a mark when the call asked for another
|
|
120
|
+
* model than the one that ran.
|
|
121
|
+
*/
|
|
122
|
+
export function modelBadge(n) {
|
|
123
|
+
const m = modelOf(n);
|
|
124
|
+
if (!m.ran) return '';
|
|
125
|
+
const word = (model) => { const f = modelFamily(model); return f ? f[0].toUpperCase() + f.slice(1) : modelName(model); };
|
|
126
|
+
const text = n?.escalatedFrom?.from ? `${word(n.escalatedFrom.from)} \u2192 ${word(n.model)}` : m.ran;
|
|
127
|
+
return `${text}${m.differs ? ' \u2260' : ''}`;
|
|
128
|
+
}
|
package/kit/studio/web/style.css
CHANGED
|
@@ -434,6 +434,8 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
|
|
|
434
434
|
font-size: 13px; color: var(--ink); overflow-wrap: anywhere;
|
|
435
435
|
}
|
|
436
436
|
.toast[hidden] { display: none; }
|
|
437
|
+
/* The one action a toast can carry. The toast takes clicks only where that button is. */
|
|
438
|
+
.toast-act { pointer-events: auto; border: 0; padding: 0; background: none; font: inherit; color: var(--studio-accent); cursor: pointer; text-decoration: underline; }
|
|
437
439
|
|
|
438
440
|
/* ============================================================== stage === */
|
|
439
441
|
|
|
@@ -545,6 +547,56 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
|
|
|
545
547
|
|
|
546
548
|
/* ------------------------------------------------------------ the list ---
|
|
547
549
|
One column, in the order of what needs the reader. */
|
|
550
|
+
/* ------------------------------------------------------------- models ---
|
|
551
|
+
The fourth view: the project's record of which model each kind of task needs. One pane, a table that scrolls
|
|
552
|
+
sideways in a narrow window, and under it what could be lowered. */
|
|
553
|
+
.mv { flex: 1; min-height: 0; min-width: 0; overflow: auto; background: var(--ground); padding: var(--space-4); }
|
|
554
|
+
.mv[hidden] { display: none; }
|
|
555
|
+
.mv-empty { padding: var(--space-6); color: var(--ink-dim); font-size: 13px; }
|
|
556
|
+
.mv-pane { border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
|
|
557
|
+
.mv-head { display: flex; align-items: center; gap: var(--space-2); padding: 14px 18px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
|
558
|
+
.mv-title { margin: 0; font-size: 15px; font-weight: 700; }
|
|
559
|
+
.mv .sub { white-space: normal; flex: initial; min-width: 0; }
|
|
560
|
+
.mv-scroll { overflow-x: auto; }
|
|
561
|
+
.mv-table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
562
|
+
.mv-table th { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-dim); text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
|
563
|
+
.mv-table td { padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
|
564
|
+
.mv-table tbody tr:last-child td { border-bottom: 0; }
|
|
565
|
+
.mv-table .mv-num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
566
|
+
.mv-table td.mv-num { font-family: var(--font-mono); }
|
|
567
|
+
.mv-tag { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: var(--radius-sm); font-size: 11px; font-weight: 700; white-space: nowrap; background: var(--surface-raised); color: var(--ink-dim); }
|
|
568
|
+
.mv-tag[data-risk="critical"] { color: var(--status-fail); background: color-mix(in srgb, var(--status-fail) 14%, transparent); }
|
|
569
|
+
.mv-tag[data-kind="raised"] { margin-left: 6px; color: var(--status-waiting); background: color-mix(in srgb, var(--status-waiting) 14%, transparent); }
|
|
570
|
+
.mv-tag[data-kind="lowered"] { margin-left: 6px; color: var(--studio-accent); background: color-mix(in srgb, var(--studio-accent) 16%, transparent); }
|
|
571
|
+
.mv-raised .mv-tag[data-kind="lowered"] { margin-left: 0; }
|
|
572
|
+
.mv-tag[data-tone="pass"] { color: var(--status-good); background: color-mix(in srgb, var(--status-good) 14%, transparent); }
|
|
573
|
+
.mv-tag[data-tone="fail"] { color: var(--status-fail); background: color-mix(in srgb, var(--status-fail) 14%, transparent); }
|
|
574
|
+
.mv-tag[data-kind="escalated"] { color: var(--studio-accent); background: color-mix(in srgb, var(--studio-accent) 16%, transparent); }
|
|
575
|
+
.mv-table td[data-partial="true"] { color: var(--ink-dim); }
|
|
576
|
+
.mv-meter { display: inline-block; width: 72px; height: 6px; margin-left: 8px; border-radius: 999px; background: var(--surface-raised); overflow: hidden; vertical-align: middle; }
|
|
577
|
+
.mv-meter-fill { display: block; height: 6px; }
|
|
578
|
+
.mv-meter-fill[data-tone="good"] { background: var(--status-good); }
|
|
579
|
+
.mv-meter-fill[data-tone="waiting"] { background: var(--status-waiting); }
|
|
580
|
+
.mv-suggest { margin: 16px 18px; padding: 14px 16px; display: flex; flex-direction: column; gap: 8px; border: 1px solid color-mix(in srgb, var(--studio-accent) 45%, var(--line)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--studio-accent) 9%, var(--surface)); }
|
|
581
|
+
.mv-suggest-h { margin: 0; font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--studio-accent); }
|
|
582
|
+
.mv-suggest-text { margin: 0; font-size: 14px; line-height: 21px; }
|
|
583
|
+
.mv-suggest-row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
|
|
584
|
+
.mv-cmd { padding: 2px 6px; border-radius: var(--radius-sm); background: var(--surface-raised); font-size: 12px; user-select: all; }
|
|
585
|
+
.mv-raised { display: flex; align-items: baseline; gap: var(--space-2); padding: 0 18px 16px; }
|
|
586
|
+
.mv-raised .mv-tag { margin-left: 0; flex: none; }
|
|
587
|
+
|
|
588
|
+
/* The inspector's task card: the three lines as they were written, and under them how the work went. */
|
|
589
|
+
.icard { margin: 0; display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 4px 12px; font-size: 12px; line-height: 18px; }
|
|
590
|
+
.icard dt { color: var(--ink-faint); }
|
|
591
|
+
.icard dd { margin: 0; font-family: var(--font-mono); overflow-wrap: anywhere; }
|
|
592
|
+
.isec-h .mv-tag { float: right; text-transform: none; letter-spacing: 0; }
|
|
593
|
+
.isteps { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; font-size: 12px; line-height: 20px; }
|
|
594
|
+
.isteps li { display: flex; gap: 10px; align-items: flex-start; }
|
|
595
|
+
.istep-n { flex: none; width: 20px; height: 20px; border-radius: 999px; background: var(--surface-raised); color: var(--ink-dim); font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; }
|
|
596
|
+
.istep-says { min-width: 0; overflow-wrap: anywhere; }
|
|
597
|
+
.istep-says .mv-tag { margin-left: 6px; }
|
|
598
|
+
.istep-note { margin: 8px 0 0; font-size: 12px; line-height: 16px; color: var(--ink-dim); }
|
|
599
|
+
|
|
548
600
|
.ls { flex: 1; min-height: 0; min-width: 0; overflow-y: auto; background: var(--ground); padding: var(--space-3) var(--space-4) var(--space-6); }
|
|
549
601
|
.ls[hidden] { display: none; }
|
|
550
602
|
.ls-sec { max-width: 760px; margin: 0 auto var(--space-4); }
|
|
@@ -742,6 +794,37 @@ button.tl-lab { cursor: pointer; }
|
|
|
742
794
|
}
|
|
743
795
|
.terminal-wait-note { flex: none; margin-left: auto; }
|
|
744
796
|
|
|
797
|
+
/* A question the model asks, or a plan it wants approved: a form in the dock, or on a card in the List. */
|
|
798
|
+
.ask { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; margin-top: var(--space-1); }
|
|
799
|
+
.ask-q { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
|
800
|
+
.ask-head { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; flex-wrap: wrap; }
|
|
801
|
+
.ask-tag { flex: none; padding: 0 6px; border-radius: var(--radius-sm); background: var(--surface-raised); font-size: 11px; color: var(--ink-dim); }
|
|
802
|
+
.ask-text { font-size: 13px; color: var(--ink); overflow-wrap: anywhere; }
|
|
803
|
+
.ask-opts { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
804
|
+
.ask-opt {
|
|
805
|
+
height: 28px; padding: 0 10px; border-radius: var(--radius-sm); border: 1px solid var(--line);
|
|
806
|
+
background: var(--surface); color: var(--ink); font: inherit; font-size: 12px; cursor: pointer;
|
|
807
|
+
}
|
|
808
|
+
.ask-opt:hover { border-color: var(--ink-faint); }
|
|
809
|
+
.ask-opt[aria-checked="true"] { border-color: var(--studio-accent); box-shadow: 0 0 0 1px var(--studio-accent); }
|
|
810
|
+
.ask-opt:disabled, .ask-own:disabled { opacity: 0.5; cursor: default; }
|
|
811
|
+
.ask-own {
|
|
812
|
+
height: 28px; padding: 0 8px; min-width: 0; max-width: 360px; border-radius: var(--radius-sm);
|
|
813
|
+
border: 1px solid var(--line); background: var(--ground); color: var(--ink); font: inherit; font-size: 12px;
|
|
814
|
+
}
|
|
815
|
+
.ask-plan {
|
|
816
|
+
margin: 0; padding: var(--space-2) var(--space-3); max-height: 220px; overflow: auto;
|
|
817
|
+
border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--ground);
|
|
818
|
+
font-family: var(--font-mono); font-size: 12px; line-height: 1.5; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere;
|
|
819
|
+
}
|
|
820
|
+
.ask-acts { display: flex; flex-wrap: wrap; gap: var(--space-2); justify-content: flex-end; }
|
|
821
|
+
.dock-ask[hidden] { display: none; }
|
|
822
|
+
/* The mode in the conversation's strip is a control: it looks like the text it sits among until it is hovered. */
|
|
823
|
+
.pane-mode { border: 0; padding: 0 4px; margin: 0 -4px; border-radius: var(--radius-sm); background: none; font: inherit; color: inherit; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
|
|
824
|
+
.pane-mode:hover { background: var(--surface-raised); color: var(--ink); }
|
|
825
|
+
/* With a form in it the dock is as tall as the form needs, and the countdown sits at its top. */
|
|
826
|
+
.dock[data-ask="questions"], .dock[data-ask="plan"] { align-items: start; }
|
|
827
|
+
|
|
745
828
|
/* -------------------------------------------------------------- dock ---
|
|
746
829
|
The approval dock: a row under the canvas, there only while a tool call is
|
|
747
830
|
waiting. It is part of the stage's column, so the canvas above it stays
|
|
@@ -1009,15 +1092,6 @@ button.tl-lab { cursor: pointer; }
|
|
|
1009
1092
|
}
|
|
1010
1093
|
.cv-tile svg { width: 64%; height: 64%; display: block; }
|
|
1011
1094
|
.cv-tile-sm { width: 18px; height: 18px; }
|
|
1012
|
-
/* A type nobody declared is marked as one. It is not quietly drawn as built-in. */
|
|
1013
|
-
.cv-tile[data-source="unknown"]::after {
|
|
1014
|
-
content: '?';
|
|
1015
|
-
position: absolute; right: -5px; top: -5px;
|
|
1016
|
-
width: 12px; height: 12px; border-radius: var(--radius-pill);
|
|
1017
|
-
display: flex; align-items: center; justify-content: center;
|
|
1018
|
-
background: var(--surface); border: 1px solid var(--line);
|
|
1019
|
-
font-family: var(--font-mono); font-size: 9px; line-height: 1; color: var(--ink-dim);
|
|
1020
|
-
}
|
|
1021
1095
|
|
|
1022
1096
|
.cv-pill {
|
|
1023
1097
|
display: inline-flex; align-items: center; gap: 5px; flex: none;
|
|
@@ -1032,6 +1106,8 @@ button.tl-lab { cursor: pointer; }
|
|
|
1032
1106
|
.cv-node.cv-chip { flex-direction: row; align-items: center; gap: var(--space-2); padding: 0 10px; border-radius: 8px; }
|
|
1033
1107
|
.cv-chip .cv-name { flex: 0 1 auto; }
|
|
1034
1108
|
.cv-chip .cv-task { flex: 1 1 0; font-size: 12px; }
|
|
1109
|
+
/* On a chip the model is said in full and does not shrink; the task beside it does. */
|
|
1110
|
+
.cv-chip .cv-model { flex: none; }
|
|
1035
1111
|
|
|
1036
1112
|
/* The session: where everything starts. */
|
|
1037
1113
|
.cv-session { cursor: pointer; }
|
|
@@ -108,6 +108,14 @@ export function lanes(bars) {
|
|
|
108
108
|
return out[1].length ? out : [out[0]];
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Which groups are folded. A group is as the viewer left it (`hand`: id -> folded); one they have not touched
|
|
113
|
+
* follows the last "Expand all" (`all` true) or "Fold all" (`all` false), and before either it is open.
|
|
114
|
+
*/
|
|
115
|
+
export function foldedSet(ids, hand = new Map(), all = null) {
|
|
116
|
+
return new Set(ids.filter((id) => (hand.has(id) ? hand.get(id) : all === false)));
|
|
117
|
+
}
|
|
118
|
+
|
|
111
119
|
/** The groups of a session's agents under one grouping, in the order they first started. */
|
|
112
120
|
export function groupsOf(nodes, grouping) {
|
|
113
121
|
const agents = nodes.filter((n) => n.kind === 'agent');
|