Public: anyone can read this; the crew can change it.
A client's first screen offers "Ask for something" on each app, opening the tell form for that app #3371
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3345-a-client-s-first-screen-offers-ask-for-s → main 5 commits fork 2 threads · 2 unresolved
9 files
changed +141
−6
README.md +6
| @@ -2023,6 +2023,12 @@ until it is re-created with `forge: ` in front of its message. | ||
| 2023 | 2023 | |
| 2024 | 2024 | `reach` is a boards source for an admin only (anyone else gets 403, an agent too): `visitors` per day, `lcp_p75` and `inp_p75` per route per day, `server_p50` and `server_p95` per route per day (the beacon's time to first byte, the only per-route server timing the forge keeps), and `accounts_active_1d`, `accounts_active_7d` and `accounts_total` as of now. Filter by `metric`, group by `route` or a time bucket. Percentiles are nearest-rank within one route and day, so a `max` or `avg` over several days is a summary of daily percentiles, not a percentile of the period. Account activity comes from a session's last-seen time, so it is a count as of now, not a history. No dependency was added. |
| 2025 | 2025 | |
| 2026 | A client's first screen offers "Ask for something" on each app they may direct | |
| 2027 | (`mayDirectCrew`): on the web a link to `/:owner/:name/tell?from=/&ask=1` (no | |
| 2028 | script), in the terminal the `t` key on the app under the cursor. Both go | |
| 2029 | through the same `tell()` and answer "Sent. The agents will pick it up."; the | |
| 2030 | web returns to `/` with that line. | |
| 2031 | ||
| 2026 | 2032 | `GET /metrics` (site admin only; repository names are in the labels) serves |
| 2027 | 2033 | `forge_egress_connections`, `forge_egress_bytes_in` and `forge_egress_bytes_out` |
| 2028 | 2034 | per `repo` and `destination` as Prometheus text, so a leaked upstream socket |
src/actions/tell.ts +3
| @@ -64,6 +64,9 @@ export function parseFrom(raw: unknown): string | null | undefined { | ||
| 64 | 64 | export const FROM_WORDS = |
| 65 | 65 | "from is a path on this forge, like /alice/notes/pulls/3, at most 512 characters"; |
| 66 | 66 | |
| 67 | /** What a client reads after asking for something, on the web and in the terminal. */ | |
| 68 | export const SENT_WORDS = "Sent. The agents will pick it up."; | |
| 69 | ||
| 67 | 70 | export type TellError = |
| 68 | 71 | | "empty" |
| 69 | 72 | | "bad_from" |
src/tui/screens/dashboard.ts +11
| @@ -23,6 +23,7 @@ import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_ | ||
| 23 | 23 | import { clientAppRows, clientDoings, doingLine } from "../../core/client-apps.ts"; |
| 24 | 24 | import { nextStepsFor } from "../../core/next-steps.ts"; |
| 25 | 25 | import { agentsLine } from "../../web/studio/client_home.ts"; |
| 26 | import { askForSomething } from "./tell.ts"; | |
| 26 | 27 | import { mayView } from "../../core/sealed.ts"; |
| 27 | 28 | import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts"; |
| 28 | 29 | import { plain } from "./boards.ts"; |
| @@ -163,6 +164,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 163 | 164 | let waits: string[] = []; |
| 164 | 165 | /** A client's apps, one sentence each on whether its agents work or wait (#1812). */ |
| 165 | 166 | let clientLines: string[] = []; |
| 167 | /** The ids of those apps, so the key that asks for something stays on them. */ | |
| 168 | let clientRepoIds = new Set<string>(); | |
| 166 | 169 | /** The check lane, as the board's strip says it; null when nothing is queued. */ |
| 167 | 170 | let lane: string | null = null; |
| 168 | 171 | /** What the decider did today, the board's Today line; null on a quiet day or for a reader. */ |
| @@ -186,6 +189,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 186 | 189 | // A client's home never carries the forge-wide figures, on either door. |
| 187 | 190 | figures = []; |
| 188 | 191 | const apps = await clientAppRows(f.app, f.user); |
| 192 | clientRepoIds = new Set(apps.map((a) => a.repo.id)); | |
| 189 | 193 | const now = new Date(); |
| 190 | 194 | const doings = clientDoings(f.app.db, apps.map((a) => a.repo.id), now); |
| 191 | 195 | clientLines = |
| @@ -741,6 +745,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 741 | 745 | disabled: focus !== "machine" || pins.length === 0, |
| 742 | 746 | }), |
| 743 | 747 | binding(["C"], "decider", { disabled: !f.user }), |
| 748 | ...(clientLines.length > 0 ? [binding(["t"], "ask for something")] : []), | |
| 744 | 749 | binding(["n"], "new repo", { disabled: !f.signedIn }), |
| 745 | 750 | binding(["M"], "migrate", { disabled: !f.signedIn }), |
| 746 | 751 | binding(["A"], "account", { disabled: !f.signedIn }), |
| @@ -941,6 +946,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 941 | 946 | } |
| 942 | 947 | return true; |
| 943 | 948 | } |
| 949 | if (e.key === "t" && f.user && clientLines.length > 0) { | |
| 950 | // A client's apps are the list; the one under the cursor is asked. | |
| 951 | const repo = shown()[at("repos").index]; | |
| 952 | if (repo && clientRepoIds.has(repo.id)) askForSomething(f, ctx, () => screen, repo); | |
| 953 | return true; | |
| 954 | } | |
| 944 | 955 | if (e.key === "n" && f.signedIn) { |
| 945 | 956 | ctx.push(newRepoScreen(f, ctx)); |
| 946 | 957 | return true; |
src/tui/screens/tell.ts +21 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | * the same `parseContext` and `contextNote` the pill's tell and the MCP use. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { tell, toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told, type TellError } from "../../actions/tell.ts"; | |
| 11 | import { SENT_WORDS, tell, toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told, type TellError } from "../../actions/tell.ts"; | |
| 12 | 12 | import { contextNote, parseContext } from "../../actions/tell_context.ts"; |
| 13 | 13 | import type { Repo } from "../../core/repos.ts"; |
| 14 | 14 | import { DIRECTION_NOTE, mayDirectCrew } from "../../crew/direct.ts"; |
| @@ -118,3 +118,23 @@ export async function tellFromPage( | ||
| 118 | 118 | const answer = tellAnswer(await tell(f.app, f.user, repo, `${said}${contextNote(where)}`)); |
| 119 | 119 | ctx.toast(answer.text, answer.tone); |
| 120 | 120 | } |
| 121 | ||
| 122 | /** A client's "Ask for something" on the first screen: one line to the app's crew, answered in the web's words. */ | |
| 123 | export function askForSomething(f: Session, ctx: Ctx, parent: () => Screen, repo: Repo): void { | |
| 124 | if (!mayDirectCrew(f.db, repo, f.user)) return; | |
| 125 | ctx.push( | |
| 126 | promptScreen(ctx, parent(), { | |
| 127 | title: "Ask for something", | |
| 128 | label: repo.name, | |
| 129 | hint: "enter sends", | |
| 130 | onSubmit: (said) => { | |
| 131 | if (said === "") return "Say something first."; | |
| 132 | return (async () => { | |
| 133 | const told = await tell(f.app, f.user, repo, said, 1, "/"); | |
| 134 | const answer = told.ok ? { text: SENT_WORDS, tone: "success" as const } : tellAnswer(told); | |
| 135 | ctx.toast(answer.text, answer.tone); | |
| 136 | })(); | |
| 137 | }, | |
| 138 | }), | |
| 139 | ); | |
| 140 | } |
src/web/pages/thread.ts +5 −2
| @@ -431,7 +431,7 @@ export interface ComposerOptions { | ||
| 431 | 431 | * the button tells the crew, and `from` (a path on this forge, or null) |
| 432 | 432 | * rides along as a hidden field so the tell remembers the page it was |
| 433 | 433 | * said on (`actions/tell.ts`). `rows` sizes it; one is a single line. */ |
| 434 | tell?: { from: string | null; rows?: number; page?: string; pull?: number; text?: string }; | |
| 434 | tell?: { from: string | null; rows?: number; page?: string; pull?: number; text?: string; ask?: boolean }; | |
| 435 | 435 | } |
| 436 | 436 | |
| 437 | 437 | /** The comment box. Close and reopen are their own form, not an `intent` |
| @@ -516,6 +516,9 @@ export function composer(ctx: Ctx, o: ComposerOptions): Html { | ||
| 516 | 516 | ${tell?.from |
| 517 | 517 | ? html`<input type="hidden" name="from" value="${tell.from}" />` |
| 518 | 518 | : null} |
| 519 | ${tell?.ask | |
| 520 | ? html`<input type="hidden" name="ask" value="1" />` | |
| 521 | : null} | |
| 519 | 522 | ${tell?.page |
| 520 | 523 | ? html`<input type="hidden" name="page" value="${tell.page}" />` |
| 521 | 524 | : null} |
| @@ -528,7 +531,7 @@ export function composer(ctx: Ctx, o: ComposerOptions): Html { | ||
| 528 | 531 | <div class="form-actions"> |
| 529 | 532 | <span class="muted small" |
| 530 | 533 | >${tell |
| 531 | ? html`${tell.from | |
| 534 | ? html`${tell.from && !tell.ask | |
| 532 | 535 | ? html`From <code>${tell.from}</code>. ` |
| 533 | 536 | : null}${tell.page |
| 534 | 537 | ? html`From <code>${tell.page}</code>. ` |
src/web/studio/actions.ts +4 −1
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | import { mayView } from "../../core/sealed.ts"; |
| 12 | 12 | import { holdPull, releasePull } from "../../actions/holds.ts"; |
| 13 | 13 | import { addComment } from "../../actions/issues.ts"; |
| 14 | import { parseFrom, tell, toldWords, TOO_LONG_WORDS } from "../../actions/tell.ts"; | |
| 14 | import { parseFrom, SENT_WORDS, tell, toldWords, TOO_LONG_WORDS } from "../../actions/tell.ts"; | |
| 15 | 15 | import { contextNote, parseContext } from "../../actions/tell_context.ts"; |
| 16 | 16 | import { keepThought } from "../../actions/thought.ts"; |
| 17 | 17 | import { DIRECTION_NOTE } from "../../crew/direct.ts"; |
| @@ -77,6 +77,9 @@ async function tellFrom( | ||
| 77 | 77 | : "That could not be told.", |
| 78 | 78 | }); |
| 79 | 79 | const { routed } = told.value; |
| 80 | // A client's "Ask for something" (named by the form, not guessed from `/`): | |
| 81 | // they asked for something and want nothing more than to know it went. | |
| 82 | if (from === "/" && field(form, "ask") === "1") return flashOrJson(ctx, back, { message: SENT_WORDS }); | |
| 80 | 83 | if (routed === undefined) |
| 81 | 84 | return flashOrJson(ctx, back, { |
| 82 | 85 | message: `Opened as an issue. Nobody is on the crew to take it yet — add someone under Settings → Crew.`, |
src/web/studio/client_home.ts +10 −2
| @@ -10,9 +10,10 @@ import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../cor | ||
| 10 | 10 | import { clientAppRows, clientDoings, doingLine } from "../../core/client-apps.ts"; |
| 11 | 11 | import type { User } from "../../core/users.ts"; |
| 12 | 12 | import type { Ctx } from "../../http/context.ts"; |
| 13 | import { mayDirectCrew } from "../../crew/direct.ts"; | |
| 13 | 14 | import { repoBase } from "../chrome.ts"; |
| 14 | 15 | import { html, type Html } from "../html.ts"; |
| 15 | import { card } from "../ui.ts"; | |
| 16 | import { btn, card } from "../ui.ts"; | |
| 16 | 17 | |
| 17 | 18 | /** The sentence about the agents, from how many are going and how many are lined up. */ |
| 18 | 19 | export function agentsLine(working: number, queued: number): string { |
| @@ -36,8 +37,15 @@ export async function clientApps(ctx: Ctx, user: User): Promise<Html> { | ||
| 36 | 37 | const rows = apps.map(({ repo, working, queued }) => { |
| 37 | 38 | const doing = doingLine(doings.get(repo.id), now.getTime()); |
| 38 | 39 | const about = repo.description !== "" ? repo.description : "An app of yours."; |
| 40 | // The tell form with this app chosen; it sends the person back to `/`. | |
| 41 | const ask = mayDirectCrew(ctx.app.db, repo, user) | |
| 42 | ? btn("Ask for something", { | |
| 43 | href: `${repoBase(repo)}/tell?from=/&ask=1`, | |
outdated
Inline after the name, the button wraps unpredictably at 360px. Name the row layout in DESIGN.md section 5 and give the button a thumb-sized target. |
||
| 44 | variant: "outline", | |
| 45 | }) | |
| 46 | : null; | |
| 39 | 47 | return html`<li class="client-app"> |
| 40 | <a href="${repoBase(repo)}"><b>${repo.name}</b></a> | |
| 48 | <a href="${repoBase(repo)}"><b>${repo.name}</b></a> ${ask} | |
| 41 | 49 | <span class="client-app-about">${about}</span> |
| 42 | 50 | <span class="client-app-agents muted" data-agents="${working > 0 ? "working" : "waiting"}" |
| 43 | 51 | >${agentsLine(working, queued)}</span |
src/web/studio/tell_here.ts +2
| @@ -45,6 +45,8 @@ export function tellHerePage(ctx: Ctx): Response { | ||
| 45 | 45 | from, |
| 46 | 46 | rows: ctx.url.searchParams.get("in") === "place" ? 1 : 4, |
| 47 | 47 | ...(text === "" ? {} : { text }), |
| 48 | // A client's "Ask for something": sent from `/`, and said plainly there. | |
| 49 | ...(ctx.url.searchParams.get("ask") === "1" && from === "/" ? { ask: true } : {}), | |
| 48 | 50 | ...(where?.page === undefined ? {} : { page: where.page }), |
| 49 | 51 | ...(where?.pull === undefined ? {} : { pull: where.pull }), |
| 50 | 52 | }, |
test/client-home.test.ts +79
| @@ -12,6 +12,8 @@ import { join } from "node:path"; | ||
| 12 | 12 | import { createRepo } from "../src/actions/repos.ts"; |
| 13 | 13 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; |
| 14 | 14 | import { createApp, type App } from "../src/app.ts"; |
| 15 | import { clientApps } from "../src/web/studio/client_home.ts"; | |
| 16 | import type { Ctx } from "../src/http/context.ts"; | |
| 15 | 17 | import { clientAppRows, cutTitle, doingLine } from "../src/core/client-apps.ts"; |
| 16 | 18 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; |
| 17 | 19 | import { moveArchived } from "../src/core/repos.ts"; |
| @@ -178,6 +180,83 @@ describe("a client's first screen", () => { | ||
| 178 | 180 | expect(home).toContain('href="/?all=1"'); |
| 179 | 181 | }); |
| 180 | 182 | |
| 183 | test("each app offers Ask for something, which opens the tell form for it, and sending lands back here", async () => { | |
| 184 | const home = main(await page(owner)); | |
| 185 | expect(home).toContain('href="/marias/bakery/tell?from=/&ask=1"'); | |
| 186 | expect(home).toContain("Ask for something"); | |
| 187 | const form = await page(owner, "/marias/bakery/tell?from=/&ask=1"); | |
| 188 | expect(form).toContain('action="/marias/bakery/tell"'); | |
| 189 | expect(form).toContain('name="ask" value="1"'); | |
| 190 | expect(form).not.toContain("From <code>"); | |
| 191 | const sent = await post(owner, "/marias/bakery/tell", { text: "Add a rye loaf", from: "/", ask: "1" }); | |
| 192 | expect(sent.status).toBe(303); | |
| 193 | expect(new URL(sent.headers.get("location")!, servers.url).pathname).toBe("/"); | |
| 194 | const flash = sent.headers.get("set-cookie")!.split(";")[0]!; | |
| 195 | expect(await page(`${owner}; ${flash}`)).toContain("Sent. The agents will pick it up."); | |
| 196 | }); | |
| 197 | ||
| 198 | test("a tell sent from / without asking keeps its routed line", async () => { | |
| 199 | const sent = await post(owner, "/marias/bakery/tell", { text: "Add a plain loaf", from: "/" }); | |
| 200 | const flash = decodeURIComponent(sent.headers.get("set-cookie")!); | |
| 201 | expect(flash).not.toContain("Sent. The agents will pick it up."); | |
| 202 | }); | |
| 203 | ||
| 204 | test("the link keeps the same-origin rule: a tell from another origin is refused", async () => { | |
| 205 | const res = await fetch(url("/marias/bakery/tell"), { | |
| 206 | method: "POST", | |
| 207 | headers: { cookie: owner, origin: "https://evil.test" }, | |
| 208 | body: new URLSearchParams({ text: "x", from: "/" }), | |
| 209 | redirect: "manual", | |
| 210 | }); | |
| 211 | expect(res.status).toBeGreaterThanOrEqual(400); | |
| 212 | }); | |
| 213 | ||
| 214 | test("a viewer who may not tell is offered no link", async () => { | |
| 215 | const reader = await createUser(child.db, { | |
| 216 | origin: "seed", | |
| 217 | trusted: true, | |
| 218 | username: "lookeronly", | |
| 219 | email: "[email protected]", | |
| 220 | password: PASSWORD, | |
| 221 | }); | |
| 222 | if (!reader.ok) throw new Error("reader"); | |
| 223 | child.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'bakery'").run(); | |
| 224 | try { | |
| 225 | const ctx = { | |
| 226 | app: child, | |
| 227 | request: new Request("https://x.test/"), | |
| 228 | auth: { user: reader.value }, | |
| 229 | } as unknown as Ctx; | |
| 230 | const shown = String(await clientApps(ctx, reader.value)); | |
| 231 | expect(shown).toContain('href="/marias/bakery"'); | |
| 232 | expect(shown).not.toContain("Ask for something"); | |
| 233 | const maria = getUserByName(child.db, "maria")!; | |
| 234 | expect(String(await clientApps(ctx, maria))).toContain("Ask for something"); | |
| 235 | } finally { | |
| 236 | child.db.query("UPDATE repo SET visibility = 'private' WHERE name = 'bakery'").run(); | |
| 237 | } | |
| 238 | }); | |
| 239 | ||
| 240 | test("the terminal's first screen asks for something with t, in the web's words", async () => { | |
| 241 | const maria = getUserByName(child.db, "maria")!; | |
| 242 | const terminal = headlessTerminal(120, 30); | |
| 243 | const { app } = createForgeApp( | |
| 244 | child, | |
| 245 | { sshUsername: "maria", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: maria }, | |
| 246 | { terminal, exitHooks: false }, | |
| 247 | ); | |
| 248 | app.start(); | |
| 249 | await settleApp(app); | |
| 250 | for (const k of ["t", ..."a rye loaf".split(""), "\r"]) { | |
| 251 | terminal.feed(k); | |
| 252 | await settleApp(app); | |
| 253 | } | |
| 254 | expect(app.screen.toText()).toContain("Sent. The agents will pick it up."); | |
| 255 | const told = child.db.query("SELECT COUNT(*) AS n FROM issue WHERE title LIKE '%rye loaf%'").get() as { n: number }; | |
| 256 | expect(told.n).toBeGreaterThan(0); | |
| 257 | app.stop(); | |
| 258 | }); | |
| 259 | ||
| 181 | 260 | test("a running agent turns the line to working", async () => { |
| 182 | 261 | const repo = child.db |
| 183 | 262 | .query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'bakery'") |
new above
offline
The button sits inline after the name with no row layout, so it will wrap oddly at 360px. Give it its own line under the agents sentence, or add the row to DESIGN.md §5 and align it.