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

Merged opened this

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

Merged

scout/forge:scout/3345-a-client-s-first-screen-offers-ask-for-s → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/3345-a-client-s-first-screen-offers-ask-for-s was merged into main as 8f2956f.

Review

Sign in to review this change.
9 files changed +141 −6
README.md +6
@@ -2023,6 +2023,12 @@ until it is re-created with `forge: ` in front of its message.
20232023
20242024 `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.
20252025
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
20262032 `GET /metrics` (site admin only; repository names are in the labels) serves
20272033 `forge_egress_connections`, `forge_egress_bytes_in` and `forge_egress_bytes_out`
20282034 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 {
6464 export const FROM_WORDS =
6565 "from is a path on this forge, like /alice/notes/pulls/3, at most 512 characters";
6666
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
6770 export type TellError =
6871 | "empty"
6972 | "bad_from"
src/tui/screens/dashboard.ts +11
@@ -23,6 +23,7 @@ import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_
2323 import { clientAppRows, clientDoings, doingLine } from "../../core/client-apps.ts";
2424 import { nextStepsFor } from "../../core/next-steps.ts";
2525 import { agentsLine } from "../../web/studio/client_home.ts";
26 import { askForSomething } from "./tell.ts";
2627 import { mayView } from "../../core/sealed.ts";
2728 import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts";
2829 import { plain } from "./boards.ts";
@@ -163,6 +164,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
163164 let waits: string[] = [];
164165 /** A client's apps, one sentence each on whether its agents work or wait (#1812). */
165166 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>();
166169 /** The check lane, as the board's strip says it; null when nothing is queued. */
167170 let lane: string | null = null;
168171 /** 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 {
186189 // A client's home never carries the forge-wide figures, on either door.
187190 figures = [];
188191 const apps = await clientAppRows(f.app, f.user);
192 clientRepoIds = new Set(apps.map((a) => a.repo.id));
189193 const now = new Date();
190194 const doings = clientDoings(f.app.db, apps.map((a) => a.repo.id), now);
191195 clientLines =
@@ -741,6 +745,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
741745 disabled: focus !== "machine" || pins.length === 0,
742746 }),
743747 binding(["C"], "decider", { disabled: !f.user }),
748 ...(clientLines.length > 0 ? [binding(["t"], "ask for something")] : []),
744749 binding(["n"], "new repo", { disabled: !f.signedIn }),
745750 binding(["M"], "migrate", { disabled: !f.signedIn }),
746751 binding(["A"], "account", { disabled: !f.signedIn }),
@@ -941,6 +946,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
941946 }
942947 return true;
943948 }
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 }
944955 if (e.key === "n" && f.signedIn) {
945956 ctx.push(newRepoScreen(f, ctx));
946957 return true;
src/tui/screens/tell.ts +21 −1
@@ -8,7 +8,7 @@
88 * the same `parseContext` and `contextNote` the pill's tell and the MCP use.
99 */
1010
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";
1212 import { contextNote, parseContext } from "../../actions/tell_context.ts";
1313 import type { Repo } from "../../core/repos.ts";
1414 import { DIRECTION_NOTE, mayDirectCrew } from "../../crew/direct.ts";
@@ -118,3 +118,23 @@ export async function tellFromPage(
118118 const answer = tellAnswer(await tell(f.app, f.user, repo, `${said}${contextNote(where)}`));
119119 ctx.toast(answer.text, answer.tone);
120120 }
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 {
431431 * the button tells the crew, and `from` (a path on this forge, or null)
432432 * rides along as a hidden field so the tell remembers the page it was
433433 * 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 };
435435 }
436436
437437 /** 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 {
516516 ${tell?.from
517517 ? html`<input type="hidden" name="from" value="${tell.from}" />`
518518 : null}
519 ${tell?.ask
520 ? html`<input type="hidden" name="ask" value="1" />`
521 : null}
519522 ${tell?.page
520523 ? html`<input type="hidden" name="page" value="${tell.page}" />`
521524 : null}
@@ -528,7 +531,7 @@ export function composer(ctx: Ctx, o: ComposerOptions): Html {
528531 <div class="form-actions">
529532 <span class="muted small"
530533 >${tell
531 ? html`${tell.from
534 ? html`${tell.from && !tell.ask
532535 ? html`From <code>${tell.from}</code>. `
533536 : null}${tell.page
534537 ? html`From <code>${tell.page}</code>. `
src/web/studio/actions.ts +4 −1
@@ -11,7 +11,7 @@
1111 import { mayView } from "../../core/sealed.ts";
1212 import { holdPull, releasePull } from "../../actions/holds.ts";
1313 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";
1515 import { contextNote, parseContext } from "../../actions/tell_context.ts";
1616 import { keepThought } from "../../actions/thought.ts";
1717 import { DIRECTION_NOTE } from "../../crew/direct.ts";
@@ -77,6 +77,9 @@ async function tellFrom(
7777 : "That could not be told.",
7878 });
7979 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 });
8083 if (routed === undefined)
8184 return flashOrJson(ctx, back, {
8285 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
1010 import { clientAppRows, clientDoings, doingLine } from "../../core/client-apps.ts";
1111 import type { User } from "../../core/users.ts";
1212 import type { Ctx } from "../../http/context.ts";
13 import { mayDirectCrew } from "../../crew/direct.ts";
1314 import { repoBase } from "../chrome.ts";
1415 import { html, type Html } from "../html.ts";
15 import { card } from "../ui.ts";
16 import { btn, card } from "../ui.ts";
1617
1718 /** The sentence about the agents, from how many are going and how many are lined up. */
1819 export function agentsLine(working: number, queued: number): string {
@@ -36,8 +37,15 @@ export async function clientApps(ctx: Ctx, user: User): Promise<Html> {
3637 const rows = apps.map(({ repo, working, queued }) => {
3738 const doing = doingLine(doings.get(repo.id), now.getTime());
3839 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

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.

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;
3947 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}
4149 <span class="client-app-about">${about}</span>
4250 <span class="client-app-agents muted" data-agents="${working > 0 ? "working" : "waiting"}"
4351 >${agentsLine(working, queued)}</span
src/web/studio/tell_here.ts +2
@@ -45,6 +45,8 @@ export function tellHerePage(ctx: Ctx): Response {
4545 from,
4646 rows: ctx.url.searchParams.get("in") === "place" ? 1 : 4,
4747 ...(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 } : {}),
4850 ...(where?.page === undefined ? {} : { page: where.page }),
4951 ...(where?.pull === undefined ? {} : { pull: where.pull }),
5052 },
test/client-home.test.ts +79
@@ -12,6 +12,8 @@ import { join } from "node:path";
1212 import { createRepo } from "../src/actions/repos.ts";
1313 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
1414 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";
1517 import { clientAppRows, cutTitle, doingLine } from "../src/core/client-apps.ts";
1618 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
1719 import { moveArchived } from "../src/core/repos.ts";
@@ -178,6 +180,83 @@ describe("a client's first screen", () => {
178180 expect(home).toContain('href="/?all=1"');
179181 });
180182
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=/&amp;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
181260 test("a running agent turns the line to working", async () => {
182261 const repo = child.db
183262 .query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'bakery'")

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs