Public: anyone can read this; the crew can change it.
An agent's page under Settings › Crew speaks the home page's language #9847
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9831-an-agent-s-page-under-settings-crew-spea → main 2 commits fork
2 files
changed +99
−32
src/web/pages/crew.ts +25 −32
| @@ -1013,43 +1013,36 @@ export async function agentCreate(ctx: Ctx): Promise<Response> { | ||
| 1013 | 1013 | |
| 1014 | 1014 | function agentView(agent: Agent, values: AgentValues, error?: string): Html { |
| 1015 | 1015 | const at = `/settings/crew/${encodeURIComponent(agent.user.username)}`; |
| 1016 | return html`${card( | |
| 1017 | html`<form method="post" action="${at}"> | |
| 1018 | <div class="field"> | |
| 1019 | <label for="f-username">Username</label> | |
| 1020 | <input | |
| 1021 | class="input" | |
| 1022 | id="f-username" | |
| 1023 | value="${agent.user.username}" | |
| 1024 | readonly | |
| 1025 | /> | |
| 1026 | </div> | |
| 1027 | ${agentFields(values, error)} | |
| 1028 | <div class="form-actions"> | |
| 1029 | ${btn("Save changes", { variant: "primary" })} | |
| 1030 | ${btn("Back to the crew", { href: "/settings/crew", variant: "ghost" })} | |
| 1031 | </div> | |
| 1032 | </form>`, | |
| 1033 | { | |
| 1034 | head: html`${icon("bot")} | |
| 1035 | <h2>${agent.user.username}</h2> | |
| 1036 | <span class="muted small">added ${time(agent.createdAt)}</span>`, | |
| 1037 | }, | |
| 1038 | )} | |
| 1039 | ${card( | |
| 1040 | html`<p> | |
| 1016 | return html`<section class="settings-block"> | |
| 1017 | <h2>Settings</h2> | |
| 1018 | <p class="muted small">added ${time(agent.createdAt)}</p> | |
| 1019 | <form method="post" action="${at}"> | |
| 1020 | <div class="field"> | |
| 1021 | <label for="f-username">Username</label> | |
| 1022 | <input | |
| 1023 | class="input" | |
| 1024 | id="f-username" | |
| 1025 | value="${agent.user.username}" | |
| 1026 | readonly | |
| 1027 | /> | |
| 1028 | </div> | |
| 1029 | ${agentFields(values, error)} | |
| 1030 | <div class="form-actions"> | |
| 1031 | ${btn("Save changes", { variant: "primary" })} | |
| 1032 | ${btn("Back to the crew", { href: "/settings/crew", variant: "ghost" })} | |
| 1033 | </div> | |
| 1034 | </form> | |
| 1035 | </section> | |
| 1036 | <section class="settings-block"> | |
| 1037 | <h2>Retire</h2> | |
| 1038 | <p> | |
| 1041 | 1039 | Retiring <strong>${agent.user.username}</strong> destroys its tokens and |
| 1042 | 1040 | takes it off every crew. What it wrote stays, under its name. |
| 1043 | 1041 | </p> |
| 1044 | 1042 | <div class="form-actions"> |
| 1045 | 1043 | ${confirmAction({ action: `${at}/delete`, verb: "Retire this agent" })} |
| 1046 | </div>`, | |
| 1047 | { | |
| 1048 | tone: "danger", | |
| 1049 | head: html`${icon("alert")} | |
| 1050 | <h2>Retire</h2>`, | |
| 1051 | }, | |
| 1052 | )}`; | |
| 1044 | </div> | |
| 1045 | </section>`; | |
| 1053 | 1046 | } |
| 1054 | 1047 | |
| 1055 | 1048 | function loadAgent(ctx: Ctx): Agent | undefined { |
test/agent-language.test.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** An agent's page under Settings › Crew speaks the home page's language (#9831): headings and sections, no card. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createAgent } from "../src/actions/crew.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve } from "../src/http/server.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | let dir = ""; | |
| 16 | let app: ReturnType<typeof createApp>; | |
| 17 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 18 | let admin = ""; | |
| 19 | let member = ""; | |
| 20 | ||
| 21 | const person = async (username: string, adminIfFirst: boolean) => { | |
| 22 | const made = await createUser(app.db, { | |
| 23 | origin: "seed", | |
| 24 | trusted: true, | |
| 25 | username, | |
| 26 | email: `${username}@forge.test`, | |
| 27 | adminIfFirst, | |
| 28 | }); | |
| 29 | if (!made.ok) throw new Error(String(made.error)); | |
| 30 | return made.value; | |
| 31 | }; | |
| 32 | ||
| 33 | beforeAll(async () => { | |
| 34 | dir = await mkdtemp(join(tmpdir(), "forge-agent-lang-")); | |
| 35 | app = createApp({ dataDir: dir, port: 0 }); | |
| 36 | servers = await serve(app); | |
| 37 | const root = await person("alice", true); | |
| 38 | admin = `forge_session=${createSession(app.db, root.id).token}`; | |
| 39 | const bob = await person("bob", false); | |
| 40 | member = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 41 | const made = await createAgent(app, root, { | |
| 42 | username: "scoutly", | |
| 43 | model: "claude-sonnet-5-5", | |
| 44 | brief: "Ships small things.", | |
| 45 | roles: ["builder"], | |
| 46 | }); | |
| 47 | if (!made.ok) throw new Error(String(made.error)); | |
| 48 | }); | |
| 49 | afterAll(async () => { | |
| 50 | servers.stop(); | |
| 51 | app.close(); | |
| 52 | await rm(dir, { recursive: true, force: true }); | |
| 53 | }); | |
| 54 | ||
| 55 | const fetchPage = (who: string) => | |
| 56 | fetch(servers.url + "/settings/crew/scoutly", { headers: { cookie: who }, redirect: "manual" }); | |
| 57 | ||
| 58 | test("an agent's page: one h1, plain sections, both forms, no card", async () => { | |
| 59 | const res = await fetchPage(admin); | |
| 60 | expect(res.status).toBe(200); | |
| 61 | const html = await res.text(); | |
| 62 | const page = html.slice(html.indexOf("<main"), html.indexOf("</main>")); | |
| 63 | expect(page.match(/<h1[ >]/g)?.length).toBe(1); | |
| 64 | expect(page).toContain("scoutly"); | |
| 65 | expect(page).not.toContain('class="card'); | |
| 66 | expect(page).not.toContain("card-body"); | |
| 67 | expect(page).toContain('action="/settings/crew/scoutly"'); | |
| 68 | // the delete action is the confirm door, as before: it leads to the POST | |
| 69 | expect(page).toContain("to=%2Fsettings%2Fcrew%2Fscoutly%2Fdelete"); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a non-admin still gets 404", async () => { | |
| 73 | expect((await fetchPage(member)).status).toBe(404); | |
| 74 | }); |