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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9583-settings-tokens-crew-subscriptions-and-u → main 6 commits fork 3 threads · 3 unresolved
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> { | ||
| 32 | 32 | const crew = listCrew(ctx.app, ctx.auth.user, repo); |
| 33 | 33 | saveGrants(ctx, form, repo.id, crew.ok ? crew.value : []); |
| 34 | 34 | return flashTo(ctx, `${repoBase(repo)}/settings#crew`, { |
| 35 | message: "Saved. The next run here works on what is ticked.", | |
| 35 | message: "Saved.", | |
| 36 | 36 | }); |
| 37 | 37 | } |
| 38 | 38 | |
| @@ -52,15 +52,15 @@ export async function crewAdd(ctx: Ctx): Promise<Response> { | ||
| 52 | 52 | `${repoBase(repo)}/settings#crew`, |
| 53 | 53 | added.ok |
| 54 | 54 | ? { |
| 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.`, | |
| 56 | 56 | } |
| 57 | 57 | : { |
| 58 | 58 | tone: "danger", |
| 59 | 59 | message: |
| 60 | 60 | 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." | |
| 62 | 62 | : 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." | |
| 64 | 64 | : "No such agent on this forge.", |
| 65 | 65 | }, |
| 66 | 66 | ); |
| @@ -111,7 +111,7 @@ export async function pillarUpdate(ctx: Ctx): Promise<Response> { | ||
| 111 | 111 | if (checked === undefined) |
| 112 | 112 | return flashTo(ctx, `${base}/settings#team`, { |
| 113 | 113 | 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.", | |
| 115 | 115 | }); |
| 116 | 116 | const done = await setSeatSlots(ctx.app, ctx.auth.user, repo, checked.role, checked.slots); |
| 117 | 117 | return flashTo( |
src/web/pages/proposal-tokens.ts +30 −41
| @@ -12,7 +12,7 @@ import { loadRepo, repoBase, repoNotFound, NOT_YOURS_TO_DO, forbiddenPage } from | ||
| 12 | 12 | import { field, readForm } from "../form.ts"; |
| 13 | 13 | import { html, type Html } from "../html.ts"; |
| 14 | 14 | 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"; | |
| 16 | 16 | |
| 17 | 17 | /** The section, or null for someone who cannot write here. */ |
| 18 | 18 | export function proposalTokensSection(ctx: Ctx, repo: Repo): Html | null { |
| @@ -20,46 +20,35 @@ export function proposalTokensSection(ctx: Ctx, repo: Repo): Html | null { | ||
| 20 | 20 | const base = repoBase(repo); |
| 21 | 21 | const listed = listProposals(ctx.app, ctx.auth.user, repo); |
| 22 | 22 | 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/<name>/…</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/<name>/…</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>`; | |
| 63 | 52 | } |
| 64 | 53 | |
| 65 | 54 | /** 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"; | ||
| 46 | 46 | import { |
| 47 | 47 | badge, |
| 48 | 48 | btn, |
| 49 | card, | |
| 50 | 49 | confirmAction, |
| 51 | 50 | empty, |
| 52 | 51 | listCard, |
| @@ -141,14 +140,14 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 141 | 140 | : empty({ |
| 142 | 141 | icon: "key", |
| 143 | 142 | 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.", | |
| 145 | 144 | }); |
| 146 | 145 | const list = |
| 147 | 146 | pool.length === 0 |
| 148 | 147 | ? empty({ |
| 149 | 148 | icon: "key", |
| 150 | 149 | 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.", | |
| 152 | 151 | }) |
| 153 | 152 | : listCard( |
| 154 | 153 | pool.map((s) => { |
| @@ -191,9 +190,12 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 191 | 190 | )}${when( |
| 192 | 191 | st !== undefined, |
| 193 | 192 | () => |
| 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 | >`, | |
| 197 | 199 | )}`, |
| 198 | 200 | trail: html`${when( |
| 199 | 201 | st !== undefined, |
| @@ -267,8 +269,8 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 267 | 269 | { extra: "setting-list" }, |
| 268 | 270 | ); |
| 269 | 271 | |
| 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}"> | |
| 272 | 274 | <div class="field"> |
| 273 | 275 | <label for="f-name">Name</label> |
| 274 | 276 | <input |
| @@ -294,9 +296,8 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 294 | 296 | spellcheck="false" |
| 295 | 297 | /> |
| 296 | 298 | <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 | |
| 300 | 301 | > |
| 301 | 302 | </div> |
| 302 | 303 | <div class="field"> |
| @@ -312,67 +313,30 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 312 | 313 | spellcheck="false" |
| 313 | 314 | /> |
| 314 | 315 | <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 | |
| 319 | 317 | > |
| 320 | 318 | </div> |
| 321 | 319 | <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>`; | |
| 328 | 321 | |
| 329 | 322 | const used = |
| 330 | 323 | places.length === 0 |
| 331 | 324 | ? 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"> | |
| 334 | 327 | ${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>`, | |
| 351 | 333 | )} |
| 352 | </ul>`, | |
| 353 | { | |
| 354 | pad: false, | |
| 355 | head: html`${icon("map")} | |
| 356 | <h2>Where each is used</h2>`, | |
| 357 | }, | |
| 358 | ); | |
| 334 | </dl></section>`; | |
| 359 | 335 | |
| 360 | 336 | const response = shell( |
| 361 | 337 | ctx, |
| 362 | 338 | 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> | |
| 376 | 340 | <div class="mt">${add}</div> |
| 377 | 341 | ${used === null ? null : html`<div class="mt">${used}</div>`}`, |
| 378 | 342 | ); |
| @@ -503,17 +467,9 @@ export function grantsSection( | ||
| 503 | 467 | }, |
| 504 | 468 | ): Html { |
| 505 | 469 | const pool = listSubscriptions(ctx.app.db); |
| 506 | const head = html`${icon("key")} | |
| 507 | <h2>${o.title ?? "Subscriptions"}</h2>`; | |
| 508 | 470 | 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>`; | |
| 517 | 473 | const granted = new Set( |
| 518 | 474 | listGrants(ctx.app.db, o.scopeId).map( |
| 519 | 475 | (g) => `${g.agentId}:${g.subscriptionId}`, |
| @@ -538,32 +494,24 @@ export function grantsSection( | ||
| 538 | 494 | </td>`, |
| 539 | 495 | )} |
| 540 | 496 | </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> | |
| 567 | 515 | </form>`; |
| 568 | 516 | } |
| 569 | 517 |
src/web/pages/usage.ts +15 −18
| @@ -14,11 +14,12 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 14 | 14 | import { signInFirst } from "../chrome.ts"; |
| 15 | 15 | import { html } from "../html.ts"; |
| 16 | 16 | import { page } from "../layout.ts"; |
| 17 | import { btn, card, empty, icon } from "../ui.ts"; | |
| 17 | import { btn, empty } from "../ui.ts"; | |
| 18 | 18 | import { usageShell } from "./usage-tabs.ts"; |
| 19 | 19 | |
| 20 | /** Only the keys that were used: "user 5 · agent 2", or nothing. */ | |
| 20 | 21 | 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(" · "); | |
| 22 | 23 | |
| 23 | 24 | // GET /settings/usage |
| 24 | 25 | export function usagePage(ctx: Ctx, patterns: () => readonly string[]): Response { |
| @@ -44,22 +45,18 @@ export function usagePage(ctx: Ctx, patterns: () => readonly string[]): Response | ||
| 44 | 45 | const rows = timePhase(ctx.request, "usage", () => routeUsage(ctx.app.db, known, new Date())); |
| 45 | 46 | const rendering = performance.now(); |
| 46 | 47 | 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>`; | |
| 63 | 60 | const res = usageShell(ctx, "routes", body); |
| 64 | 61 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 65 | 62 | return res; |
src/web/studio/crew.ts +33 −81
| @@ -50,19 +50,12 @@ import { | ||
| 50 | 50 | btn, |
| 51 | 51 | type Tone, |
| 52 | 52 | card, |
| 53 | empty, | |
| 54 | 53 | field, |
| 55 | 54 | row, |
| 56 | 55 | selectField, |
| 57 | 56 | userLink, |
| 58 | 57 | } from "../ui.ts"; |
| 59 | 58 | |
| 60 | const ROLE_WORD: Record<CrewRole, string> = { | |
| 61 | builder: "builds", | |
| 62 | reviewer: "reviews", | |
| 63 | planner: "plans", | |
| 64 | }; | |
| 65 | ||
| 66 | 59 | /** |
| 67 | 60 | * The sentence and the ceiling the New app page asked for, editable again: |
| 68 | 61 | * posts straight to `settingsUpdate` (`POST /:owner/:name/settings`, the |
| @@ -155,7 +148,9 @@ function teamCard( | ||
| 155 | 148 | everyone: readonly Agent[], |
| 156 | 149 | ): Html { |
| 157 | 150 | 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])); | |
| 159 | 154 | const seatSlots = new Map(seatSlotsOf(ctx.app.db, repo).map((p) => [p.role, p])); |
| 160 | 155 | const rows = team.seats.map((p) => { |
| 161 | 156 | const slots = seatSlots.get(p.role)!; |
| @@ -185,10 +180,13 @@ function teamCard( | ||
| 185 | 180 | min="0" max="${String(slots.max)}" value="${String(slots.slots)}" /> |
| 186 | 181 | ${btn("Set", { size: "sm" })} |
| 187 | 182 | </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({ | |
| 190 | 187 | 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>` }), | |
| 192 | 190 | ...(admin |
| 193 | 191 | ? { |
| 194 | 192 | trail: html`<form class="row-actions" method="post" action="${base}/settings/crew/${encodeURIComponent(u)}/remove"> |
| @@ -197,8 +195,8 @@ function teamCard( | ||
| 197 | 195 | } |
| 198 | 196 | : {}), |
| 199 | 197 | data: { member: u, seat: p.role }, |
| 200 | }), | |
| 201 | ); | |
| 198 | }); | |
| 199 | }); | |
| 202 | 200 | return row({ |
| 203 | 201 | lead: icon(SEAT_LEAD[p.state]), |
| 204 | 202 | title: SEAT_TITLE[p.role], |
| @@ -217,14 +215,16 @@ function teamCard( | ||
| 217 | 215 | }); |
| 218 | 216 | }); |
| 219 | 217 | const intro = crewOffInPreview() |
| 220 | ? "The crew is off in a preview, so nobody here is working." | |
| 218 | ? "The crew is off in a preview." | |
| 221 | 219 | : 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); | |
| 224 | 223 | return card( |
| 225 | 224 | html`<p class="small" id="auto-mode" data-auto="${team.auto ? "on" : "off"}"> |
| 226 | 225 | ${intro} |
| 227 | 226 | </p> |
| 227 | ${nobody} | |
| 228 | 228 | <ul class="list setting-list"> |
| 229 | 229 | ${rows} |
| 230 | 230 | </ul>`, |
| @@ -236,69 +236,26 @@ function teamCard( | ||
| 236 | 236 | ); |
| 237 | 237 | } |
| 238 | 238 | |
| 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. */ | |
| 239 | 256 | 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)); | |
| 243 | 257 | 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 | ||
| 248 | 258 | 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 | ||
| 302 | 259 | // Whose money the crew spends here is the forge admin's to say. |
| 303 | 260 | const grants = |
| 304 | 261 | admin && forgeAdmin |
| @@ -315,11 +272,6 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | ||
| 315 | 272 | return html`<div id="crew" data-crew-settings> |
| 316 | 273 | ${teamCard(ctx, repo, base, admin, allListed.ok ? allListed.value : [])} |
| 317 | 274 | ${admin ? crewReadiness(ctx) : null} |
| 318 | ${card(html`${rows}`, { | |
| 319 | head: html`${icon("bot")} | |
| 320 | <h2>Crew</h2>`, | |
| 321 | pad: false, | |
| 322 | })} | |
| 323 | 275 | ${grants} |
| 324 | 276 | </div>`; |
| 325 | 277 | } |
test/studio-shipping.test.ts +6 −1
| @@ -468,7 +468,12 @@ describe("a stranger sees the app, not the owner's books", () => { | ||
| 468 | 468 | const after = await ( |
| 469 | 469 | await get("/alice/beacon/settings", ownerCookie) |
| 470 | 470 | ).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>"); | |
| 472 | 477 | }); |
| 473 | 478 | |
| 474 | 479 | 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", () => { | ||
| 271 | 271 | // 1,200,000 + 34,000 = 1,234,000 → 1.2M, in both windows. |
| 272 | 272 | const settings = await get("/settings/subscriptions", cookie); |
| 273 | 273 | 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"); | |
| 275 | 275 | const board = await get("/board", cookie); |
| 276 | 276 | expect(board.match(/>1\.2M tok</g)?.length).toBe(2); |
| 277 | 277 | }); |
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.