Public: anyone can read this; the crew can change it.

Settings › Tokens, Crew, Subscriptions and Usage get the Security page's rewrite, each measurably shorter #9617

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9583-settings-tokens-crew-subscriptions-and-u → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/9583-settings-tokens-crew-subscriptions-and-u was merged into main as 96b4746.

Review

Sign in to review this change.
7 files changed +133 −242
src/web/pages/crew-settings.ts +5 −5
@@ -32,7 +32,7 @@ export async function crewSubscriptionsSave(ctx: Ctx): Promise<Response> {
3232 const crew = listCrew(ctx.app, ctx.auth.user, repo);
3333 saveGrants(ctx, form, repo.id, crew.ok ? crew.value : []);
3434 return flashTo(ctx, `${repoBase(repo)}/settings#crew`, {
35 message: "Saved. The next run here works on what is ticked.",
35 message: "Saved.",
3636 });
3737 }
3838
@@ -52,15 +52,15 @@ export async function crewAdd(ctx: Ctx): Promise<Response> {
5252 `${repoBase(repo)}/settings#crew`,
5353 added.ok
5454 ? {
55 message: `${added.value.user.username} is on the crew. Assign it an issue and it starts.`,
55 message: `${added.value.user.username} is on the crew.`,
5656 }
5757 : {
5858 tone: "danger",
5959 message:
6060 added.error === "role_not_declared"
61 ? "That agent does not do that job. Change its roles first, under Settings → Crew."
61 ? "That agent does not do that job."
6262 : added.error === "not_an_agent"
63 ? "Only an agent can be on a crew; a person is given access through an organization's team."
63 ? "Only an agent can be on a crew."
6464 : "No such agent on this forge.",
6565 },
6666 );
@@ -111,7 +111,7 @@ export async function pillarUpdate(ctx: Ctx): Promise<Response> {
111111 if (checked === undefined)
112112 return flashTo(ctx, `${base}/settings#team`, {
113113 tone: "danger",
114 message: "A pillar may do a whole number at once, from 0 (paused) to 8; the planner 0 or 1.",
114 message: "Use a whole number from 0 (paused) to 8; the planner 0 or 1.",
115115 });
116116 const done = await setSeatSlots(ctx.app, ctx.auth.user, repo, checked.role, checked.slots);
117117 return flashTo(
src/web/pages/proposal-tokens.ts +30 −41
@@ -12,7 +12,7 @@ import { loadRepo, repoBase, repoNotFound, NOT_YOURS_TO_DO, forbiddenPage } from
1212 import { field, readForm } from "../form.ts";
1313 import { html, type Html } from "../html.ts";
1414 import { flashTo } from "../layout.ts";
15 import { btn, card, confirmAction, field as formField, icon, time } from "../ui.ts";
15 import { btn, confirmAction, field as formField, time } from "../ui.ts";
1616
1717 /** The section, or null for someone who cannot write here. */
1818 export function proposalTokensSection(ctx: Ctx, repo: Repo): Html | null {
@@ -20,46 +20,35 @@ export function proposalTokensSection(ctx: Ctx, repo: Repo): Html | null {
2020 const base = repoBase(repo);
2121 const listed = listProposals(ctx.app, ctx.auth.user, repo);
2222 const tokens = listed.ok ? listed.value : [];
23 return card(
24 html`<p class="muted">
25 A proposal token lets one named child forge read this repository's code (to fork it), push
26 <code>child/&lt;name&gt;/…</code> branches here and open pulls from
27 them — nothing else, and revoking it ends all of it. It lives 30 days.
28 </p>
29 ${tokens.length === 0
30 ? html`<p class="muted">No proposal tokens.</p>`
31 : html`<div class="table-scroll">
32 <table class="table">
33 <thead>
34 <tr><th>Child</th><th>Origin</th><th>Expires</th><th>Last used</th><th></th></tr>
35 </thead>
36 <tbody>
37 ${tokens.map(
38 (t) =>
39 html`<tr>
40 <td><strong>${t.child}</strong></td>
41 <td class="muted mono">${t.origin}</td>
42 <td class="muted">${t.expiresAt ? time(t.expiresAt) : "never"}</td>
43 <td class="muted">${t.lastUsedAt ? time(t.lastUsedAt) : "never"}</td>
44 <td>
45 ${confirmAction({
46 action: `${base}/settings/proposal-tokens/${t.id}/delete`,
47 verb: "Revoke",
48 size: "sm",
49 })}
50 </td>
51 </tr>`,
52 )}
53 </tbody>
54 </table>
55 </div>`}
56 <form method="post" action="${base}/settings/proposal-tokens">
57 ${formField({ label: "Child", name: "child", mono: true, required: true, hint: "Lowercase words and hyphens." })}
58 ${formField({ label: "Origin", name: "origin", mono: true, required: true, placeholder: "https://child.example.com/", hint: "Where the child lives." })}
59 <div class="form-actions">${btn("Mint token", { variant: "primary" })}</div>
60 </form>`,
61 { head: html`${icon("key")}<h2>Proposal tokens</h2>` },
62 );
23 return html`<section class="settings-block" id="proposal-tokens">
24 <h2>Proposal tokens</h2>
25 <p class="muted">Lets one child forge fork this code, push <code>child/&lt;name&gt;/…</code> branches and open pulls. Lasts 30 days.</p>
26 ${tokens.length === 0
27 ? null
28 : html`<dl class="status-rows">
29 ${tokens.map(
30 (t) => html`<div class="status-row">
31 <dt>${t.child}</dt>
32 <dd>
33 <span class="mono">${t.origin}</span>
34 <span class="muted">· expires ${t.expiresAt ? time(t.expiresAt) : "never"} · last used ${t.lastUsedAt ? time(t.lastUsedAt) : "never"}</span>
35 </dd>
36 <dd class="status-act">
37 ${confirmAction({
38 action: `${base}/settings/proposal-tokens/${t.id}/delete`,
39 verb: "Revoke",
40 size: "sm",
41 })}
42 </dd>
43 </div>`,
44 )}
45 </dl>`}
46 <form method="post" action="${base}/settings/proposal-tokens" class="stack">
47 ${formField({ label: "Child", name: "child", mono: true, required: true, hint: "Lowercase words and hyphens." })}
48 ${formField({ label: "Origin", name: "origin", mono: true, required: true, placeholder: "https://child.example.com/" })}
49 <div class="form-actions">${btn("Mint token", { variant: "primary" })}</div>
50 </form>
51 </section>`;
6352 }
6453
6554 /** The repository for a writer's POST, or the response that refuses it. */
src/web/pages/subscriptions.ts +43 −95
@@ -46,7 +46,6 @@ import { flashTo, page } from "../layout.ts";
4646 import {
4747 badge,
4848 btn,
49 card,
5049 confirmAction,
5150 empty,
5251 listCard,
@@ -141,14 +140,14 @@ export function subscriptionsPage(ctx: Ctx): Response {
141140 : empty({
142141 icon: "key",
143142 title: "The crew works on the forge's own token",
144 note: "Add a subscription here and grant it to a repository or an organization; its runs spend that account instead. One granted nowhere is never offered to the crew.",
143 note: "Add one, then grant it to a repository or organization.",
145144 });
146145 const list =
147146 pool.length === 0
148147 ? empty({
149148 icon: "key",
150149 title: "The crew works on the forge's own token",
151 note: "Add a subscription here and grant it to a repository or an organization; its runs spend that account instead. Two on one grant take turns, and one the provider rests hands over to the other.",
150 note: "Add one, then grant it to a repository or organization.",
152151 })
153152 : listCard(
154153 pool.map((s) => {
@@ -191,9 +190,12 @@ export function subscriptionsPage(ctx: Ctx): Response {
191190 )}${when(
192191 st !== undefined,
193192 () =>
194 html`<br />${st!.windows.map((w, i) =>
195 i === 0 ? windowText(w) : html` · ${windowText(w)}`,
196 )}`,
193 html`<br /><span
194 title="A figure marked ~ is inferred from what the crew's runs spent; the rest the provider said."
195 >${st!.windows.map((w, i) =>
196 i === 0 ? windowText(w) : html` · ${windowText(w)}`,
197 )}</span
198 >`,
197199 )}`,
198200 trail: html`${when(
199201 st !== undefined,
@@ -267,8 +269,8 @@ export function subscriptionsPage(ctx: Ctx): Response {
267269 { extra: "setting-list" },
268270 );
269271
270 const add = card(
271 html`<form class="stack" method="post" action="${POOL}">
272 const add = html`<section class="settings-block"><h2>Add one</h2>
273 <form class="stack" method="post" action="${POOL}">
272274 <div class="field">
273275 <label for="f-name">Name</label>
274276 <input
@@ -294,9 +296,8 @@ export function subscriptionsPage(ctx: Ctx): Response {
294296 spellcheck="false"
295297 />
296298 <span class="hint"
297 ><code>claude setup-token</code> prints a subscription's token; an
298 Anthropic API key or an OpenRouter key (<code>sk-or-v1-…</code>)
299 works too. It is sealed on this forge and never shown again.</span
299 ><code>claude setup-token</code> prints one; an API key works too.
300 Sealed here, never shown again.</span
300301 >
301302 </div>
302303 <div class="field">
@@ -312,67 +313,30 @@ export function subscriptionsPage(ctx: Ctx): Response {
312313 spellcheck="false"
313314 />
314315 <span class="hint"
315 >Anthropic-compatible gateway for Claude Code. OpenRouter:
316 <code>https://openrouter.ai/api</code> (no <code>/v1</code> — the
317 SDK adds it). Leave empty for Anthropic itself; a setup-token
318 (sk-ant-oat…) takes none.</span
316 >Empty for Anthropic itself; a setup-token takes none.</span
319317 >
320318 </div>
321319 <div>${btn("Add", { variant: "primary", size: "sm", icon: "plus" })}</div>
322 </form>`,
323 {
324 head: html`${icon("plus")}
325 <h2>Add one</h2>`,
326 },
327 );
320 </form></section>`;
328321
329322 const used =
330323 places.length === 0
331324 ? null
332 : card(
333 html`<ul class="list">
325 : html`<section class="settings-block"><h2>Where each is used</h2>
326 <dl class="status-rows">
334327 ${places.map(
335 (g) =>
336 html`<li>
337 <div class="row">
338 <span class="row-lead">${icon("key")}</span>
339 <span class="row-main">
340 <span class="row-title">${g.subscription}</span>
341 <span class="row-meta"
342 >${g.place} ·
343 ${g.agent === undefined
344 ? "everyone on the crew"
345 : g.agent}</span
346 >
347 </span>
348 <a class="row-trail small" href="${g.href}">change</a>
349 </div>
350 </li>`,
328 (g) => html`<div class="status-row">
329 <dt>${g.subscription}</dt>
330 <dd>${g.place} · ${g.agent === undefined ? "everyone on the crew" : g.agent}</dd>
331 <dd class="status-act"><a href="${g.href}">Change</a></dd>
332 </div>`,
351333 )}
352 </ul>`,
353 {
354 pad: false,
355 head: html`${icon("map")}
356 <h2>Where each is used</h2>`,
357 },
358 );
334 </dl></section>`;
359335
360336 const response = shell(
outdated

The footnote explaining '~' (inferred figures) was removed, and the page now has no explanation of the marker. Restore it as one short line or a tooltip.

361337 ctx,
362338 html`${list} ${none === null ? null : html`<div class="mt">${none}</div>`}
363 ${when(
364 pool.length > 0,
365 () =>
366 html`<p class="muted small mt">
367 Windows in US dollars of list price. A figure marked ~ is inferred
368 from what the crew's runs spent; the rest the provider said. The
369 cap is learned the first time the provider calls a window full,
370 and the share kept for people is never spent by the crew. Tokens
371 are what the crew's runs read and wrote in the window, counted by
372 Claude's tokenizer.
373 </p>`,
374 )}
375 <p class="mt muted small">What these accounts spent, by credential, app, agent, issue and run kind: <a href="/settings/spend">Usage → Spend</a>.</p>
339 <p class="mt muted small">What they spent: <a href="/settings/spend">Usage → Spend</a>.</p>
376340 <div class="mt">${add}</div>
377341 ${used === null ? null : html`<div class="mt">${used}</div>`}`,
378342 );
@@ -503,17 +467,9 @@ export function grantsSection(
503467 },
504468 ): Html {
505469 const pool = listSubscriptions(ctx.app.db);
506 const head = html`${icon("key")}
507 <h2>${o.title ?? "Subscriptions"}</h2>`;
508470 if (pool.length === 0)
509 return card(
510 html`<p class="muted small">
511 Runs here work on the forge's own token. Add a subscription under
512 <a href="${POOL}">Settings → Subscriptions</a> to spend a particular
513 account here.
514 </p>`,
515 { head },
516 );
471 return html`<section class="settings-block"><h2>${o.title ?? "Subscriptions"}</h2>
472 <p class="muted small">Runs use the forge's own token. <a href="${POOL}">Add a subscription</a> to use another.</p></section>`;
517473 const granted = new Set(
518474 listGrants(ctx.app.db, o.scopeId).map(
519475 (g) => `${g.agentId}:${g.subscriptionId}`,
@@ -538,32 +494,24 @@ export function grantsSection(
538494 </td>`,
539495 )}
540496 </tr>`;
541 return html`<form method="post" action="${o.action}">
542 ${card(
543 html`<div class="table-scroll">
544 <table class="table">
545 <thead>
546 <tr>
547 <th></th>
548 ${pool.map((s) => html`<th scope="col">${s.name}</th>`)}
549 </tr>
550 </thead>
551 <tbody>
552 ${line(EVERYONE, html`Everyone on the crew`)}
553 ${o.agents.map((a) => line(a.user.id, html`${a.user.username}`))}
554 </tbody>
555 </table>
556 </div>`,
557 {
558 head,
559 pad: false,
560 foot: html`${btn("Save", { variant: "primary", size: "sm" })}
561 <span class="muted small"
562 >Two ticked on one row take turns. Nothing ticked means the wider
563 grant applies: ${o.wider}.</span
564 >`,
565 },
566 )}
497 return html`<form method="post" action="${o.action}" class="settings-block">
outdated

Principle 8: 'Nothing ticked: …' is a fragment. Write 'Nothing ticked uses the wider grant: …'.

498 <h2>${o.title ?? "Subscriptions"}</h2>
499 <div class="table-scroll">
500 <table class="table">
501 <thead>
502 <tr>
503 <th></th>
504 ${pool.map((s) => html`<th scope="col">${s.name}</th>`)}
505 </tr>
506 </thead>
507 <tbody>
508 ${line(EVERYONE, html`Everyone on the crew`)}
509 ${o.agents.map((a) => line(a.user.id, html`${a.user.username}`))}
510 </tbody>
511 </table>
512 </div>
513 <p class="form-actions">${btn("Save", { variant: "primary", size: "sm" })}
514 <span class="muted small">Nothing ticked uses the wider grant: ${o.wider}.</span></p>
567515 </form>`;
568516 }
569517
src/web/pages/usage.ts +15 −18
@@ -14,11 +14,12 @@ import type { Ctx } from "../../http/context.ts";
1414 import { signInFirst } from "../chrome.ts";
1515 import { html } from "../html.ts";
1616 import { page } from "../layout.ts";
17 import { btn, card, empty, icon } from "../ui.ts";
17 import { btn, empty } from "../ui.ts";
1818 import { usageShell } from "./usage-tabs.ts";
1919
20 /** Only the keys that were used: "user 5 · agent 2", or nothing. */
2021 const part = (counts: Readonly<Record<string, number>>, keys: readonly string[]) =>
21 keys.map((k) => `${k} ${counts[k] ?? 0}`).join(" · ");
22 keys.filter((k) => (counts[k] ?? 0) > 0).map((k) => `${k} ${counts[k]}`).join(" · ");
2223
2324 // GET /settings/usage
2425 export function usagePage(ctx: Ctx, patterns: () => readonly string[]): Response {
@@ -44,22 +45,18 @@ export function usagePage(ctx: Ctx, patterns: () => readonly string[]): Response
4445 const rows = timePhase(ctx.request, "usage", () => routeUsage(ctx.app.db, known, new Date()));
4546 const rendering = performance.now();
4647 const unused = rows.filter((r) => r.total === 0).length;
47 const body = card(
48 html`<p class="muted small card-body">${String(unused)} of ${String(rows.length)} routes had no request in
49 ${String(WINDOW_DAYS)} days; they are listed first. Counted by <code>usage_count</code>; one pattern
50 covers every method.</p>
51 <table class="table" aria-label="Route usage">
52 <thead><tr><th scope="col">Pattern</th><th scope="col" class="num">${String(WINDOW_DAYS)} days</th>
53 <th scope="col">By role</th><th scope="col">By surface</th><th scope="col">Last use (UTC)</th></tr></thead>
54 <tbody>${rows.map(
55 (r) => html`<tr data-unused="${r.total === 0 ? "1" : "0"}">
56 <td class="mono">${r.pattern}</td><td class="num mono">${String(r.total)}</td>
57 <td class="mono small">${part(r.roles, ROLES)}</td><td class="mono small">${part(r.surfaces, SURFACES)}</td>
58 <td class="mono">${r.last === null ? "never" : r.last.replace("T", " ") + ":00"}</td></tr>`,
59 )}</tbody>
60 </table>`,
61 { head: html`${icon("activity")}<h2>Routes</h2>`, pad: false },
62 );
48 const body = html`<p class="muted small">${String(unused)} of ${String(rows.length)} routes had no request in
49 ${String(WINDOW_DAYS)} days; they come first.</p>
50 <div class="table-scroll"><table class="table" aria-label="Route usage">
51 <thead><tr><th scope="col">Pattern</th><th scope="col" class="num">${String(WINDOW_DAYS)} days</th>
52 <th scope="col">By role</th><th scope="col">By surface</th><th scope="col">Last use (UTC)</th></tr></thead>
53 <tbody>${rows.map(
54 (r) => html`<tr data-unused="${r.total === 0 ? "1" : "0"}">
55 <td class="mono">${r.pattern}</td><td class="num mono">${String(r.total)}</td>

Principle 7: a list of routes should be rows, not a bare table. Use the row pattern, or at least wrap it in table-scroll so it does not overflow at 390.

56 <td class="small muted">${part(r.roles, ROLES)}</td><td class="small muted">${part(r.surfaces, SURFACES)}</td>
57 <td class="mono">${r.last === null ? "never" : r.last.replace("T", " ") + ":00"}</td></tr>`,
58 )}</tbody>
59 </table></div>`;
6360 const res = usageShell(ctx, "routes", body);
6461 notePhase(ctx.request, "render", performance.now() - rendering);
6562 return res;
src/web/studio/crew.ts +33 −81
@@ -50,19 +50,12 @@ import {
5050 btn,
5151 type Tone,
5252 card,
53 empty,
5453 field,
5554 row,
5655 selectField,
5756 userLink,
5857 } from "../ui.ts";
5958
60 const ROLE_WORD: Record<CrewRole, string> = {
61 builder: "builds",
62 reviewer: "reviews",
63 planner: "plans",
64 };
65
6659 /**
6760 * The sentence and the ceiling the New app page asked for, editable again:
6861 * posts straight to `settingsUpdate` (`POST /:owner/:name/settings`, the
@@ -155,7 +148,9 @@ function teamCard(
155148 everyone: readonly Agent[],
156149 ): Html {
157150 const team = seatsOf(ctx.app.db, repo, crewOffInPreview());
158 const held = new Map(listCrewRoles(ctx.app.db, repo.id).map((c) => [c.agent.user.username, c.role]));
151 const crew = listCrewRoles(ctx.app.db, repo.id);
152 const held = new Map(crew.map((c) => [c.agent.user.username, c.role]));
153 const byName = new Map(crew.map((c) => [c.agent.user.username, c.agent]));
159154 const seatSlots = new Map(seatSlotsOf(ctx.app.db, repo).map((p) => [p.role, p]));
160155 const rows = team.seats.map((p) => {
161156 const slots = seatSlots.get(p.role)!;
@@ -185,10 +180,13 @@ function teamCard(
185180 min="0" max="${String(slots.max)}" value="${String(slots.slots)}" />
186181 ${btn("Set", { size: "sm" })}
187182 </form>`;
188 const members = p.by.map((u) =>
189 row({
183 const members = p.by.map((u) => {
184 const agent = byName.get(u);
185 const said = [agent?.model ?? "", agent?.brief ?? ""].filter((w) => w !== "").join(" · ");
186 return row({
190187 lead: icon("bot"),
191 title: userLink(u),
188 title: agent ? userLink(u, { mark: markOf(agent.user) }) : userLink(u),
189 ...(said === "" ? {} : { meta: html`<span title="${said}">${said}</span>` }),
192190 ...(admin
193191 ? {
194192 trail: html`<form class="row-actions" method="post" action="${base}/settings/crew/${encodeURIComponent(u)}/remove">
@@ -197,8 +195,8 @@ function teamCard(
197195 }
198196 : {}),
199197 data: { member: u, seat: p.role },
200 }),
201 );
198 });
199 });
202200 return row({
203201 lead: icon(SEAT_LEAD[p.state]),
204202 title: SEAT_TITLE[p.role],
@@ -217,14 +215,16 @@ function teamCard(
217215 });
218216 });
219217 const intro = crewOffInPreview()
220 ? "The crew is off in a preview, so nobody here is working."
218 ? "The crew is off in a preview."
221219 : team.auto
222 ? `Auto mode is on (${team.mode}): your team works on its own — a planner decides what is next, a builder builds it, a reviewer checks it.`
223 : "Auto mode is off: your team works only when you ask. Pick who fills each list, then turn auto mode on under On its own. The number beside a list is how many may work at once; 0 pauses it.";
220 ? `Auto mode is on (${team.mode}): the team works on its own.`
221 : "Auto mode is off: the team works when asked. Turn it on under On its own. A list's number is how many work at once; 0 pauses it.";
222 const nobody = crew.length > 0 ? null : nobodyLine(everyone.length > 0, ctx.auth.user?.isAdmin === true);
224223 return card(
225224 html`<p class="small" id="auto-mode" data-auto="${team.auto ? "on" : "off"}">
226225 ${intro}
227226 </p>
227 ${nobody}
228228 <ul class="list setting-list">
229229 ${rows}
230230 </ul>`,
@@ -236,69 +236,26 @@ function teamCard(
236236 );
237237 }
238238
239 /**
240 * What an empty crew is told, in one line: pick from a list above, or — when
241 * no agent exists on the forge — where one is made (a forge admin) or who
242 * can make one (anyone else).
243 */
244 function nobodyLine(agentsExist: boolean, forgeAdmin: boolean): Html {
245 return html`<p class="small" id="crew-nobody">
246 <strong>Nobody yet.</strong>
247 ${agentsExist
248 ? "Pick an agent in a list below."
249 : forgeAdmin
250 ? html`No agents exist on this forge yet. <a href="/settings/crew">Add an agent</a>.`
251 : "No agents exist on this forge yet. An admin can add one."}
252 </p>`;
253 }
254
255 /** The team, what the crew still needs, and — for a forge admin — whose subscription it spends. */
239256 function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html {
240 const db = ctx.app.db;
241 const crew = listCrewRoles(db, repo.id);
242 const onCrew = new Set(crew.map((c) => c.agent.user.id));
243257 const allListed = listAllAgents(ctx.app, ctx.auth.user);
244 const available = (allListed.ok ? allListed.value : []).filter(
245 (agent) => !onCrew.has(agent.user.id),
246 );
247
248258 const forgeAdmin = ctx.auth.user?.isAdmin === true;
249 const rows =
250 crew.length === 0
251 ? empty(
252 available.length > 0
253 ? {
254 icon: "bot",
255 title: "Nobody yet",
256 note: "Add an agent below and tell it what you want.",
257 }
258 : forgeAdmin
259 ? {
260 icon: "bot",
261 title: "Nobody yet",
262 note: "No agents exist on this forge yet.",
263 action: btn("Add an agent", {
264 href: "/settings/crew",
265 variant: "primary",
266 icon: "plus",
267 }),
268 }
269 : {
270 icon: "bot",
271 title: "Nobody yet",
272 note: "No agents exist on this forge yet. An admin can add one.",
273 },
274 )
275 : html`<ul class="list setting-list">${crew.map((c) =>
276 row({
277 lead: icon("bot"),
278 title: html`${userLink(c.agent.user.username, {
279 mark: markOf(c.agent.user),
280 })}`,
281 meta: html`<span title="${c.agent.brief}"
282 >${ROLE_WORD[c.role]}${c.agent.model === ""
283 ? ""
284 : ` · ${c.agent.model}`}${c.agent.brief === ""
285 ? ""
286 : ` · ${c.agent.brief}`}</span
287 >`,
288 ...(admin
289 ? {
290 trail: html`<form
291 class="row-actions"
292 method="post"
293 action="${base}/settings/crew/${encodeURIComponent(c.agent.user.username)}/remove"
294 >
295 ${btn("Remove", { size: "sm", icon: "x" })}
296 </form>`,
297 }
298 : {}),
299 }),
300 )}</ul>`;
301
302259 // Whose money the crew spends here is the forge admin's to say.
303260 const grants =
304261 admin && forgeAdmin
@@ -315,11 +272,6 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html {
315272 return html`<div id="crew" data-crew-settings>
316273 ${teamCard(ctx, repo, base, admin, allListed.ok ? allListed.value : [])}
317274 ${admin ? crewReadiness(ctx) : null}
318 ${card(html`${rows}`, {
319 head: html`${icon("bot")}
320 <h2>Crew</h2>`,
321 pad: false,
322 })}
323275 ${grants}
324276 </div>`;
325277 }
test/studio-shipping.test.ts +6 −1
@@ -468,7 +468,12 @@ describe("a stranger sees the app, not the owner's books", () => {
468468 const after = await (
469469 await get("/alice/beacon/settings", ownerCookie)
470470 ).text();
471 expect(after).toContain("reviews");
471 // Keeper declares only "builder", so the repository cannot widen it to
472 // reviewer: it is drawn once, under the seat it holds — the team lists
473 // are the crew, there is no second Crew card listing it again.
474 const keeperRows = after.match(/<li[^>]*data-member="keeper"[^>]*>/g) ?? [];
475 expect(keeperRows).toEqual(['<li data-member="keeper" data-seat="builder">']);
476 expect(after).not.toContain("<h2>Crew</h2>");
472477 });
473478
474479 test("?mode=dev on the settings route is the same one page; dev's Crew and Autonomy addresses land on its cards", async () => {
test/tokens.test.ts +1 −1
@@ -271,7 +271,7 @@ describe("token counts on the pages", () => {
271271 // 1,200,000 + 34,000 = 1,234,000 → 1.2M, in both windows.
272272 const settings = await get("/settings/subscriptions", cookie);
273273 expect(settings.match(/>1\.2M tok</g)?.length).toBe(2);
274 expect(settings).toContain("counted by\n Claude's tokenizer");
274 expect(settings).toContain("as counted by Claude");
275275 const board = await get("/board", cookie);
276276 expect(board.match(/>1\.2M tok</g)?.length).toBe(2);
277277 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs