Public: anyone can read this; the crew can change it.
A wave proposal is one inbox card: Approve opens its issues, Redirect re-plans from the owner's words, Later puts it aside #2023
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1622-a-wave-proposal-is-one-inbox-card-approv → main 5 commits fork 8 threads · 8 unresolved
README.md +2
| @@ -304,6 +304,8 @@ and `forge sync` below is what acts on it. | ||
| 304 | 304 | |
| 305 | 305 | **Muting a repository from the home**: each ask row on the home carries a "Mute owner/name" chip. A muted repository's asks leave the home, `/api/v1/asks`, the header count and the phone's pushes (the same per-user mute the On you card uses, no new table); the home says "N muted repositories still ask" with a "Listen again" button, and an app's own page still counts them. |
| 306 | 306 | |
| 307 | **A wave proposal is one card.** With "plans need a nod" on, a plan the planner writes is kept as a proposal and opens nothing. It is one row in the asks list (home, `/asks`, `/api/v1/asks`, MCP `asks`, the terminal's On you card) with its title, its why and its issue titles, all escaped, and it asks the app's owner only. Three taps, each one POST that leaves you on the page you were on: **Approve** opens the issues with their links, exactly as an unheld plan would; **Redirect** takes a text box and hands your words to the planner as a new run on the direction issue; **Later** hides it until you bring it back, from the "N waves put aside" line under the asks (each with a "Bring back" button). A proposal is decided once. The same four verbs (`approve`, `redirect`, `later`, `again`) are on every door, all calling `actions/proposals.ts`: the page (`/asks/proposal/<id>/<verb>`), the API (`POST /api/v1/asks/proposal/<id>/<verb>`, a `rw` token, `{"words": …}` for redirect), the MCP tool `asks_proposal` (`id`, `verb`, `words`), and in the terminal `y` approve, `R` redirect, `l` later and `B` bring every put-aside wave back, on a wave row of the On you card. | |
| 308 | ||
| 307 | 309 | **Chips on the feed**: kind chips switch a kind of post off: `reviews` (pulls), `decisions` (decisions and choose decks), `crew` (tells, replies, looks) and `releases`; a person chip switches one person or agent off. Saved per user, one filter (`chipHides`) behind the home feed, `/api/v1/feed`, the Asks list and the terminal's inbox. `/live/apps` (your apps) is not feed items, so chips do not apply there. `GET /api/v1/feed/chips` answers `{kinds, people}`; `POST /api/v1/feed/chips` takes `{"kind": "kind"|"person", "value": "reviews"|"<username>", "hidden": true|false}` (scope `rw`, same-origin). In the terminal dashboard `1`–`4` switch the kind chips `h` on an On you row switches its author off (not your own) and the author's rows leave the card, and `P` brings every person back. |
| 308 | 310 | |
| 309 | 311 | **On you, in the terminal**, answers to the same mutes and one-move verbs as |
scripts/gate.ts +1
| @@ -228,6 +228,7 @@ export const GATE: readonly string[] = [ | ||
| 228 | 228 | "test/asks.test.ts", |
| 229 | 229 | "test/review-wait.test.ts", |
| 230 | 230 | "test/asks-bulk.test.ts", |
| 231 | "test/proposal-card.test.ts", | |
| 231 | 232 | "test/asks-waiting-for.test.ts", |
| 232 | 233 | "test/asks-one-list.test.ts", |
| 233 | 234 | "test/hand-decider.test.ts", |
src/actions/proposals.ts added +164
| @@ -0,0 +1,164 @@ | ||
| 1 | /** | |
| 2 | * The four things a person does with a wave proposal (`core/proposals.ts`), | |
| 3 | * for every door (the web form, `POST /api/v1/asks/proposal/:id/<verb>`, | |
| 4 | * MCP's `asks_proposal` and the terminal): approve opens its issues exactly | |
| 5 | * as an unheld plan would, | |
| 6 | * redirect hands the owner's words to the planner as a new run, later puts | |
| 7 | * it aside, and again brings it back. Each takes the actor and the id, | |
| 8 | * checks the actor is who the card asks (the same predicate as the asks | |
| 9 | * list) and hands back plain data. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { App } from "../app.ts"; | |
| 13 | import { listCrewRoles } from "../core/agents.ts"; | |
| 14 | import { getProposal, moveProposal, type Proposal, type ProposalState } from "../core/proposals.ts"; | |
| 15 | import { getRepoById, ownerFactRepoIds, canRead, type Repo } from "../core/repos.ts"; | |
| 16 | import type { User } from "../core/users.ts"; | |
| 17 | import { ensureDirectionIssue } from "../crew/autonomy.ts"; | |
| 18 | import { queueAgentRun } from "../crew/dispatch.ts"; | |
| 19 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 20 | import { MAX_BODY } from "../core/issues.ts"; | |
| 21 | import { assignIssue, addComment } from "./issues.ts"; | |
| 22 | import { openPlan, parsePlan, type Planned } from "./plan.ts"; | |
| 23 | ||
| 24 | export interface ProposalError { | |
| 25 | readonly code: "unauthorized" | "not_found" | "forbidden" | "invalid" | "conflict"; | |
| 26 | readonly message: string; | |
| 27 | } | |
| 28 | ||
| 29 | const fail = (code: ProposalError["code"], message: string) => | |
| 30 | err<ProposalError>({ code, message }); | |
| 31 | ||
| 32 | /** An error from another action as a sentence, never "[object Object]". */ | |
| 33 | function errorWords(e: unknown): string { | |
| 34 | if (typeof e === "string") return e; | |
| 35 | const m = e !== null && typeof e === "object" ? (e as { message?: unknown }).message : undefined; | |
| 36 | return typeof m === "string" ? m : "it was refused"; | |
| 37 | } | |
| 38 | ||
| 39 | /** The proposal and its repository, when this person is who it asks and it is in one of `states`. */ | |
| 40 | function mine( | |
| 41 | app: App, | |
| 42 | actor: User | undefined, | |
| 43 | id: unknown, | |
| 44 | states: readonly ProposalState[], | |
| 45 | ): Result<{ proposal: Proposal; repo: Repo; actor: User }, ProposalError> { | |
| 46 | if (!actor) return fail("unauthorized", "sign in to act on your asks"); | |
| 47 | const proposal = typeof id === "string" ? getProposal(app.db, id) : undefined; | |
| 48 | const repo = proposal ? getRepoById(app.db, proposal.repoId) : undefined; | |
| 49 | if (!proposal || !repo || !canRead(app.db, repo, actor)) | |
| 50 | return fail("not_found", "no such proposal"); | |
| 51 | if (!ownerFactRepoIds(app.db, [repo], actor).has(repo.id)) | |
| 52 | return fail("forbidden", "a proposal is the owner's to decide"); | |
| 53 | if (!states.includes(proposal.state)) | |
| 54 | return fail("conflict", "it was already decided"); | |
| 55 | return ok({ proposal, repo, actor }); | |
| 56 | } | |
| 57 | ||
| 58 | /** Open the proposal's issues with their links, as `openPlan` does for a plan nobody held. */ | |
| 59 | export function approveProposal( | |
| 60 | app: App, | |
| 61 | actor: User | undefined, | |
| 62 | id: unknown, | |
| 63 | ): Result<Planned, ProposalError> { | |
| 64 | const found = mine(app, actor, id, ["pending", "later"]); | |
| 65 | if (!found.ok) return found; | |
| 66 | const { proposal, repo } = found.value; | |
| 67 | const items = parsePlan(proposal.issues); | |
| 68 | if (!items.ok) return fail("invalid", items.error.message); | |
| 69 | // Taken first, so two taps open the issues once. | |
| 70 | if (!moveProposal(app.db, proposal.id, ["pending", "later"], "approved")) | |
| 71 | return fail("conflict", "it was already decided"); | |
| 72 | const opened = openPlan(app, found.value.actor, repo, items.value); | |
| 73 | if (!opened.ok) { | |
| 74 | moveProposal(app.db, proposal.id, ["approved"], proposal.state); | |
| 75 | return fail("invalid", opened.error.message); | |
| 76 | } | |
| 77 | return ok(opened.value); | |
| 78 | } | |
| 79 | ||
| 80 | /** Hand the owner's words to the planner as a new run on the direction issue. */ | |
| 81 | export function redirectProposal( | |
| 82 | app: App, | |
| 83 | actor: User | undefined, | |
| 84 | id: unknown, | |
| 85 | words: unknown, | |
| 86 | ): Result<{ issue: number }, ProposalError> { | |
| 87 | const said = typeof words === "string" ? words.trim() : ""; | |
| 88 | if (said === "") return fail("invalid", "say what the planner should do instead"); | |
| 89 | if (said.length > MAX_BODY) return fail("invalid", `the words are over ${MAX_BODY} characters`); | |
| 90 | const found = mine(app, actor, id, ["pending", "later"]); | |
| 91 | if (!found.ok) return found; | |
| 92 | const { proposal, repo } = found.value; | |
| 93 | const planner = listCrewRoles(app.db, repo.id).find((c) => c.role === "planner")?.agent; | |
| 94 | if (planner === undefined) return fail("invalid", "no planner on this crew to hand it to"); | |
| 95 | const direction = ensureDirectionIssue(app, found.value.actor, repo); | |
Minor: this can create a direction issue before |
||
| 96 | if (!direction) return fail("invalid", "this app has no direction issue to plan from"); | |
| 97 | if (!moveProposal(app.db, proposal.id, ["pending", "later"], "redirected")) | |
| 98 | return fail("conflict", "it was already decided"); | |
| 99 | const said_ = addComment( | |
| 100 | app, | |
| 101 | found.value.actor, | |
| 102 | repo, | |
| 103 | direction, | |
| 104 | `Redirect: not the wave "${proposal.title}" as proposed.\n\n${said}`, | |
| 105 | ); | |
| 106 | if (!said_.ok) { | |
| 107 | moveProposal(app.db, proposal.id, ["redirected"], proposal.state); | |
| 108 | return fail("invalid", `could not write to the direction issue: ${errorWords(said_.error)}`); | |
| 109 | } | |
| 110 | if (direction.assigneeId === planner.user.id) { | |
| 111 | queueAgentRun(app, repo, direction, planner, "feedback", found.value.actor.id); | |
| 112 | } else { | |
| 113 | if (direction.assigneeId !== null) assignIssue(app, found.value.actor, repo, direction, null); | |
| 114 | assignIssue(app, found.value.actor, repo, direction, planner.user); | |
Minor: the result of |
||
| 115 | } | |
| 116 | return ok({ issue: direction.number }); | |
| 117 | } | |
| 118 | ||
| 119 | /** Hide the proposal until the owner brings it back. */ | |
| 120 | export function laterProposal( | |
| 121 | app: App, | |
| 122 | actor: User | undefined, | |
| 123 | id: unknown, | |
| 124 | ): Result<null, ProposalError> { | |
| 125 | const found = mine(app, actor, id, ["pending"]); | |
| 126 | if (!found.ok) return found; | |
| 127 | return moveProposal(app.db, found.value.proposal.id, ["pending"], "later") | |
| 128 | ? ok(null) | |
| 129 | : fail("conflict", "it was already decided"); | |
| 130 | } | |
| 131 | ||
| 132 | /** Bring a proposal put aside back among the asks. */ | |
| 133 | export function againProposal( | |
| 134 | app: App, | |
| 135 | actor: User | undefined, | |
| 136 | id: unknown, | |
| 137 | ): Result<null, ProposalError> { | |
| 138 | const found = mine(app, actor, id, ["later"]); | |
| 139 | if (!found.ok) return found; | |
| 140 | return moveProposal(app.db, found.value.proposal.id, ["later"], "pending") | |
| 141 | ? ok(null) | |
| 142 | : fail("conflict", "it was already decided"); | |
| 143 | } | |
| 144 | ||
| 145 | export const PROPOSAL_VERBS = ["approve", "redirect", "later", "again"] as const; | |
| 146 | export type ProposalVerb = (typeof PROPOSAL_VERBS)[number]; | |
| 147 | ||
| 148 | /** Whether a door's word names one of the four verbs. */ | |
| 149 | export const isProposalVerb = (v: unknown): v is ProposalVerb => | |
| 150 | typeof v === "string" && (PROPOSAL_VERBS as readonly string[]).includes(v); | |
| 151 | ||
| 152 | /** One verb on one proposal: what every door calls, so each does the same thing. */ | |
| 153 | export function actOnProposal( | |
| 154 | app: App, | |
| 155 | actor: User | undefined, | |
| 156 | verb: ProposalVerb, | |
| 157 | id: unknown, | |
| 158 | words?: unknown, | |
| 159 | ): Result<unknown, ProposalError> { | |
| 160 | if (verb === "approve") return approveProposal(app, actor, id); | |
| 161 | if (verb === "redirect") return redirectProposal(app, actor, id, words); | |
| 162 | if (verb === "later") return laterProposal(app, actor, id); | |
| 163 | return againProposal(app, actor, id); | |
| 164 | } |
src/core/migrations/20261016120000-proposal-state.ts added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | // a wave proposal is one inbox card (#1622): pending asks the owner, later | |
| 2 | // hides it until they open it again, approved and redirected are decided. | |
| 3 | export default ` | |
| 4 | ALTER TABLE plan_proposal ADD COLUMN state TEXT NOT NULL DEFAULT 'pending'; | |
| 5 | ALTER TABLE plan_proposal ADD COLUMN decided_at TEXT; | |
| 6 | `; |
src/core/proposals.ts added +99
| @@ -0,0 +1,99 @@ | ||
| 1 | /** | |
| 2 | * Wave proposals: a plan kept for a person's nod (`actions/plan.ts`), read | |
| 3 | * and moved between its states. Plain rows in, plain data out; the | |
| 4 | * actions in `actions/proposals.ts` own who may move one. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | ||
| 9 | export type ProposalState = "pending" | "later" | "approved" | "redirected"; | |
| 10 | ||
| 11 | export interface Proposal { | |
| 12 | readonly id: string; | |
| 13 | readonly repoId: string; | |
| 14 | readonly authorId: string; | |
| 15 | readonly title: string; | |
| 16 | readonly why: string; | |
| 17 | /** The plan's items as stored JSON; `parsePlan` checks them again before use. */ | |
| 18 | readonly issues: unknown; | |
| 19 | readonly state: ProposalState; | |
| 20 | readonly createdAt: string; | |
| 21 | } | |
| 22 | ||
| 23 | interface Row { | |
| 24 | id: string; | |
| 25 | repo_id: string; | |
| 26 | author_id: string; | |
| 27 | title: string; | |
| 28 | why: string; | |
| 29 | issues: string; | |
| 30 | state: string; | |
| 31 | created_at: string; | |
| 32 | } | |
| 33 | ||
| 34 | const toProposal = (r: Row): Proposal => { | |
| 35 | let issues: unknown = []; | |
| 36 | try { | |
| 37 | issues = JSON.parse(r.issues); | |
| 38 | } catch { | |
| 39 | // A row we cannot read shows no issues and cannot be approved. | |
| 40 | } | |
| 41 | return { | |
| 42 | id: r.id, | |
| 43 | repoId: r.repo_id, | |
| 44 | authorId: r.author_id, | |
| 45 | title: r.title, | |
| 46 | why: r.why, | |
| 47 | issues, | |
| 48 | state: r.state as ProposalState, | |
| 49 | createdAt: r.created_at, | |
| 50 | }; | |
| 51 | }; | |
| 52 | ||
| 53 | export function getProposal(db: Database, id: string): Proposal | undefined { | |
| 54 | const row = db.query<Row, [string]>("SELECT * FROM plan_proposal WHERE id = ?1").get(id); | |
| 55 | return row ? toProposal(row) : undefined; | |
| 56 | } | |
| 57 | ||
| 58 | /** The proposals in `state` for these repositories, oldest first. */ | |
| 59 | export function proposalsFor( | |
| 60 | db: Database, | |
| 61 | repoIds: readonly string[], | |
| 62 | state: ProposalState, | |
| 63 | ): Proposal[] { | |
| 64 | if (repoIds.length === 0) return []; | |
| 65 | const marks = repoIds.map((_, i) => `?${i + 2}`).join(","); | |
| 66 | return db | |
| 67 | .query<Row, string[]>( | |
| 68 | `SELECT * FROM plan_proposal WHERE state = ?1 AND repo_id IN (${marks}) | |
| 69 | ORDER BY created_at, id`, | |
| 70 | ) | |
| 71 | .all(state, ...repoIds) | |
| 72 | .map(toProposal); | |
| 73 | } | |
| 74 | ||
| 75 | /** The titles of a proposal's issues, for a card and a row. */ | |
| 76 | export function issueTitles(p: Proposal): string[] { | |
| 77 | return Array.isArray(p.issues) | |
| 78 | ? p.issues | |
| 79 | .map((i) => (i !== null && typeof i === "object" ? (i as { title?: unknown }).title : undefined)) | |
| 80 | .filter((t): t is string => typeof t === "string") | |
| 81 | : []; | |
| 82 | } | |
| 83 | ||
| 84 | /** Move a proposal from one of `from` to `to`; false when it was not in one (someone moved it first). */ | |
| 85 | export function moveProposal( | |
| 86 | db: Database, | |
| 87 | id: string, | |
| 88 | from: readonly ProposalState[], | |
| 89 | to: ProposalState, | |
| 90 | ): boolean { | |
| 91 | const marks = from.map((_, i) => `?${i + 4}`).join(","); | |
| 92 | const done = db | |
| 93 | .query( | |
| 94 | `UPDATE plan_proposal SET state = ?1, decided_at = ?2 | |
| 95 | WHERE id = ?3 AND state IN (${marks})`, | |
| 96 | ) | |
| 97 | .run(to, new Date().toISOString(), id, ...from); | |
| 98 | return done.changes === 1; | |
| 99 | } |
src/core/waiting_for.ts +1 −1
| @@ -18,7 +18,7 @@ export interface WaitingFor { | ||
| 18 | 18 | |
| 19 | 19 | export interface WaitFacts { |
| 20 | 20 | /** The ask's kind (`DecisionKind`). */ |
| 21 | readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "setting"; | |
| 21 | readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal" | "setting"; | |
| 22 | 22 | /** Files stopping the pull from merging, as the last sweep found it. */ |
| 23 | 23 | readonly conflicts: number; |
| 24 | 24 | /** Whether the pull or issue the ask is about is assigned to the viewer. */ |
src/http/feed.ts +16
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | readFeed, |
| 30 | 30 | } from "../feed/feed.ts"; |
| 31 | 31 | import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts"; |
| 32 | import { actOnProposal, isProposalVerb } from "../actions/proposals.ts"; | |
| 32 | 33 | import { asksAnswer } from "../web/studio/asks.ts"; |
| 33 | 34 | import { |
| 34 | 35 | applyAsksBulk, |
| @@ -163,6 +164,21 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 163 | 164 | return json(undone.value, 201); |
| 164 | 165 | }); |
| 165 | 166 | |
| 167 | // A wave proposal's four verbs (`actions/proposals.ts`): approve, redirect (with `words`), later, again. | |
| 168 | router.add("POST", "/api/v1/asks/proposal/:id/:verb", async (ctx) => { | |
| 169 | const denied = requireScope(ctx, "rw"); | |
| 170 | if (denied) return denied; | |
| 171 | const verb = ctx.params.verb; | |
| 172 | if (!isProposalVerb(verb)) return notFound(); | |
| 173 | const input = await body(ctx.request); | |
| 174 | const done = actOnProposal(ctx.app, ctx.auth.user, verb, ctx.params.id, input.words); | |
| 175 | if (done.ok) return json(done.value ?? { ok: true }, 201); | |
| 176 | const { code, message } = done.error; | |
| 177 | if (code === "not_found") return notFound(); | |
| 178 | if (code === "unauthorized") return unauthorized(); | |
| 179 | return apiError(code, code === "forbidden" ? 403 : code === "conflict" ? 409 : 422, message); | |
| 180 | }); | |
| 181 | ||
| 166 | 182 | router.add("POST", "/api/v1/feed/interactions", async (ctx) => { |
| 167 | 183 | const denied = requireScope(ctx, "rw"); |
| 168 | 184 | if (denied) return denied; |
src/http/mcp.ts +22
| @@ -121,6 +121,7 @@ import { | ||
| 121 | 121 | } from "../web/pages/milestones.ts"; |
| 122 | 122 | import { appsOf, asksAnswer } from "../web/studio/asks.ts"; |
| 123 | 123 | import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts"; |
| 124 | import { actOnProposal, isProposalVerb, PROPOSAL_VERBS } from "../actions/proposals.ts"; | |
| 124 | 125 | import { applyAsksBulk, previewAsksBulk } from "../actions/asks_bulk.ts"; |
| 125 | 126 | import type { Ctx, Handler } from "./context.ts"; |
| 126 | 127 | import { |
| @@ -633,6 +634,27 @@ const TOOLS: readonly Tool[] = [ | ||
| 633 | 634 | return done.ok ? good(done.value) : bad(done.error.code, done.error.message); |
| 634 | 635 | }, |
| 635 | 636 | }, |
| 637 | { | |
| 638 | name: "asks_proposal", | |
| 639 | description: | |
| 640 | "Act on a wave proposal (an `asks` row with a `proposal`; `id` is its `proposal.id`). `approve` opens its issues with their links, exactly as an unheld plan would; `redirect` hands your `words` to the planner as a new run; `later` hides it until `again` brings it back. Only the app's owner may; a proposal is decided once.", | |
| 641 | inputSchema: { | |
| 642 | type: "object", | |
| 643 | properties: { | |
| 644 | id: { type: "string", maxLength: 80 }, | |
| 645 | verb: { type: "string", enum: [...PROPOSAL_VERBS] }, | |
| 646 | words: { type: "string", maxLength: 65536 }, | |
| 647 | }, | |
| 648 | required: ["id", "verb"], | |
| 649 | }, | |
| 650 | writes: true, | |
| 651 | run(ctx, user, args) { | |
| 652 | const verb = args["verb"]; | |
| 653 | if (!isProposalVerb(verb)) return bad("invalid", `verb is ${PROPOSAL_VERBS.join(", ")}`); | |
| 654 | const done = actOnProposal(ctx.app, user, verb, args["id"], args["words"]); | |
| 655 | return done.ok ? good(done.value ?? { ok: true }) : bad(done.error.code, done.error.message); | |
| 656 | }, | |
| 657 | }, | |
| 636 | 658 | { |
| 637 | 659 | name: "asks_undecide", |
| 638 | 660 | description: |
src/http/server.ts +5
| @@ -220,6 +220,7 @@ import { | ||
| 220 | 220 | asksBulkSubmit, |
| 221 | 221 | asksBulkUndo, |
| 222 | 222 | asksDecide, |
| 223 | asksProposal, | |
| 223 | 224 | asksSetting, |
| 224 | 225 | asksDecideUndo, |
| 225 | 226 | asksListen, |
| @@ -620,6 +621,10 @@ export function buildRouter(): Router<Handler> { | ||
| 620 | 621 | router.add("POST", "/asks/bulk/:id/undo", asksBulkUndo); |
| 621 | 622 | router.add("POST", "/asks/listen", asksListen); |
| 622 | 623 | router.add("POST", "/asks/mute", asksMute); |
| 624 | router.add("POST", "/asks/proposal/:id/approve", asksProposal("approve")); | |
| 625 | router.add("POST", "/asks/proposal/:id/redirect", asksProposal("redirect")); | |
| 626 | router.add("POST", "/asks/proposal/:id/later", asksProposal("later")); | |
| 627 | router.add("POST", "/asks/proposal/:id/again", asksProposal("again")); | |
| 623 | 628 | router.add("POST", "/asks/decide", asksDecide); |
| 624 | 629 | router.add("POST", "/asks/setting", asksSetting); |
| 625 | 630 | router.add("POST", "/asks/decide/:id/undo", asksDecideUndo); |
src/tui/screens/dashboard.ts +88 −5
| @@ -22,10 +22,14 @@ import { nextStepsFor } from "../../core/next-steps.ts"; | ||
| 22 | 22 | import { agentsLine } from "../../web/studio/client_home.ts"; |
| 23 | 23 | import { mayView } from "../../core/sealed.ts"; |
| 24 | 24 | import { pinRepo, unpinRepo } from "../../actions/origins.ts"; |
| 25 | import { getProposal } from "../../core/proposals.ts"; | |
| 25 | 26 | import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts"; |
| 26 | 27 | import { askLinesFor } from "../../web/studio/asks.ts"; |
| 27 | 28 | import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts"; |
| 28 | 29 | import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts"; |
| 30 | import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts"; | |
| 31 | import { putAsideWaves } from "../../web/studio/asks.ts"; | |
| 32 | import type { Proposal } from "../../core/proposals.ts"; | |
| 29 | 33 | import { answerSetting, openFindings } from "../../actions/setting_ask.ts"; |
| 30 | 34 | import { |
| 31 | 35 | applyAsksBulk, |
| @@ -118,6 +122,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 118 | 122 | let pins: Pin[] = []; |
| 119 | 123 | let pinned = new Set<string>(); |
| 120 | 124 | let onYou: OnYouItem[] = []; |
| 125 | /** Wave proposals that wait for this person's nod, listed after the rows above. */ | |
| 126 | let waves: { proposal: Proposal; repo: Repo }[] = []; | |
| 127 | /** Waves put aside with Later, which `B` brings back. */ | |
| 128 | let asideWaves: Proposal[] = []; | |
| 121 | 129 | /** Repositories the viewer muted that hold work assigned to them: said, never silent. */ |
| 122 | 130 | let silenced = new Map<string, Repo>(); |
| 123 | 131 | /** The rows marked for one verb, by issue id. */ |
| @@ -161,6 +169,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 161 | 169 | pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>(); |
| 162 | 170 | all = listReposVisibleTo(f.db, f.user, { limit: 200 }); |
| 163 | 171 | onYou = assignedToMe(); |
| 172 | waves = f.user === undefined ? [] : wavesFor(); | |
| 173 | asideWaves = f.user === undefined ? [] : putAsideWaves(f.db, f.user); | |
| 164 | 174 | for (const id of [...marked]) |
| 165 | 175 | if (!onYou.some((item) => item.issue.id === id)) marked.delete(id); |
| 166 | 176 | lastSet = f.user ? latestUndoable(f.app, f.user) : undefined; |
| @@ -274,6 +284,60 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 274 | 284 | return out; |
| 275 | 285 | }; |
| 276 | 286 | |
| 287 | /** The wave proposals that ask this person, as the web's asks list has them. */ | |
| 288 | const wavesFor = (): { proposal: Proposal; repo: Repo }[] => { | |
| 289 | if (f.user === undefined) return []; | |
| 290 | const out: { proposal: Proposal; repo: Repo }[] = []; | |
| 291 | for (const row of askLinesFor(f.db, f.user)) { | |
| 292 | if (row.proposal === undefined) continue; | |
| 293 | const proposal = getProposal(f.db, row.proposal.id); | |
| 294 | const repo = proposal === undefined ? undefined : getRepoById(f.db, proposal.repoId); | |
| 295 | if (proposal !== undefined && repo !== undefined) out.push({ proposal, repo }); | |
| 296 | } | |
| 297 | return out; | |
| 298 | }; | |
| 299 | ||
| 300 | /** The wave under the cursor, when the cursor is past the issues and pulls. */ | |
| 301 | const waveHere = (): { proposal: Proposal; repo: Repo } | undefined => | |
| 302 | waves[at("onyou").index - onYou.length]; | |
| 303 | ||
| 304 | /** One verb on the wave under the cursor, through the action every door uses. */ | |
| 305 | const actOnWave = (verb: ProposalVerb, words?: string): void => { | |
| 306 | const here = waveHere(); | |
| 307 | if (here === undefined) { | |
| 308 | ctx.toast("only a wave proposal takes this", "attn"); | |
| 309 | return; | |
| 310 | } | |
| 311 | const done = actOnProposal(f.app, f.user, verb, here.proposal.id, words); | |
| 312 | reload(); | |
| 313 | ctx.toast( | |
| 314 | done.ok ? (verb === "approve" ? "issues opened" : verb === "later" ? "put aside · B brings it back" : "sent to the planner") : done.error.message, | |
| 315 | done.ok ? "success" : "danger", | |
| 316 | ); | |
| 317 | }; | |
| 318 | ||
| 319 | const redirectWave = (): void => { | |
| 320 | if (waveHere() === undefined) return actOnWave("redirect"); | |
| 321 | ctx.push( | |
| 322 | promptScreen(ctx, screen, { | |
| 323 | title: "Redirect the planner", | |
| 324 | label: "Plan this instead", | |
| 325 | hint: "Your words go to the planner as a new run.", | |
| 326 | onSubmit: (value) => { | |
| 327 | actOnWave("redirect", value); | |
| 328 | return undefined; | |
| 329 | }, | |
| 330 | }), | |
| 331 | ); | |
| 332 | }; | |
| 333 | ||
| 334 | const bringBackWaves = (): void => { | |
| 335 | let n = 0; | |
| 336 | for (const w of asideWaves) if (actOnProposal(f.app, f.user, "again", w.id).ok) n++; | |
| 337 | reload(); | |
| 338 | ctx.toast(`${n} ${n === 1 ? "wave" : "waves"} back`, "success"); | |
| 339 | }; | |
| 340 | ||
| 277 | 341 | /** The marked rows, or the one under the cursor when none is marked. */ |
| 278 | 342 | const targets = (): OnYouItem[] => { |
| 279 | 343 | const mine = onYou.filter((item) => item.hand !== true && item.setting === undefined); |
| @@ -474,7 +538,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 474 | 538 | pane === "repos" |
| 475 | 539 | ? shown().length |
| 476 | 540 | : pane === "onyou" |
| 477 | ? onYou.length | |
| 541 | ? onYou.length + waves.length | |
| 478 | 542 | : pins.length; |
| 479 | 543 | |
| 480 | 544 | const cycle = (step: number): void => { |
| @@ -557,6 +621,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 557 | 621 | ...(focus === "onyou" && onYou[at("onyou").index]?.hand === true |
| 558 | 622 | ? [binding(["D"], "let the decider")] |
| 559 | 623 | : []), |
| 624 | ...(focus === "onyou" && waveHere() !== undefined | |
| 625 | ? [binding(["y"], "approve wave"), binding(["R"], "redirect"), binding(["l"], "later")] | |
| 626 | : []), | |
| 627 | ...(asideWaves.length > 0 && !(focus === "onyou" && onYou[at("onyou").index]?.setting !== undefined) | |
outdated
With a setting card under the cursor, this shows next to "B put back", but |
||
| 628 | ? [binding(["B"], "bring waves back")] | |
| 629 | : []), | |
| 560 | 630 | ...(focus === "onyou" && onYou[at("onyou").index]?.setting !== undefined |
| 561 | 631 | ? [binding(["K"], "keep setting"), binding(["B"], "put back")] |
| 562 | 632 | : []), |
| @@ -707,9 +777,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 707 | 777 | if (e.key === "S") return shipSet(), true; |
| 708 | 778 | if (e.key === "D" && item !== undefined) return handToDecider(), true; |
| 709 | 779 | if (e.key === "K") return answerHere("keep"), true; |
| 710 | if (e.key === "B") return answerHere("put"), true; | |
| 780 | if (e.key === "B" && item?.setting !== undefined) return answerHere("put"), true; | |
| 711 | 781 | if (e.key === "h") return hideAuthor(), true; |
| 782 | if (e.key === "y") return actOnWave("approve"), true; | |
| 783 | if (e.key === "R") return redirectWave(), true; | |
| 784 | if (e.key === "l") return actOnWave("later"), true; | |
outdated
This catches |
||
| 712 | 785 | } |
| 786 | if (e.key === "B" && f.user && asideWaves.length > 0) return bringBackWaves(), true; | |
| 713 | 787 | if (e.key === "u" && f.user) return undoSet(), true; |
| 714 | 788 | if (e.key === "U" && f.user) return undoHand(), true; |
| 715 | 789 | if (e.key === "L" && f.user) return listenAgain(), true; |
| @@ -766,7 +840,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 766 | 840 | focused: on, |
| 767 | 841 | ...(footer === undefined ? {} : { footer }), |
| 768 | 842 | }); |
| 769 | if (onYou.length === 0) { | |
| 843 | if (onYou.length + waves.length === 0) { | |
| 770 | 844 | note(s, inner, c, [ |
| 771 | 845 | silenced.size > 0 |
| 772 | 846 | ? "Nothing is on you outside muted repositories." |
| @@ -779,7 +853,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 779 | 853 | inner, |
| 780 | 854 | c, |
| 781 | 855 | at("onyou"), |
| 782 | onYou.map((item): ListRow => { | |
| 856 | [ | |
| 857 | ...onYou.map((item): ListRow => { | |
| 783 | 858 | const mark = |
| 784 | 859 | set === "nerd" |
| 785 | 860 | ? icon(item.issue.kind === "pull" ? "pull" : "issue", set) |
| @@ -791,9 +866,16 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 791 | 866 | meta: `${item.waiting.word} · on ${item.waiting.on}`, |
| 792 | 867 | }; |
| 793 | 868 | }), |
| 869 | ...waves.map( | |
| 870 | (w): ListRow => ({ | |
| 871 | title: `wave ${w.proposal.title} · ${w.repo.ownerName}/${w.repo.name}`, | |
| 872 | meta: "waits for your nod · y approve · R redirect · l later", | |
| 873 | }), | |
| 874 | ), | |
| 875 | ], | |
| 794 | 876 | on, |
| 795 | 877 | ); |
| 796 | hitList(ctx, inner, at("onyou"), onYou.length, { | |
| 878 | hitList(ctx, inner, at("onyou"), onYou.length + waves.length, { | |
| 797 | 879 | select: () => { |
| 798 | 880 | focus = "onyou"; |
| 799 | 881 | }, |
| @@ -825,6 +907,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 825 | 907 | if (lastSet !== undefined) parts.push(`u undo ${lastSet.verb}`); |
| 826 | 908 | if (lastHand !== undefined) parts.push("U undo decider"); |
| 827 | 909 | if (onYou[at("onyou").index]?.hand === true) parts.push("D let the decider"); |
| 910 | if (asideWaves.length > 0) parts.push(`${asideWaves.length} ${asideWaves.length === 1 ? "wave" : "waves"} put aside · B bring back`); | |
| 828 | 911 | return parts.length > 0 ? parts.join(" · ") : undefined; |
| 829 | 912 | } |
| 830 | 913 |
src/web/studio/asks.ts +78 −2
| @@ -18,6 +18,7 @@ import { appsRuntime } from "../../apps/runtime.ts"; | ||
| 18 | 18 | import { appsForRepos, getAppByRepo, listApps } from "../../core/apps.ts"; |
| 19 | 19 | import { latestDecision, stopStands } from "../../core/decisions.ts"; |
| 20 | 20 | import { getHold, holdsFor } from "../../core/holds.ts"; |
| 21 | import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.ts"; | |
| 21 | 22 | import { chipsOf } from "../../core/feed_chips.ts"; |
| 22 | 23 | import { chipHides } from "../../feed/chips.ts"; |
| 23 | 24 | import { askDismissals, mutedRepos } from "../../core/interactions.ts"; |
| @@ -106,7 +107,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) { | ||
| 106 | 107 | ); |
| 107 | 108 | } |
| 108 | 109 | |
| 109 | export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "setting"; | |
| 110 | export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal" | "setting"; | |
| 110 | 111 | |
| 111 | 112 | /** Dollars as a person reads them: cents when small, whole when not. */ |
| 112 | 113 | const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0)); |
| @@ -121,6 +122,8 @@ export interface Decision { | ||
| 121 | 122 | /** For "parked": the issue the breaker stopped; for "declined": the |
| 122 | 123 | * direction issue the planner declined. */ |
| 123 | 124 | readonly issue?: Issue | undefined; |
| 125 | /** For "proposal": the wave waiting for a nod. */ | |
| 126 | readonly proposal?: Proposal | undefined; | |
| 124 | 127 | readonly who: string; |
| 125 | 128 | /** Three sentences at most: what, why, what happens next. */ |
| 126 | 129 | readonly says: string; |
| @@ -190,6 +193,11 @@ export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = { | ||
| 190 | 193 | options: ["Open it"], |
| 191 | 194 | ifNothing: "it stays assigned to you", |
| 192 | 195 | }, |
| 196 | proposal: { | |
| 197 | decision: "Open this wave of issues?", | |
| 198 | options: ["Approve", "Redirect", "Later"], | |
| 199 | ifNothing: "it stays proposed; no issue is opened", | |
| 200 | }, | |
| 193 | 201 | setting: { |
| 194 | 202 | decision: "A setting is away from its default: keep it, or put it back?", |
| 195 | 203 | options: ["Keep", "Put back"], |
| @@ -215,6 +223,7 @@ export interface Consequence { | ||
| 215 | 223 | |
| 216 | 224 | /** The tier of a kind when nothing about the thing itself says more. */ |
| 217 | 225 | export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = { |
| 226 | proposal: { score: 60, why: "a wave of work waits for your nod" }, | |
| 218 | 227 | paused: { score: 80, why: "money: the crew is stopped at its ceiling" }, |
| 219 | 228 | declined: { score: 70, why: "the planner has stopped for this app" }, |
| 220 | 229 | stopped: { score: 50, why: "red, and the crew stopped" }, |
| @@ -252,6 +261,8 @@ interface AskCandidate { | ||
| 252 | 261 | readonly repo: Repo; |
| 253 | 262 | readonly pull?: Pull; |
| 254 | 263 | readonly issue?: Issue; |
| 264 | /** For "proposal": the wave waiting for a nod. */ | |
| 265 | readonly proposal?: Proposal; | |
| 255 | 266 | readonly who: string; |
| 256 | 267 | /** Whether the author is a crew member — a "look" card words itself |
| 257 | 268 | * differently for a person's own change than for the crew's (below). |
| @@ -459,6 +470,16 @@ function askCandidatesFor( | ||
| 459 | 470 | who: "the planner", |
| 460 | 471 | decline: { runId: d.runId, words: d.words }, |
| 461 | 472 | }); |
| 473 | // A wave the planner proposed waits for the owner's nod: one card each. | |
| 474 | for (const proposal of proposalsFor(db, repos.map((r) => r.id), "pending")) | |
| 475 | out.push({ | |
| 476 | kind: "proposal", | |
| 477 | id: `proposal:${proposal.id}`, | |
| 478 | since: proposal.createdAt, | |
| 479 | repo: byId.get(proposal.repoId)!, | |
| 480 | proposal, | |
| 481 | who: getUserById(db, proposal.authorId)?.username ?? "the planner", | |
| 482 | }); | |
| 462 | 483 | // A setting the sentinel found away from its default is the owner's to keep |
| 463 | 484 | // or put back, never a crew run: one ask per key, from its newest open finding. |
| 464 | 485 | if (user.isAdmin) |
| @@ -572,7 +593,7 @@ function consequencesOf( | ||
| 572 | 593 | /** The row an ask collapses into: one kind in one repository. */ |
| 573 | 594 | const groupId = (a: { kind: DecisionKind; repo: Repo; id: string }): string => |
| 574 | 595 | // Work assigned to you is each its own row: they share no decision. |
| 575 | a.kind === "assigned" || a.kind === "setting" ? a.id : `${a.kind}:${a.repo.id}`; | |
| 596 | a.kind === "assigned" || a.kind === "proposal" || a.kind === "setting" ? a.id : `${a.kind}:${a.repo.id}`; | |
| 576 | 597 | |
| 577 | 598 | /** |
| 578 | 599 | * The asks the viewer has not set aside: a muted repository's asks are all |
| @@ -731,6 +752,13 @@ function scoredFor( | ||
| 731 | 752 | })); |
| 732 | 753 | } |
| 733 | 754 | |
| 755 | /** The waves the viewer put aside with Later, oldest first, for the line that brings them back. */ | |
| 756 | export function putAsideWaves(db: Database, user: User): Proposal[] { | |
| 757 | const repos = listReposVisibleTo(db, user, { limit: 200 }); | |
| 758 | const mine = ownerFactRepoIds(db, repos, user); | |
| 759 | return proposalsFor(db, repos.filter((r) => mine.has(r.id)).map((r) => r.id), "later"); | |
| 760 | } | |
| 761 | ||
| 734 | 762 | /** |
| 735 | 763 | * The viewer's muted repositories that hold asks right now: said on the |
| 736 | 764 | * home, never silent, so a mute is always one tap from being taken back. |
| @@ -873,6 +901,8 @@ function lineOf(c: AskCandidate): string { | ||
| 873 | 901 | return `"${c.pull?.title ?? c.issue?.title ?? ""}" is assigned to you`; |
| 874 | 902 | if (c.kind === "parked") |
| 875 | 903 | return `the crew parked #${c.issue!.number} after three failed runs`; |
| 904 | if (c.kind === "proposal") | |
| 905 | return `proposes "${c.proposal?.title ?? ""}" and waits for your nod`; | |
| 876 | 906 | if (c.kind === "declined") |
| 877 | 907 | return "the planner declined the direction and waits for you"; |
| 878 | 908 | if (c.kind === "setting" && c.setting) |
| @@ -905,6 +935,7 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> = | ||
| 905 | 935 | parked: (n) => `the crew parked ${n} issues after three failed runs`, |
| 906 | 936 | handover: (n) => `${n} changes had three reviews asking for changes and wait for you`, |
| 907 | 937 | declined: (n) => `the planner declined ${n} times and waits for you`, |
| 938 | proposal: (n) => `${n} waves wait for your nod`, | |
| 908 | 939 | paused: (n) => `${n} pauses at the ceiling`, |
| 909 | 940 | setting: (n) => `${n} settings are away from their defaults`, |
| 910 | 941 | assigned: (n) => `${n} things assigned to you`, |
| @@ -937,6 +968,8 @@ export function askLinesFor( | ||
| 937 | 968 | link: string; |
| 938 | 969 | /** The pull's or issue's number, for a row of one that is about one. */ |
| 939 | 970 | number?: number; |
| 971 | /** For a wave proposal: what it says, as plain text (the reader escapes it). */ | |
| 972 | proposal?: { id: string; title: string; why: string; issues: string[] }; | |
| 940 | 973 | items: string[]; |
| 941 | 974 | /** The decision, its options and what happens if nobody acts (`ASK_WORDS`). */ |
| 942 | 975 | words: AskWords; |
| @@ -968,6 +1001,18 @@ export function askLinesFor( | ||
| 968 | 1001 | const line = `${row.repo.name}${only.kind === "paused" ? " " : ": "}${lineOf(only)}`; |
| 969 | 1002 | if (only.kind === "paused") |
| 970 | 1003 | return { ...about, line, link: `${base}/settings/autonomy` }; |
| 1004 | if (only.proposal !== undefined) | |
| 1005 | return { | |
| 1006 | ...about, | |
| 1007 | line, | |
| 1008 | link: `/#${askAnchor(only.id)}`, | |
| 1009 | proposal: { | |
| 1010 | id: only.proposal.id, | |
| 1011 | title: only.proposal.title, | |
| 1012 | why: only.proposal.why, | |
| 1013 | issues: issueTitles(only.proposal), | |
| 1014 | }, | |
| 1015 | }; | |
| 971 | 1016 | if (only.pull === undefined && only.issue !== undefined) |
| 972 | 1017 | return { |
| 973 | 1018 | ...about, |
| @@ -1032,6 +1077,16 @@ export function decisionsFor( | ||
| 1032 | 1077 | short: `assigned to you · updated ${thing.updatedAt.slice(0, 10)}`, |
| 1033 | 1078 | }; |
| 1034 | 1079 | } |
| 1080 | if (c.kind === "proposal" && c.proposal) | |
| 1081 | return { | |
| 1082 | kind: "proposal", | |
| 1083 | id: `proposal:${c.proposal.id}`, | |
| 1084 | repo: c.repo, | |
| 1085 | proposal: c.proposal, | |
| 1086 | who: c.who, | |
| 1087 | says: `${c.who} proposes "${c.proposal.title}" (${issueTitles(c.proposal).length} issues). Approve opens them, Redirect hands your words to the planner, Later puts it aside.`, | |
| 1088 | short: `proposes "${c.proposal.title}"`, | |
| 1089 | }; | |
| 1035 | 1090 | if (c.kind === "parked" && c.issue) |
| 1036 | 1091 | return { |
| 1037 | 1092 | kind: "parked", |
| @@ -1151,6 +1206,7 @@ export function decisionForm(d: Decision, back?: string): Html | null { | ||
| 1151 | 1206 | return html`<form ${own} action="/asks/decide"> |
| 1152 | 1207 | <input type="hidden" name="ask" value="${d.id}" /> |
| 1153 | 1208 | </form>`; |
| 1209 | if (d.kind === "proposal" && d.proposal) return proposalForms(d, back); | |
| 1154 | 1210 | if (d.kind === "setting" && d.setting) |
| 1155 | 1211 | return html`<form ${own}-keep action="/asks/setting"> |
| 1156 | 1212 | <input type="hidden" name="key" value="${d.setting.key}" /> |
| @@ -1227,6 +1283,11 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others: | ||
| 1227 | 1283 | }), |
| 1228 | 1284 | others: [], |
| 1229 | 1285 | }; |
| 1286 | if (d.kind === "proposal" && d.proposal) | |
| 1287 | return { | |
| 1288 | primary: btn("Approve", { size: "sm", form: askFormId(d), variant: "primary" }), | |
| 1289 | others: [btn("Later", { size: "sm", form: `${askFormId(d)}-later` })], | |
| 1290 | }; | |
| 1230 | 1291 | if (d.kind === "setting" && d.setting) |
| 1231 | 1292 | return { |
| 1232 | 1293 | primary: btn("Keep", { size: "sm", form: `${askFormId(d)}-keep`, variant: "primary" }), |
| @@ -1393,9 +1454,24 @@ export function askTargetsFor( | ||
| 1393 | 1454 | else if (ask === undefined) refused.push({ item, reason: "it no longer asks you" }); |
| 1394 | 1455 | else if (ask.kind === "paused") |
| 1395 | 1456 | refused.push({ item, reason: "a ceiling is raised in the app's settings" }); |
| 1457 | else if (ask.kind === "proposal") | |
| 1458 | refused.push({ item, reason: "a proposal is approved, redirected or put aside on its card" }); | |
| 1396 | 1459 | else if (ask.kind === "declined") |
| 1397 | 1460 | refused.push({ item, reason: "a planner's decline is answered on its issue" }); |
| 1398 | 1461 | else issueIds.push((ask.pull ?? ask.issue)!.id); |
| 1399 | 1462 | } |
| 1400 | 1463 | return { issueIds: [...new Set(issueIds)], refused }; |
| 1401 | 1464 | } |
| 1465 | ||
| 1466 | /** | |
| 1467 | * The forms behind a proposal card: Approve, Later and Redirect, each one | |
| 1468 | * POST to `/asks/proposal/:id/<verb>` that lands back on the page it came | |
| 1469 | * from. They sit apart from the card's own form, as forms do not nest. | |
| 1470 | */ | |
| 1471 | function proposalForms(d: Decision, back?: string): Html { | |
| 1472 | const id = askFormId(d); | |
| 1473 | const post = (verb: string) => `/asks/proposal/${d.proposal!.id}/${verb}`; | |
| 1474 | const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`; | |
| 1475 | return html`<form id="${id}" method="post" action="${post("approve")}" hidden>${where}</form> | |
| 1476 | <form id="${id}-later" method="post" action="${post("later")}" hidden>${where}</form>`; | |
| 1477 | } |
src/web/studio/asks_page.ts +60 −4
| @@ -29,7 +29,10 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 29 | 29 | import type { User } from "../../core/users.ts"; |
| 30 | 30 | import { onYouAnswer, studioHome } from "./home.ts"; |
| 31 | 31 | import { notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 32 | import { field, readForm } from "../form.ts"; | |
| 32 | import { backTo, field, readForm } from "../form.ts"; | |
| 33 | import { MAX_BODY } from "../../core/issues.ts"; | |
| 34 | import { issueTitles } from "../../core/proposals.ts"; | |
| 35 | import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts"; | |
| 33 | 36 | import { |
| 34 | 37 | canRead, |
| 35 | 38 | getRepoById, |
| @@ -50,6 +53,7 @@ import { | ||
| 50 | 53 | decisionsFor, |
| 51 | 54 | forgeNeedsFor, |
| 52 | 55 | silencedRepos, |
| 56 | putAsideWaves, | |
| 53 | 57 | type AskRow, |
| 54 | 58 | type Consequence, |
| 55 | 59 | type Decision, |
| @@ -168,6 +172,7 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = { | ||
| 168 | 172 | handover: "Handed over", |
| 169 | 173 | parked: "Parked", |
| 170 | 174 | declined: "Declined", |
| 175 | proposal: "Proposed", | |
| 171 | 176 | paused: "Paused", |
| 172 | 177 | assigned: "Assigned", |
| 173 | 178 | setting: "Setting away", |
| @@ -191,15 +196,33 @@ function acts(ctx: Ctx, d: Decision): Html { | ||
| 191 | 196 | </div>`; |
| 192 | 197 | } |
| 193 | 198 | |
| 199 | /** A wave proposal's why, its issue titles and the Redirect box; nothing for any other ask. */ | |
| 200 | function proposalBody(ctx: Ctx, d: Decision): Html | null { | |
| 201 | if (!d.proposal) return null; | |
| 202 | return html`<p class="small">${d.proposal.why}</p> | |
| 203 | <ul class="small"> | |
| 204 | ${issueTitles(d.proposal).map((t) => html`<li>${t}</li>`)} | |
| 205 | </ul> | |
| 206 | <form method="post" action="/asks/proposal/${d.proposal.id}/redirect" class="bar"> | |
| 207 | <input type="hidden" name="back" value="${ctx.url.pathname}" /> | |
| 208 | <label class="sr-only" for="redirect-${d.proposal.id}">Redirect the planner</label> | |
| 209 | <textarea id="redirect-${d.proposal.id}" name="words" rows="2" maxlength="${MAX_BODY}" required placeholder="Plan this instead…"></textarea> | |
| 210 | ${btn("Redirect", { size: "sm" })} | |
| 211 | </form>`; | |
| 212 | } | |
| 213 | ||
| 194 | 214 | function decisionCard(ctx: Ctx, d: Decision, picked: ReadonlySet<string>): Html { |
| 195 | 215 | const base = repoBase(d.repo); |
| 196 | 216 | const title = d.pull |
| 197 | 217 | ? html`<a href="${changeHref(base, d.pull.number)}">${d.pull.title}</a>` |
| 198 | 218 | : d.issue |
| 199 | 219 | ? html`<a href="${base}/issues/${d.issue.number}">${d.issue.title}</a>` |
| 200 | : html`<a href="${base}/settings/autonomy">Paused</a>`; | |
| 220 | : d.proposal | |
| 221 | ? html`${d.proposal.title}` | |
| 222 | : html`<a href="${base}/settings/autonomy">Paused</a>`; | |
| 201 | 223 | return card( |
| 202 | 224 | html`${clause(d, d.kind, d.consequence)} |
| 225 | ${proposalBody(ctx, d)} | |
| 203 | 226 | ${acts(ctx, d)}`, |
| 204 | 227 | { |
| 205 | 228 | id: askAnchor(d.id), |
| @@ -379,6 +402,22 @@ export function needsYouSection( | ||
| 379 | 402 | > |
| 380 | 403 | <button type="submit" class="btn btn-sm btn-ghost">Listen again</button> |
| 381 | 404 | </form>`; |
| 405 | const aside = putAsideWaves(ctx.app.db, user); | |
| 406 | const asideNote = | |
| 407 | aside.length === 0 | |
| 408 | ? null | |
| 409 | : html`<details class="bar"> | |
| 410 | <summary class="muted small" | |
| 411 | >${aside.length} ${aside.length === 1 ? "wave" : "waves"} put aside</summary | |
| 412 | > | |
| 413 | ${aside.map( | |
| 414 | (w) => html`<form method="post" action="/asks/proposal/${w.id}/again" class="bar"> | |
| 415 | <input type="hidden" name="back" value="${ctx.url.pathname}" /> | |
| 416 | <span class="small">${w.title}</span> | |
| 417 | ${btn("Bring back", { size: "sm" })} | |
| 418 | </form>`, | |
| 419 | )} | |
| 420 | </details>`; | |
| 382 | 421 | const last = latestUndoable(ctx.app, user); |
| 383 | 422 | const undo = |
| 384 | 423 | last === undefined |
| @@ -391,7 +430,7 @@ export function needsYouSection( | ||
| 391 | 430 | const count = rows.length + needs.length; |
| 392 | 431 | if (count === 0 && top === undefined) |
| 393 | 432 | return { |
| 394 | body: html`<h2 id="asks-sentence" class="sr-only">${askSentence(0)}</h2>${silencedNote}${undo}`, | |
| 433 | body: html`<h2 id="asks-sentence" class="sr-only">${askSentence(0)}</h2>${silencedNote}${asideNote}${undo}`, | |
| 395 | 434 | scriptJs: "", |
| 396 | 435 | }; |
| 397 | 436 | // The forge's own health first, then the few asks that matter most; |
| @@ -442,7 +481,7 @@ export function needsYouSection( | ||
| 442 | 481 | ${reasonInput(top?.reason)} ${btn("Preview", { size: "sm" })} |
| 443 | 482 | <span class="muted small">on the selected asks</span> |
| 444 | 483 | </form>`} |
| 445 | ${silencedNote} ${undo} | |
| 484 | ${silencedNote} ${asideNote} ${undo} | |
| 446 | 485 | ${decisions.map((d) => decisionForm(d, ctx.url.pathname))} |
| 447 | 486 | </section>`; |
| 448 | 487 | return { body, scriptJs: SELECT_JS }; |
| @@ -649,6 +688,23 @@ export async function asksDecide(ctx: Ctx): Promise<Response> { | ||
| 649 | 688 | return redirect(`/?decided=${done.value.id}#asks-bulk-result`); |
| 650 | 689 | } |
| 651 | 690 | |
| 691 | /** POST /asks/proposal/:id/(approve|redirect|later|again) — one wave proposal, then back to the page the person came from. */ | |
| 692 | export function asksProposal(verb: ProposalVerb) { | |
| 693 | return async (ctx: Ctx): Promise<Response> => { | |
| 694 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 695 | const form = await readForm(ctx, "rw"); | |
| 696 | if (form instanceof Response) return form; | |
| 697 | const done = actOnProposal(ctx.app, ctx.auth.user, verb, ctx.params.id, form.get("words")); | |
outdated
Unused: |
||
| 698 | if (!done.ok) | |
| 699 | return done.error.code === "not_found" | |
| 700 | ? notFoundPage(ctx) | |
| 701 | : done.error.code === "forbidden" || done.error.code === "unauthorized" | |
| 702 | ? new Response("forbidden", { status: 403 }) | |
| 703 | : await asksScreen(ctx, nothingDone(done.error.message)); | |
| 704 | return redirect(backTo(form, "/")); | |
| 705 | }; | |
| 706 | } | |
| 707 | ||
| 652 | 708 | /** POST /asks/setting — the owner's Keep or Put back on a setting away from its default. */ |
| 653 | 709 | export async function asksSetting(ctx: Ctx): Promise<Response> { |
| 654 | 710 | if (!ctx.auth.user) return signInFirst(ctx); |
test/mcp.test.ts +1
| @@ -169,6 +169,7 @@ describe("the forge over MCP", () => { | ||
| 169 | 169 | "asks", |
| 170 | 170 | "asks_act", |
| 171 | 171 | "asks_decide", |
| 172 | "asks_proposal", | |
| 172 | 173 | "asks_undecide", |
| 173 | 174 | "seal", |
| 174 | 175 | "now", |
test/proposal-card.test.ts added +258
| @@ -0,0 +1,258 @@ | ||
| 1 | /** | |
| 2 | * A wave proposal is one inbox card (#1622): Approve opens its issues, | |
| 3 | * Redirect hands the owner's words to the planner, Later puts it aside. | |
| 4 | * Refusals come first: a non-owner and a cross-origin post change nothing. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { proposePlan } from "../src/actions/plan.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { getAgentByName, setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import { listIssues, listComments } from "../src/core/issues.ts"; | |
| 17 | import { getProposal } from "../src/core/proposals.ts"; | |
| 18 | import type { Repo } from "../src/core/repos.ts"; | |
| 19 | import { createToken } from "../src/core/tokens.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 22 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 23 | import { settleApp } from "./helpers/tui.ts"; | |
| 24 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 25 | import { askLinesFor } from "../src/web/studio/asks.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | ||
| 28 | quietLogs(); | |
| 29 | ||
| 30 | const PASSWORD = "a-long-proposal-passphrase"; | |
| 31 | ||
| 32 | describe("a wave proposal is one inbox card", () => { | |
| 33 | let dataDir = ""; | |
| 34 | let app: App; | |
| 35 | let servers: Servers; | |
| 36 | let alice: User; | |
| 37 | let bob: User; | |
| 38 | let aliceCookie = ""; | |
| 39 | let bobCookie = ""; | |
| 40 | let repo: Repo; | |
| 41 | ||
| 42 | const signIn = async (username: string): Promise<string> => { | |
| 43 | const res = await fetch(`${servers.url}/signin`, { | |
| 44 | method: "POST", | |
| 45 | body: new URLSearchParams({ username, password: PASSWORD }), | |
| 46 | redirect: "manual", | |
| 47 | }); | |
| 48 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 49 | }; | |
| 50 | ||
| 51 | const post = (cookie: string, path: string, fields: [string, string][] = [], origin = servers.url) => | |
| 52 | fetch(`${servers.url}${path}`, { | |
| 53 | method: "POST", | |
| 54 | headers: { cookie: `${cookie}; forge_mode=studio`, origin }, | |
| 55 | body: new URLSearchParams(fields), | |
| 56 | redirect: "manual", | |
| 57 | }); | |
| 58 | ||
| 59 | const propose = (title: string): string => { | |
| 60 | const made = proposePlan( | |
| 61 | app, | |
| 62 | alice, | |
| 63 | repo, | |
| 64 | [ | |
| 65 | { key: "a", title: "First <b>step</b>", body: "", blockedBy: [] }, | |
| 66 | { key: "b", title: "Second step", body: "", blockedBy: ["a"] }, | |
| 67 | ], | |
| 68 | { title, why: "because <script>x</script>" }, | |
| 69 | ); | |
| 70 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 71 | return made.value.id; | |
| 72 | }; | |
| 73 | ||
| 74 | const rows = () => askLinesFor(app.db, alice).filter((r) => r.proposal !== undefined); | |
| 75 | ||
| 76 | beforeAll(async () => { | |
| 77 | dataDir = await mkdtemp(join(tmpdir(), "forge-proposal-")); | |
| 78 | app = createApp({ dataDir, port: 0 }); | |
| 79 | servers = await serve(app); | |
| 80 | const a = await createUser(app.db, { trusted: true, username: "alice", email: "[email protected]", password: PASSWORD, adminIfFirst: true }); | |
| 81 | const b = await createUser(app.db, { trusted: true, username: "bob", email: "[email protected]", password: PASSWORD }); | |
| 82 | if (!a.ok || !b.ok) throw new Error("users"); | |
| 83 | alice = a.value; | |
| 84 | bob = b.value; | |
| 85 | aliceCookie = await signIn("alice"); | |
| 86 | bobCookie = await signIn("bob"); | |
| 87 | await createAgent(app, alice, { username: "planner" }); | |
| 88 | const made = await createRepo(app, alice, { name: "wave", visibility: "public" }); | |
| 89 | if (!made.ok) throw new Error(String(made.error)); | |
| 90 | repo = made.value; | |
| 91 | await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# wave\n" }] }); | |
| 92 | addToCrew(app, alice, repo, "planner"); | |
| 93 | setCrewRole(app.db, repo.id, getAgentByName(app.db, "planner")!.user.id, "planner"); | |
| 94 | }, 30_000); | |
| 95 | ||
| 96 | afterAll(async () => { | |
| 97 | await servers.stop(); | |
| 98 | await rm(dataDir, { recursive: true, force: true }); | |
| 99 | }); | |
| 100 | ||
| 101 | test("refusals: a non-owner and a cross-origin post change nothing", async () => { | |
| 102 | const id = propose("Refused wave"); | |
| 103 | expect((await post(bobCookie, `/asks/proposal/${id}/approve`)).status).toBe(403); | |
| 104 | expect((await post(aliceCookie, `/asks/proposal/${id}/approve`, [], "https://evil.example")).status).toBe(403); | |
| 105 | expect((await post("", `/asks/proposal/${id}/later`)).headers.get("location")).toContain("signin"); | |
| 106 | expect(getProposal(app.db, id)!.state).toBe("pending"); | |
| 107 | expect(listIssues(app.db, repo.id, { state: "open" }).length).toBe(0); | |
| 108 | expect(askLinesFor(app.db, bob).some((r) => r.proposal !== undefined)).toBe(false); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a pending proposal is one row with title, why and issue titles", async () => { | |
| 112 | const [row] = rows(); | |
| 113 | expect(row!.proposal!.title).toBe("Refused wave"); | |
| 114 | expect(row!.proposal!.issues).toEqual(["First <b>step</b>", "Second step"]); | |
| 115 | const page = await (await fetch(`${servers.url}/asks`, { headers: { cookie: `${aliceCookie}; forge_mode=studio` } })).text(); | |
| 116 | expect(page).toContain("First <b>step</b>"); | |
| 117 | expect(page).toContain("because <script>"); | |
| 118 | expect(page).not.toContain("<b>step</b>"); | |
| 119 | }); | |
| 120 | ||
| 121 | test("Later hides it; again brings it back", async () => { | |
| 122 | const id = rows()[0]!.proposal!.id; | |
| 123 | const res = await post(aliceCookie, `/asks/proposal/${id}/later`, [["back", "/asks"]]); | |
| 124 | expect(res.status).toBe(303); | |
| 125 | expect(res.headers.get("location")).toBe("/asks"); | |
| 126 | expect(rows()).toHaveLength(0); | |
| 127 | expect((await post(aliceCookie, `/asks/proposal/${id}/again`)).status).toBe(303); | |
| 128 | expect(rows()).toHaveLength(1); | |
| 129 | }); | |
| 130 | ||
| 131 | test("Approve opens the issues with their links, once", async () => { | |
| 132 | const id = rows()[0]!.proposal!.id; | |
| 133 | const res = await post(aliceCookie, `/asks/proposal/${id}/approve`, [["back", "/asks"]]); | |
| 134 | expect(res.status).toBe(303); | |
| 135 | expect(res.headers.get("location")).toBe("/asks"); | |
| 136 | const open = listIssues(app.db, repo.id, { state: "open" }); | |
| 137 | expect(open.map((i) => i.title).sort()).toEqual(["First <b>step</b>", "Second step"]); | |
| 138 | expect(rows()).toHaveLength(0); | |
| 139 | await post(aliceCookie, `/asks/proposal/${id}/approve`); | |
| 140 | expect(listIssues(app.db, repo.id, { state: "open" })).toHaveLength(2); | |
| 141 | }); | |
| 142 | ||
| 143 | test("Redirect writes the owner's words for the planner and takes the card off", async () => { | |
| 144 | const id = propose("Wrong wave"); | |
| 145 | expect((await post(aliceCookie, `/asks/proposal/${id}/redirect`, [["words", " "]])).status).toBe(200); | |
| 146 | expect(getProposal(app.db, id)!.state).toBe("pending"); | |
| 147 | const res = await post(aliceCookie, `/asks/proposal/${id}/redirect`, [["words", "Do the tests first"], ["back", "/"]]); | |
| 148 | expect(res.status).toBe(303); | |
| 149 | expect(getProposal(app.db, id)!.state).toBe("redirected"); | |
| 150 | const direction = listIssues(app.db, repo.id, { state: "open" }).find((i) => i.assigneeName === "planner"); | |
| 151 | expect(direction).toBeDefined(); | |
| 152 | expect(listComments(app.db, direction!.id).some((c) => c.body.includes("Do the tests first"))).toBe(true); | |
| 153 | expect(rows()).toHaveLength(0); | |
| 154 | }); | |
| 155 | ||
| 156 | test("home shows the Redirect box on the card, and Later's way back", async () => { | |
| 157 | const id = propose("Home wave"); | |
| 158 | const get = async () => | |
| 159 | (await (await fetch(`${servers.url}/`, { headers: { cookie: `${aliceCookie}; forge_mode=studio` } })).text()); | |
| 160 | expect(await get()).toContain(`/asks/proposal/${id}/redirect`); | |
| 161 | await post(aliceCookie, `/asks/proposal/${id}/later`, [["back", "/"]]); | |
| 162 | const page = await get(); | |
| 163 | expect(page).not.toContain(`/asks/proposal/${id}/redirect`); | |
| 164 | expect(page).toContain("1 wave put aside"); | |
| 165 | expect(page).toContain(`/asks/proposal/${id}/again`); | |
| 166 | await post(aliceCookie, `/asks/proposal/${id}/again`); | |
| 167 | expect(getProposal(app.db, id)!.state).toBe("pending"); | |
| 168 | await post(aliceCookie, `/asks/proposal/${id}/later`); | |
| 169 | }); | |
| 170 | ||
| 171 | describe("the other doors", () => { | |
| 172 | let rw = ""; | |
| 173 | let ro = ""; | |
| 174 | let bobRw = ""; | |
| 175 | beforeAll(() => { | |
| 176 | rw = createToken(app.db, alice.id, "rw", "rw").token; | |
| 177 | ro = createToken(app.db, alice.id, "ro", "ro").token; | |
| 178 | bobRw = createToken(app.db, bob.id, "rw", "rw").token; | |
| 179 | }); | |
| 180 | const api = async (token: string, id: string, verb: string, payload: unknown = {}) => { | |
| 181 | const res = await fetch(`${servers.url}/api/v1/asks/proposal/${id}/${verb}`, { | |
| 182 | method: "POST", | |
| 183 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 184 | body: JSON.stringify(payload), | |
| 185 | }); | |
| 186 | return { status: res.status, body: (await res.json()) as any }; | |
| 187 | }; | |
| 188 | const mcp = async (token: string, args: unknown) => { | |
| 189 | const res = await fetch(`${servers.url}/mcp`, { | |
| 190 | method: "POST", | |
| 191 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 192 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "asks_proposal", arguments: args } }), | |
| 193 | }); | |
| 194 | const body = (await res.json()) as any; | |
| 195 | return { error: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 196 | }; | |
| 197 | ||
| 198 | test("API: refused to a reader, a read-only token and a stranger's verb; works for the owner", async () => { | |
| 199 | const id = propose("Api wave"); | |
| 200 | expect((await api(bobRw, id, "approve")).status).toBe(403); | |
| 201 | expect((await api(ro, id, "later")).status).toBe(403); | |
| 202 | expect((await api(rw, id, "explode")).status).toBe(404); | |
| 203 | expect(getProposal(app.db, id)!.state).toBe("pending"); | |
| 204 | expect((await api(rw, id, "redirect", { words: "" })).status).toBe(422); | |
| 205 | expect((await api(rw, id, "later")).status).toBe(201); | |
| 206 | expect(getProposal(app.db, id)!.state).toBe("later"); | |
| 207 | expect((await api(rw, id, "later")).status).toBe(409); | |
| 208 | expect((await api(rw, id, "again")).status).toBe(201); | |
| 209 | const before = listIssues(app.db, repo.id, { state: "open" }).length; | |
| 210 | const done = await api(rw, id, "approve"); | |
| 211 | expect(done.status).toBe(201); | |
| 212 | expect(done.body.opened).toHaveLength(2); | |
| 213 | expect(listIssues(app.db, repo.id, { state: "open" }).length).toBe(before + 2); | |
| 214 | }); | |
| 215 | ||
| 216 | test("MCP: asks_proposal is a write that the owner alone may do", async () => { | |
| 217 | const id = propose("Mcp wave"); | |
| 218 | expect((await mcp(ro, { id, verb: "later" })).error).toBe(true); | |
| 219 | expect((await mcp(bobRw, { id, verb: "later" })).error).toBe(true); | |
| 220 | expect((await mcp(rw, { id, verb: "bogus" })).error).toBe(true); | |
| 221 | expect((await mcp(rw, { id, verb: "later" })).error).toBe(false); | |
| 222 | expect(getProposal(app.db, id)!.state).toBe("later"); | |
| 223 | expect((await mcp(rw, { id, verb: "again" })).error).toBe(false); | |
| 224 | const row = askLinesFor(app.db, alice).find((r) => r.proposal?.id === id); | |
| 225 | expect(row).toBeDefined(); | |
| 226 | expect((await mcp(rw, { id, verb: "redirect", words: "Smaller, please" })).error).toBe(false); | |
| 227 | expect(getProposal(app.db, id)!.state).toBe("redirected"); | |
| 228 | }); | |
| 229 | ||
| 230 | test("terminal: a wave is a row on the On you card; y approves, l puts aside, B brings back", async () => { | |
| 231 | const a = propose("Tui approve wave"); | |
| 232 | const b = propose("Tui later wave"); | |
| 233 | const terminal = headlessTerminal(140, 40); | |
| 234 | const { app: ui } = createForgeApp( | |
| 235 | app, | |
| 236 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: alice }, | |
| 237 | { terminal, exitHooks: false }, | |
| 238 | ); | |
| 239 | ui.start(); | |
| 240 | const press = async (key: string) => { | |
| 241 | ui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 242 | await settleApp(ui); | |
| 243 | }; | |
| 244 | await settleApp(ui); | |
| 245 | await press("tab"); | |
| 246 | expect(ui.screen.toText()).toContain("Tui later wave"); | |
| 247 | // The first row is wave a (oldest); y approves it. | |
| 248 | await press("y"); | |
| 249 | expect(getProposal(app.db, a)!.state).toBe("approved"); | |
| 250 | await press("l"); | |
| 251 | expect(getProposal(app.db, b)!.state).toBe("later"); | |
| 252 | expect(ui.screen.toText()).toContain("B brings it back"); | |
| 253 | await press("B"); | |
| 254 | expect(getProposal(app.db, b)!.state).toBe("pending"); | |
| 255 | ui.stop(); | |
| 256 | }); | |
| 257 | }); | |
| 258 | }); |
test/tui-onyou.test.ts +9
| @@ -249,4 +249,13 @@ describe("terminal On you: mutes and many in one move", () => { | ||
| 249 | 249 | expect(h.text()).not.toContain("setting cells.egress"); |
| 250 | 250 | h.stop(); |
| 251 | 251 | }); |
| 252 | ||
| 253 | test("B with no wave put aside and no setting under the cursor does nothing", async () => { | |
| 254 | hunt(forge, "alice/lamp"); | |
| 255 | const h = await boot(); | |
| 256 | await h.press("B"); | |
| 257 | expect(h.text()).not.toContain("wave back"); | |
| 258 | expect(h.text()).not.toContain("waves back"); | |
| 259 | h.stop(); | |
| 260 | }); | |
| 252 | 261 | }); |
"for every door": only the web form calls these today. The JSON API, the MCP tools and the terminal need routes or keys that call them too.