Public: anyone can read this; the crew can change it.
Notes: write an idea in one box and watch it become shipped work #8703
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8696-notes-write-an-idea-in-one-box-and-watch → main 5 commits fork 3 threads · 3 unresolved
20 files
changed +943
−18
docs/design/areas/notes.md added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | # Notes | |
| 2 | ||
| 3 | `/notes` (`src/web/pages/notes.ts`): the composer is `.note-box` (a textarea and a bar), and each row's one-line state is `.note-line` inside a §5 row's meta. |
docs/design/audit.md +1
| @@ -61,6 +61,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 61 | 61 | - `/review` — `studio/review_page.ts` · every ask waiting on me, one row each, newest first · `/asks` (same asks, with buttons) · none seen. |
| 62 | 62 | - `/flow` — `pages/flow.ts` · the pipeline from ready issue to live deploy, and the bottleneck · `/system`, `/board`, `/` · none seen. A topbar button points here. |
| 63 | 63 | - `/talk` — `pages/talk.ts` · the person's one conversation with the forge: history, composer, Confirm and Cancel; replies stream in over `/talk/stream` · `/settings/confirms` (the same yes, as a list), the home tell box (one line to an app's crew) · none seen; styles in `docs/design/areas/talk.md`. |
| 64 | - `/notes` — `pages/notes.ts` · what the person wrote, newest first, with the planner's verdict and live state in one line, and one tap to overrule it · the home tell box (Notes button), the user menu, the palette · none seen; styles in `docs/design/areas/notes.md`. | |
| 64 | 65 | - `/pipeline` — `pages/pipeline.ts` · the four steps a change follows (check, review, land, live), for a newcomer · a pull page's step line, `/flow` · none seen. |
| 65 | 66 | - `/map` — `pages/map.ts` (1,806 lines) · the dependency map of work across the forge · `/board`, issue dependency lists, `/:owner/:name/map` · colours 3, sizes 12: the largest private vocabulary. |
| 66 | 67 | - `/audit` — `pages/audit.ts` · every app's contract, who can do what · the app page's one-line policy · none seen. Names the audit of apps, not this document. |
docs/features/notes.md added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | # Notes | |
| 2 | ||
| 3 | `/notes` is one box and a list. Type an idea, press Enter (Shift+Enter for a new line; the Save button when there is no script), and it is kept as a thought (`keepThought`, source `composer`, the same door the API and MCP use). Your notes are listed newest first, yours alone. | |
| 4 | ||
| 5 | The planner (`crew/triage.ts`) places every note without asking, within a couple of minutes: | |
| 6 | ||
| 7 | - a long dump becomes several issues: its bullets or numbered lines, else its blank-line paragraphs (`splitIdeas`, at most 8), each issue linked back to the note; | |
| 8 | - an idea that clearly touches an open issue (most of its words shared, not a reversal) is added to that issue as a comment instead of a duplicate (`attachTarget`); | |
| 9 | - a repeat, a reversal, a vision and a decision behave as before. | |
| 10 | ||
| 11 | Each note carries one line (`core/note_line.ts`): *Waiting for the planner*, *Planned*, *Building*, *In review*, *Shipped in version N*, with links to the issues and pulls. A split dump says "3 issues: 1 shipped, 1 in review, 1 planned". The version is read from the release commits of the forge's own repository, so it is named only for work shipped there; elsewhere the line says *Shipped*. | |
| 12 | ||
| 13 | Reached from the Notes button beside the tell box on home, the user menu, and the palette (`Ctrl/Cmd+K`). Writes need a session or a token with `rw` scope and the same origin. | |
| 14 | ||
| 15 | ## The planner decides; you overrule in one tap | |
| 16 | ||
| 17 | Nothing waits for you to accept a note. The planner gives each one a verdict and the line says which, and why: | |
| 18 | ||
| 19 | - **Accepted**: it opened or amended issues; the line then follows them (planned, building, in review, shipped). Tap *Not this* while it is only planned and the issues it opened are closed. | |
| 20 | - **Declined**: nothing to build (an empty note, a bare repository name). Tap *Build it anyway* and an issue is opened from it. | |
| 21 | - **Duplicate**: the same ask as an earlier note; the line says when you asked and where that stands, and the earlier issue is raised. Tap *Build it anyway* for a new issue. | |
| 22 | - **Snoozed**: it says "someday", "not now", "eventually", "low priority" and the like, and the repository has a Later gate: the issue is opened behind the gate. Tap *Build it anyway* to let it out. | |
| 23 | ||
| 24 | An overrule is `POST /notes/:id/overrule` (`do=build|decline`), kept as the setting `note.<thought id>` on you; the planner's own placement is never rewritten, only added to. | |
| 25 | ||
| 26 | The terminal has the same door: the palette's "Notes" (`src/tui/screens/thoughtnotes.ts`) writes with `n`, lists your own notes with the same one-line state, and `b` takes the tap the line offers. An overrule is once per note and only what the line offers; a repeat or a stale form does nothing. "Snoozed" is judged per idea in a dump, and "backlog" is not a snooze word. |
forge.json +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | "api.anthropic.com" |
| 4 | 4 | ], |
| 5 | 5 | "app": { |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/talk", "/contribute", "/pipeline", "/resilience", "/settings"], | |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/notes", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/talk", "/contribute", "/pipeline", "/resilience", "/settings"], | |
| 7 | 7 | "run": "bun run src/main.ts", |
| 8 | 8 | "install": "", |
| 9 | 9 | "health": "/healthz", |
src/actions/note_overrule.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** | |
| 2 | * One tap against the planner's verdict on a note (#8696). The planner | |
| 3 | * accepts, declines, snoozes or calls a note a duplicate without asking; the | |
| 4 | * person's say is final and is one action: `build` (a declined, duplicate or | |
| 5 | * snoozed note is built after all) or `decline` (an accepted note's own | |
| 6 | * issues are closed). The note is the person's alone; the overrule is kept as | |
| 7 | * a setting on them and, for `build`, as the issue it opened. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { openPlan } from "./plan.ts"; | |
| 12 | import { setIssueState } from "./issues.ts"; | |
| 13 | import { removeDependency } from "../core/dependencies.ts"; | |
| 14 | import { getIssue, MAX_TITLE } from "../core/issues.ts"; | |
| 15 | import { appendPlaced, type Placed } from "../core/placements.ts"; | |
| 16 | import { noteLine } from "../core/note_line.ts"; | |
| 17 | import { pillarsOf } from "../core/pillars.ts"; | |
| 18 | import { getRepo } from "../core/repos.ts"; | |
| 19 | import { setUserSetting } from "../core/settings.ts"; | |
| 20 | import { overruleKey, thoughtStream } from "../core/thought_stream.ts"; | |
| 21 | import { thoughtOf } from "../core/thoughts.ts"; | |
| 22 | import type { User } from "../core/users.ts"; | |
| 23 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 24 | import { homeOf } from "../crew/triage.ts"; | |
| 25 | ||
| 26 | export type OverruleError = "unauthorized" | "not_found" | "nothing_to_do" | "failed"; | |
| 27 | ||
| 28 | export function overruleNote(app: App, actor: User | undefined, thoughtId: string, action: unknown): Result<"accepted" | "declined", OverruleError> { | |
| 29 | if (!actor) return err("unauthorized"); | |
| 30 | if (action !== "build" && action !== "decline") return err("nothing_to_do"); | |
| 31 | const thought = thoughtOf(app.db, actor.id, thoughtId); | |
| 32 | const note = thought && thoughtStream(app.db, actor, { limit: 200 }).find((n) => n.id === thought.id); | |
| 33 | if (!thought || !note || note.placedAt === null) return err("not_found"); | |
| 34 | // Only what the line offers: one tap, once. A replay or a stale form finds nothing to do. | |
| 35 | if (note.overruled !== null || noteLine(note).overrule?.do !== action) return err("nothing_to_do"); | |
| 36 | const slugRepo = (slug: string) => { | |
| 37 | const [owner, name] = slug.split("/"); | |
| 38 | return owner && name ? getRepo(app.db, owner, name) : undefined; | |
| 39 | }; | |
| 40 | ||
| 41 | if (action === "decline") { | |
| 42 | for (const x of note.placed) { | |
| 43 | if (x.action !== "opened") continue; | |
| 44 | const repo = slugRepo(x.repo); | |
| 45 | const issue = repo && getIssue(app.db, repo.id, x.number); | |
| 46 | if (repo && issue && issue.state === "open") setIssueState(app, actor, repo, issue, "closed"); | |
| 47 | } | |
| 48 | setUserSetting(app.db, actor.id, overruleKey(thought.id), "declined"); | |
| 49 | app.bus.append({ kind: "thought.overruled", actorId: actor.id, payload: { id: thought.id, to: "declined" } }); | |
| 50 | return ok("declined"); | |
| 51 | } | |
| 52 | ||
| 53 | // build: a snoozed note is let out from behind the Later gate; any other is opened now. | |
| 54 | const snoozed = note.placed.filter((x) => x.action === "deferred"); | |
| 55 | const opened: Placed[] = []; | |
| 56 | if (note.kind === "work" && snoozed.length > 0 && note.overruled === null) { | |
| 57 | for (const x of snoozed) { | |
| 58 | const repo = slugRepo(x.repo); | |
| 59 | const issue = repo && getIssue(app.db, repo.id, x.number); | |
| 60 | const gate = repo && pillarsOf(app.db, repo).gate; | |
| 61 | if (issue && gate) removeDependency(app.db, issue.id, gate.id); | |
| 62 | } | |
| 63 | } else { | |
| 64 | const repo = homeOf(app, actor.id); | |
| 65 | if (!repo) return err("failed"); | |
| 66 | const slug = `${repo.ownerName}/${repo.name}`; | |
| 67 | const made = openPlan(app, actor, repo, [ | |
| 68 | { | |
| 69 | key: "thought", | |
| 70 | title: (thought.text.replace(/\s+/g, " ").trim().slice(0, Math.min(MAX_TITLE, 120)) || "Note"), | |
| 71 | body: `From thought \`${thought.id}\`, built on its author's say:\n\n${thought.text.split("\n").map((l) => `> ${l}`).join("\n")}`, | |
| 72 | blockedBy: [], | |
| 73 | }, | |
| 74 | ]); | |
| 75 | if (!made.ok) return err("failed"); | |
| 76 | for (const o of made.value.opened) opened.push({ action: "opened", repo: slug, number: o.number }); | |
| 77 | appendPlaced(app.db, thought.id, opened); | |
| 78 | } | |
| 79 | setUserSetting(app.db, actor.id, overruleKey(thought.id), "accepted"); | |
| 80 | app.bus.append({ kind: "thought.overruled", actorId: actor.id, payload: { id: thought.id, to: "accepted" } }); | |
| 81 | return ok("accepted"); | |
| 82 | } |
src/core/note_line.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | /** | |
| 2 | * A note's live state in one line (#8696). The planner decides each note on | |
| 3 | * its own — accept (it opened or amended issues), decline (nothing to | |
| 4 | * build), duplicate (the same ask as an earlier note) or snooze (opened, but | |
| 5 | * behind the Later gate) — and the line says which and why, then, for an | |
| 6 | * accepted note, how far it got: planned, building, in review, shipped (in | |
| 7 | * version N when the release is known). Every verdict has one tap that | |
| 8 | * overrules it (`actions/note_overrule.ts`). Plain data in, plain data out: | |
| 9 | * the page draws the words and links, escaping both. | |
| 10 | */ | |
| 11 | ||
| 12 | import { BUILDS, type StreamThought } from "./thought_stream.ts"; | |
| 13 | ||
| 14 | export type NotePhase = "waiting" | "planned" | "building" | "review" | "shipped" | "superseded" | "declined" | "duplicate" | "snoozed"; | |
| 15 | ||
| 16 | export interface NoteLink { | |
| 17 | readonly label: string; | |
| 18 | /** A path on this forge. */ | |
| 19 | readonly href: string; | |
| 20 | } | |
| 21 | ||
| 22 | /** What one tap does to the planner's verdict. */ | |
| 23 | export type Overrule = { readonly do: "build" | "decline"; readonly label: string }; | |
| 24 | ||
| 25 | export interface NoteLine { | |
| 26 | /** The verdict while the planner's call stands, else where the accepted work is: the earliest phase among its issues. */ | |
| 27 | readonly phase: NotePhase; | |
| 28 | readonly text: string; | |
| 29 | readonly links: readonly NoteLink[]; | |
| 30 | readonly overrule: Overrule | null; | |
| 31 | } | |
| 32 | ||
| 33 | /** The version a merged pull of `repo` (owner/name) went out in, when known. */ | |
| 34 | export type VersionOf = (repo: string, pull: number) => string | undefined; | |
| 35 | ||
| 36 | const ORDER: readonly NotePhase[] = ["planned", "building", "review", "shipped"]; | |
| 37 | const WORD: Record<NotePhase, string> = { | |
| 38 | waiting: "Waiting for the planner", | |
| 39 | planned: "Planned", | |
| 40 | building: "Building", | |
| 41 | review: "In review", | |
| 42 | shipped: "Shipped", | |
| 43 | superseded: "Superseded", | |
| 44 | declined: "Declined", | |
| 45 | duplicate: "Duplicate", | |
| 46 | snoozed: "Snoozed", | |
| 47 | }; | |
| 48 | const BUILD: Overrule = { do: "build", label: "Build it anyway" }; | |
| 49 | const DECLINE: Overrule = { do: "decline", label: "Not this" }; | |
| 50 | ||
| 51 | export function noteLine(t: StreamThought, versionOf: VersionOf = () => undefined): NoteLine { | |
| 52 | if (t.placedAt === null) return { phase: "waiting", text: WORD.waiting, links: [], overrule: null }; | |
| 53 | if (t.state === "superseded") return { phase: "superseded", text: WORD.superseded, links: [], overrule: null }; | |
| 54 | const why = t.reply ?? ""; | |
| 55 | const issues = t.placed.filter((x) => x.readable && BUILDS.has(x.action)); | |
| 56 | const own = (x: (typeof issues)[number]) => ({ label: `#${x.number}`, href: `/${x.repo}/issues/${x.number}` }); | |
| 57 | if (t.overruled === "declined") | |
| 58 | return { phase: "declined", text: "Declined by you", links: issues.map(own), overrule: null }; | |
| 59 | if (t.overruled === null && issues.length === 0 && t.placed.every((x) => x.action !== "deferred")) | |
| 60 | return { phase: "declined", text: `Declined: ${why.replace(/^Declined:\s*/i, "") || "nothing to build"}`, links: [], overrule: BUILD }; | |
| 61 | ||
| 62 | const links: NoteLink[] = []; | |
| 63 | const phases: NotePhase[] = []; | |
| 64 | let version: string | undefined; | |
| 65 | for (const x of issues) { | |
| 66 | const merged = x.pulls.find((p) => p.state === "merged"); | |
| 67 | const open = x.pulls.find((p) => p.state === "open"); | |
| 68 | phases.push(merged ? "shipped" : open ? "review" : x.running ? "building" : "planned"); | |
| 69 | links.push(own(x)); | |
| 70 | const pull = merged ?? open; | |
| 71 | if (pull) links.push({ label: `pull #${pull.number}`, href: `/${x.repo}/pulls/${pull.number}` }); | |
| 72 | if (merged) version ??= versionOf(x.repo, merged.number); | |
| 73 | } | |
| 74 | const phase = ORDER.find((p) => phases.includes(p)) ?? "planned"; | |
| 75 | const progress = | |
| 76 | issues.length === 1 | |
| 77 | ? phase === "shipped" && version !== undefined | |
| 78 | ? `Shipped in version ${version}` | |
| 79 | : WORD[phase] | |
| 80 | : `${issues.length} issues: ${[...ORDER].reverse().flatMap((p) => { | |
| 81 | const n = phases.filter((q) => q === p).length; | |
| 82 | return n === 0 ? [] : [`${n} ${WORD[p].toLowerCase()}`]; | |
| 83 | }).join(", ")}`; | |
| 84 | ||
| 85 | if (t.overruled === null && t.kind === "work" && t.placed.some((x) => x.action === "deferred")) | |
| 86 | return { phase: "snoozed", text: "Snoozed: you said it can wait", links, overrule: BUILD }; | |
| 87 | if (t.overruled === null && t.repeats !== null) | |
| 88 | return { phase: "duplicate", text: `Duplicate: ${why} ${issues.length > 0 ? `(${progress.toLowerCase()})` : ""}`.trim(), links, overrule: BUILD }; | |
Low: |
||
| 89 | return { phase, text: progress, links, overrule: phase === "planned" && t.overruled === null && issues.some((x) => x.action === "opened") ? DECLINE : null }; | |
| 90 | } |
src/core/placements.ts +7
| @@ -60,6 +60,13 @@ function toPlacement(r: Row): Placement { | ||
| 60 | 60 | }; |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | /** Add what a person's overrule did to a placement (#8696); what the planner wrote stays as it was. */ | |
| 64 | export function appendPlaced(db: Database, thoughtId: string, more: readonly Placed[]): void { | |
| 65 | const now = placementOf(db, thoughtId); | |
| 66 | if (!now) return; | |
| 67 | db.query("UPDATE placement SET placed = ?2 WHERE thought_id = ?1").run(thoughtId, JSON.stringify([...now.placed, ...more])); | |
| 68 | } | |
| 69 | ||
| 63 | 70 | export function placementOf(db: Database, thoughtId: string): Placement | undefined { |
| 64 | 71 | const row = db.query<Row, [string]>("SELECT * FROM placement WHERE thought_id = ?1").get(thoughtId); |
| 65 | 72 | return row ? toPlacement(row) : undefined; |
src/core/reserved.ts +1
| @@ -111,6 +111,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 111 | 111 | "sw.js", |
| 112 | 112 | "tell", |
| 113 | 113 | "talk", |
| 114 | "notes", | |
| 114 | 115 | "theme", |
| 115 | 116 | "verify", |
| 116 | 117 | "recover", |
src/core/thought_stream.ts +24 −2
| @@ -29,6 +29,8 @@ import type { User } from "./users.ts"; | ||
| 29 | 29 | export type ThoughtState = "unplaced" | "placed" | "building" | "shipped" | "superseded"; |
| 30 | 30 | |
| 31 | 31 | export const FLAG_DAYS = 3; |
| 32 | /** The user setting that holds a person's overrule of one note. */ | |
| 33 | export const overruleKey = (thoughtId: string): string => `note.${thoughtId}`; | |
| 32 | 34 | export const FLAG_DAYS_KEY = "thoughtFlagDays"; |
| 33 | 35 | export const THOUGHT_LIMIT = 200; |
| 34 | 36 | |
| @@ -40,6 +42,9 @@ export interface PlacedLink extends Placed { | ||
| 40 | 42 | readonly readable: boolean; |
| 41 | 43 | readonly title: string | null; |
| 42 | 44 | readonly issueState: "open" | "closed" | null; |
| 45 | /** The pulls that close it, and whether a run is on it now (what a note's one line reads). */ | |
| 46 | readonly pulls: readonly { readonly number: number; readonly state: "open" | "merged" | "closed" }[]; | |
| 47 | readonly running: boolean; | |
| 43 | 48 | } |
| 44 | 49 | |
| 45 | 50 | export interface StreamThought { |
| @@ -55,6 +60,8 @@ export interface StreamThought { | ||
| 55 | 60 | readonly placed: readonly PlacedLink[]; |
| 56 | 61 | readonly state: ThoughtState; |
| 57 | 62 | readonly flagged: boolean; |
| 63 | /** What the person said against the planner's verdict (#8696), if they did. */ | |
| 64 | readonly overruled: "accepted" | "declined" | null; | |
| 58 | 65 | /** The later thought that superseded this one. */ |
| 59 | 66 | readonly supersededBy: { readonly id: string; readonly text: string; readonly receivedAt: string } | null; |
| 60 | 67 | /** The earlier thought this one says again (#654). */ |
| @@ -76,7 +83,7 @@ export function flagDays(db: Database, userId: string): number { | ||
| 76 | 83 | } |
| 77 | 84 | |
| 78 | 85 | const keyOf = (p: Placed) => `${p.repo}#${p.number}`; |
| 79 | const BUILDS = new Set(["opened", "amended", "linked"]); | |
| 86 | export const BUILDS = new Set(["opened", "amended", "linked"]); | |
| 80 | 87 | |
| 81 | 88 | export function thoughtStream( |
| 82 | 89 | db: Database, |
| @@ -128,6 +135,12 @@ export function thoughtStream( | ||
| 128 | 135 | return t ? { id: t.id, text: t.text, receivedAt: t.receivedAt } : null; |
| 129 | 136 | }; |
| 130 | 137 | |
| 138 | const overrules = new Map( | |
| 139 | db | |
| 140 | .query<{ key: string; value: string }, [string]>("SELECT key, value FROM user_setting WHERE user_id = ?1 AND key LIKE 'note.%'") | |
| 141 | .all(viewer.id) | |
| 142 | .map((r) => [r.key, r.value]), | |
| 143 | ); | |
| 131 | 144 | return thoughts.map((t) => { |
| 132 | 145 | const p = placements.get(t.id); |
| 133 | 146 | const from = linksFrom(db, t.id); |
| @@ -135,7 +148,15 @@ export function thoughtStream( | ||
| 135 | 148 | const links: PlacedLink[] = (p?.placed ?? []).map((x) => { |
| 136 | 149 | const repo = repoOf(x.repo); |
| 137 | 150 | const issue = repo ? getIssue(db, repo.id, x.number) : undefined; |
| 138 | return { ...x, readable: repo !== undefined, title: issue?.title ?? null, issueState: issue?.state ?? null }; | |
| 151 | const its = repo ? (pullsOf(repo).get(`${repo.id}#${x.number}`) ?? []) : []; | |
| 152 | return { | |
| 153 | ...x, | |
| 154 | readable: repo !== undefined, | |
| 155 | title: issue?.title ?? null, | |
| 156 | issueState: issue?.state ?? null, | |
| 157 | pulls: its.map((q) => ({ number: q.number, state: q.state })), | |
| 158 | running: issue !== undefined && activeRuns.has(issue.id), | |
| 159 | }; | |
| 139 | 160 | }); |
| 140 | 161 | let built = false; |
| 141 | 162 | let shipped = false; |
| @@ -176,6 +197,7 @@ export function thoughtStream( | ||
| 176 | 197 | placed: links, |
| 177 | 198 | state, |
| 178 | 199 | flagged, |
| 200 | overruled: ((v) => (v === "accepted" || v === "declined" ? v : null))(overrules.get(overruleKey(t.id))), | |
| 179 | 201 | supersededBy: |
| 180 | 202 | state === "superseded" && by ? { id: by.id, text: by.text, receivedAt: by.receivedAt } : null, |
| 181 | 203 | repeats: from.repeats[0] ? ref(from.repeats[0]) : null, |
src/crew/triage.ts +86 −15
| @@ -90,6 +90,48 @@ function targetOf(text: string, open: readonly Open[]): Open | undefined { | ||
| 90 | 90 | return best; |
| 91 | 91 | } |
| 92 | 92 | |
| 93 | /** Words that say an idea can wait. */ | |
| 94 | const SNOOZE = /\b(someday|some day|maybe later|not now|eventually|nice to have|when we have time|low priority)\b/i; | |
outdated
Medium: 'backlog' is an ordinary product word and will snooze real work; and this regex is applied to the whole text (see |
||
| 95 | /** At most this many issues come of one note. */ | |
| 96 | const IDEAS_MAX = 8; | |
| 97 | const BULLET = /^\s*(?:[-*•]|\d+[.)])\s+/; | |
| 98 | ||
| 99 | /** | |
| 100 | * The ideas in a dump: its bullets or numbered lines when it has two or more | |
| 101 | * (a line that continues one belongs to it), else its blank-line paragraphs; | |
| 102 | * otherwise the whole text, one idea. An item of under three words is | |
| 103 | * a heading or a stray, and is kept with the one before it. | |
| 104 | */ | |
| 105 | export function splitIdeas(text: string): string[] { | |
| 106 | const lines = text.split("\n"); | |
| 107 | let items: string[] = []; | |
| 108 | if (lines.filter((l) => BULLET.test(l)).length >= 2) { | |
| 109 | for (const l of lines) { | |
| 110 | if (BULLET.test(l)) items.push(l.replace(BULLET, "").trim()); | |
| 111 | else if (items.length > 0 && l.trim() !== "") items[items.length - 1] += `\n${l.trim()}`; | |
| 112 | } | |
| 113 | } else items = text.split(/\n\s*\n/).map((p) => p.trim()); | |
| 114 | const kept: string[] = []; | |
| 115 | for (const item of items.filter((i) => i !== "")) { | |
| 116 | if (words(item).size < 3 && kept.length > 0) kept[kept.length - 1] += `\n${item}`; | |
| 117 | else kept.push(item); | |
| 118 | } | |
| 119 | return kept.length < 2 || kept.length > IDEAS_MAX ? [text] : kept; | |
| 120 | } | |
| 121 | ||
| 122 | /** The open issue an idea clearly touches (most of the shorter side's words are shared), else none. */ | |
| 123 | export function attachTarget(idea: string, open: readonly Open[]): Open | undefined { | |
| 124 | const mine = words(idea); | |
| 125 | let best: Open | undefined; | |
| 126 | let most = 0; | |
| 127 | for (const o of open) { | |
| 128 | const theirs = words(o.title); | |
| 129 | const n = overlap(mine, theirs); | |
| 130 | if (n >= 3 && n / Math.min(mine.size, theirs.size) >= 0.6 && n > most && !opposed(idea, o.title)) (best = o), (most = n); | |
| 131 | } | |
| 132 | return best; | |
| 133 | } | |
| 134 | ||
| 93 | 135 | function pillarOf(text: string, set: Pillars): string | undefined { |
| 94 | 136 | if (set.pillars.length === 0) return undefined; |
| 95 | 137 | const mine = words(text); |
| @@ -309,22 +351,51 @@ export async function placeThought( | ||
| 309 | 351 | kind = kind === "reversal" ? "decision" : "work"; |
| 310 | 352 | } |
| 311 | 353 | if (kind === "work") { |
| 312 | const first = text.trim().split("\n")[0] ?? ""; | |
| 313 | const opened = openPlan(app, by, repo, [ | |
| 314 | { | |
| 315 | key: "thought", | |
| 316 | title: clip(first, Math.min(MAX_TITLE, 120)) || "Thought", | |
| 317 | body: `${ref}\n\n${quote(text)}${pillar ? `\n\nServes ${set.pillars.find((p) => p.short === pillar)?.name}.` : ""}`, | |
| 318 | blockedBy: [], | |
| 319 | ...(pillar ? { pillar } : {}), | |
| 320 | }, | |
| 321 | ]); | |
| 322 | if (opened.ok) { | |
| 323 | const number = opened.value.opened[0]!.number; | |
| 324 | placed.push({ action: "opened", repo: slug, number, ...(pillar ? { pillar } : {}) }); | |
| 354 | // A long dump is several ideas: each is attached to the open issue it | |
| 355 | // touches, or becomes an issue of its own, linked back to this thought. | |
| 356 | const ideas = splitIdeas(text); | |
| 357 | const fresh: string[] = []; | |
| 358 | for (const idea of ideas) { | |
| 359 | const near = attachTarget(idea, open); | |
| 360 | const issue = near ? here(near.number) : undefined; | |
| 361 | if (issue) { | |
| 362 | comment(issue, "A new thought touches this:"); | |
| 363 | placed.push({ action: "amended", repo: slug, number: issue.number }); | |
| 364 | say.push(`Added to #${issue.number}.`); | |
| 365 | } else fresh.push(idea); | |
| 366 | } | |
| 367 | // Said to wait: it is opened, and sits behind the Later gate until its author says otherwise. | |
| 368 | const waits = (idea: string): boolean => set.gate !== undefined && SNOOZE.test(idea); | |
| 369 | const opened = | |
| 370 | fresh.length === 0 | |
| 371 | ? undefined | |
| 372 | : openPlan( | |
| 373 | app, | |
| 374 | by, | |
| 375 | repo, | |
| 376 | fresh.map((idea, i) => ({ | |
| 377 | key: `thought${i}`, | |
| 378 | title: clip(idea.split("\n")[0] ?? "", Math.min(MAX_TITLE, 120)) || "Thought", | |
| 379 | body: `${ref}\n\n${quote(idea)}${pillar ? `\n\nServes ${set.pillars.find((p) => p.short === pillar)?.name}.` : ""}`, | |
| 380 | blockedBy: [], | |
| 381 | ...(waits(idea) ? { later: "You said it can wait." } : pillar ? { pillar } : {}), | |
| 382 | })), | |
| 383 | ); | |
| 384 | if (opened?.ok) { | |
| 325 | 385 | const name = set.pillars.find((p) => p.short === pillar)?.name; |
| 326 | say.push(`Opened #${number}${name ? ` under ${name}` : ""}.`); | |
| 327 | } else if (direction) { | |
| 386 | const numbers = opened.value.opened.map((o) => o.number); | |
| 387 | const later = new Set(fresh.flatMap((idea, i) => (waits(idea) ? [`thought${i}`] : []))); | |
| 388 | const waiting = opened.value.opened.filter((o) => later.has(o.key)).map((o) => o.number); | |
| 389 | for (const number of numbers) { | |
| 390 | const waits = waiting.includes(number); | |
| 391 | placed.push({ action: "opened", repo: slug, number, ...(pillar && !waits ? { pillar } : {}) }); | |
| 392 | if (waits) placed.push({ action: "deferred", repo: slug, number }); | |
| 393 | } | |
| 394 | const list = (ns: readonly number[]) => ns.map((n) => `#${n}`).join(", "); | |
| 395 | const now = numbers.filter((n) => !waiting.includes(n)); | |
| 396 | if (now.length > 0) say.push(`Opened ${list(now)}${name ? ` under ${name}` : ""}.`); | |
| 397 | if (waiting.length > 0) say.push(`Snoozed: you said it can wait, so ${list(waiting)} sits behind the Later gate.`); | |
| 398 | } else if (opened && direction) { | |
| 328 | 399 | comment(direction, "A thought that could not be opened as an issue:"); |
| 329 | 400 | placed.push({ action: "amended", repo: slug, number: direction.number }); |
| 330 | 401 | say.push(`Could not open it as an issue (${opened.error.message}); kept on #${direction.number}.`); |
src/http/server.ts +4
| @@ -226,6 +226,7 @@ import { forgeLinkSubmit, forgesPage } from "../web/pages/forges.ts"; | ||
| 226 | 226 | import { forgeItSubmit } from "../web/pages/forge-it.ts"; |
| 227 | 227 | import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts"; |
| 228 | 228 | import { askedPage } from "../web/studio/asked_page.ts"; |
| 229 | import { notesOverrule, notesSave, notesView } from "../web/pages/notes.ts"; | |
| 229 | 230 | import { reviewPage } from "../web/studio/review_page.ts"; |
| 230 | 231 | import { reviewAct } from "../web/studio/review_act.ts"; |
| 231 | 232 | import { |
| @@ -734,6 +735,9 @@ export function buildRouter(): Router<Handler> { | ||
| 734 | 735 | router.add("POST", "/asks/decide/:id/undo", asksDecideUndo); |
| 735 | 736 | // What people asked of the crew, and what became of it (core/asked.ts). |
| 736 | 737 | router.add("GET", "/asked", askedPage); |
| 738 | router.add("GET", "/notes", notesView); | |
| 739 | router.add("POST", "/notes", notesSave); | |
| 740 | router.add("POST", "/notes/:id/overrule", notesOverrule); | |
| 737 | 741 | router.add("GET", "/review", reviewPage); |
| 738 | 742 | router.add("POST", "/review/act", reviewAct); |
| 739 | 743 | // The big view, folded into the board: every old address lands on its wall mode. |
src/tui/forge.ts +9
| @@ -33,6 +33,7 @@ import { | ||
| 33 | 33 | import { chainScreen } from "./screens/chain.ts"; |
| 34 | 34 | import { canSeeMemory } from "../boards/memory.ts"; |
| 35 | 35 | import { askedScreen } from "./screens/asked.ts"; |
| 36 | import { notesScreen } from "./screens/thoughtnotes.ts"; | |
| 36 | 37 | import { deciderScreen } from "./screens/decider.ts"; |
| 37 | 38 | import { familyScreen } from "./screens/family.ts"; |
| 38 | 39 | import { familyLines, familyView } from "../spawn/family.ts"; |
| @@ -254,6 +255,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 254 | 255 | hint: "your thoughts and what became of them", |
| 255 | 256 | run: () => ctx.push(askedScreen(session, ctx)), |
| 256 | 257 | }); |
| 258 | // One box to write an idea in, each note with its live state; /notes' own door (#8696). | |
| 259 | if (session.signedIn) | |
| 260 | commands.push({ | |
| 261 | id: "thought-notes", | |
| 262 | title: "Notes", | |
| 263 | hint: "write an idea and watch it become shipped work", | |
| 264 | run: () => ctx.push(notesScreen(session, ctx)), | |
| 265 | }); | |
| 257 | 266 | if (session.signedIn) |
| 258 | 267 | commands.push({ |
| 259 | 268 | id: "decider", |
src/tui/screens/thoughtnotes.ts added +134
| @@ -0,0 +1,134 @@ | ||
| 1 | /** | |
| 2 | * /notes in the terminal (#8696): the same door as the page. `n` writes a | |
| 3 | * note (`keepThought`, the one door the page, the composer and the MCP use), | |
| 4 | * the list is the signed-in person's own `thoughtStream` with `noteLine`'s | |
| 5 | * one-line state, and `b` takes the tap the line offers (`overruleNote`, which | |
| 6 | * refuses what the line does not offer). Everything is the person's own: a | |
| 7 | * guest has nothing, and the palette offers the screen only when signed in. | |
| 8 | * A note written here shows live on the page, being the same row. | |
| 9 | */ | |
| 10 | ||
| 11 | import { overruleNote } from "../../actions/note_overrule.ts"; | |
| 12 | import { keepThought, THOUGHT_MAX } from "../../actions/thought.ts"; | |
| 13 | import { noteLine, type NotePhase } from "../../core/note_line.ts"; | |
| 14 | import { thoughtStream } from "../../core/thought_stream.ts"; | |
| 15 | import type { User } from "../../core/users.ts"; | |
| 16 | import type { Database } from "bun:sqlite"; | |
| 17 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 18 | import { binding } from "../app/keys.ts"; | |
| 19 | import type { Session } from "../session.ts"; | |
| 20 | import { split, fill, len } from "../ui/layout.ts"; | |
| 21 | import { emptyState, input, type Tone } from "../ui/widgets.ts"; | |
| 22 | import { style } from "../term/style.ts"; | |
| 23 | import { truncate } from "../term/width.ts"; | |
| 24 | import { ListState, drawList, hitList, metaOf, relTime, type ListRow } from "./common.ts"; | |
| 25 | ||
| 26 | const SHOWN = 50; | |
| 27 | ||
| 28 | /** One line of plain text: a typed note may carry newlines or escapes the terminal must not obey. */ | |
| 29 | const plain = (text: string): string => | |
| 30 | // eslint-disable-next-line no-control-regex | |
| 31 | text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim(); | |
| 32 | ||
| 33 | const TONE: Partial<Record<NotePhase, Tone>> = { building: "attn", review: "attn", shipped: "success" }; | |
| 34 | ||
| 35 | export interface NoteRow extends ListRow { | |
| 36 | readonly id: string; | |
| 37 | /** The tap the line offers, if any. */ | |
| 38 | readonly overrule: "build" | "decline" | null; | |
| 39 | } | |
| 40 | ||
| 41 | /** The viewer's notes as rows; a guest has none. */ | |
| 42 | export function noteRows(db: Database, viewer: User | undefined): NoteRow[] { | |
| 43 | if (viewer === undefined) return []; | |
| 44 | return thoughtStream(db, viewer, { limit: SHOWN }).map((n) => { | |
| 45 | const line = noteLine(n); | |
| 46 | const tone = TONE[line.phase]; | |
| 47 | return { | |
| 48 | id: n.id, | |
| 49 | title: plain(n.text), | |
| 50 | ...(tone ? { tone } : {}), | |
| 51 | hollow: line.phase === "waiting" || line.phase === "superseded", | |
| 52 | secondary: plain(line.text + (line.links.length > 0 ? ` · ${line.links.map((l) => l.label).join(" ")}` : "") + (line.overrule ? ` · b: ${line.overrule.label}` : "")), | |
| 53 | meta: metaOf(line.phase, relTime(n.receivedAt)), | |
| 54 | overrule: line.overrule?.do ?? null, | |
| 55 | }; | |
| 56 | }); | |
| 57 | } | |
| 58 | ||
| 59 | export function notesScreen(f: Session, ctx: Ctx): Screen { | |
| 60 | const state = new ListState(); | |
| 61 | let rows: NoteRow[] = []; | |
| 62 | let typing = false; | |
| 63 | let draft = ""; | |
| 64 | const read = (): void => { | |
| 65 | rows = noteRows(f.db, f.user); | |
| 66 | state.clamp(rows.length); | |
| 67 | }; | |
| 68 | const save = (): void => { | |
| 69 | if (f.user === undefined || draft.trim() === "") return; | |
| 70 | const kept = keepThought(f.app, f.user, draft, "composer"); | |
| 71 | if (!kept.ok) { | |
| 72 | ctx.toast(`A note is at most ${THOUGHT_MAX} characters`, "attn"); | |
| 73 | return; | |
| 74 | } | |
| 75 | draft = ""; | |
| 76 | typing = false; | |
| 77 | read(); | |
| 78 | state.index = 0; | |
| 79 | state.offset = 0; | |
| 80 | ctx.toast("Saved. The planner will pick it up.", "success"); | |
| 81 | }; | |
| 82 | const overrule = (): void => { | |
| 83 | const row = rows[state.index]; | |
| 84 | if (row === undefined || row.overrule === null) return; | |
| 85 | const done = overruleNote(f.app, f.user, row.id, row.overrule); | |
| 86 | ctx.toast(done.ok ? (done.value === "accepted" ? "Built after all" : "Declined") : "That note cannot be changed now", done.ok ? "success" : "attn"); | |
| 87 | read(); | |
| 88 | }; | |
| 89 | return { | |
| 90 | title: "Notes", | |
| 91 | get keys() { | |
| 92 | return typing | |
| 93 | ? [binding(["enter"], "save"), binding(["esc"], "cancel")] | |
| 94 | : [binding(["j", "k"], "move"), binding(["n"], "write a note"), binding(["b"], "overrule the planner"), binding(["r"], "refresh")]; | |
| 95 | }, | |
| 96 | load: async () => read(), | |
| 97 | render(area, s) { | |
| 98 | const c = ctx.theme.c; | |
| 99 | const [head, body] = split(area, "v", [len(typing ? 2 : 1), fill(1)]); | |
| 100 | if (typing) { | |
| 101 | input(s, { ...head!, y: head!.y }, c, { value: draft, placeholder: "Write an idea. Enter saves it.", focused: true, cursor: [...draft].length }); | |
| 102 | } else s.text(head!.x, head!.y, truncate(`${rows.length} notes · n writes one`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 103 | if (rows.length === 0) { | |
| 104 | emptyState(s, body!, c, { title: "No notes yet", body: "Press n and write an idea. The crew plans it and this page says where it is." }); | |
| 105 | return; | |
| 106 | } | |
| 107 | drawList(s, body!, c, state, rows); | |
| 108 | hitList(ctx, body!, state, rows.length, {}); | |
| 109 | }, | |
| 110 | key(e) { | |
| 111 | if (typing) { | |
| 112 | if (e.key === "enter") save(); | |
| 113 | else if (e.key === "esc") typing = false; | |
| 114 | else if (e.key === "backspace") draft = [...draft].slice(0, -1).join(""); | |
| 115 | else if (!e.ctrl && !e.alt && [...e.key].length === 1) draft += e.key; | |
| 116 | ctx.invalidate(); | |
| 117 | return true; | |
| 118 | } | |
| 119 | if (state.key(e, rows.length)) return true; | |
| 120 | if (e.key === "n") typing = true; | |
| 121 | else if (e.key === "b") overrule(); | |
| 122 | else if (e.key === "r") read(); | |
| 123 | else return false; | |
| 124 | ctx.invalidate(); | |
| 125 | return true; | |
| 126 | }, | |
| 127 | paste(text) { | |
| 128 | if (!typing) return false; | |
| 129 | draft += plain(text); | |
| 130 | ctx.invalidate(); | |
| 131 | return true; | |
| 132 | }, | |
| 133 | }; | |
| 134 | } |
src/web/layout.ts +2
| @@ -239,6 +239,7 @@ function userMenu( | ||
| 239 | 239 | { label: "Your profile", href: `/${user.username}`, icon: "user" }, |
| 240 | 240 | { label: "Your repositories", href: "/", icon: "repo" }, |
| 241 | 241 | { label: "Apps", href: "/apps", icon: "rocket" }, |
| 242 | { label: "Notes", href: "/notes", icon: "comment" }, | |
| 242 | 243 | { label: "Talk", href: "/talk", icon: "comment" }, |
| 243 | 244 | { label: "Explore", href: "/explore", icon: "search" }, |
| 244 | 245 | { label: "Map", href: "/map", icon: "map" }, |
| @@ -317,6 +318,7 @@ const STATIC_PALETTE: PaletteItem[] = [ | ||
| 317 | 318 | { t: "New repository", h: "/new", c: "Create" }, |
| 318 | 319 | { t: "Migrate a repository", h: "/migrate", c: "Create" }, |
| 319 | 320 | { t: "New organization", h: "/organizations/new", c: "Create" }, |
| 321 | { t: "Notes", h: "/notes", c: "Go to" }, | |
| 320 | 322 | { t: "Talk to the forge", h: "/talk", c: "Go to" }, |
| 321 | 323 | { t: "Search", h: "/search", c: "Go to" }, |
| 322 | 324 | { t: "Account settings", h: "/settings", c: "Settings" }, |
src/web/pages/notes.ts added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | /** | |
| 2 | * /notes (#8696): what a person wrote, newest first, each with its live state | |
| 3 | * in one line. One box: typing and Enter keeps the note (`keepThought`, the | |
| 4 | * same door the composer and the MCP use); the planner turns it into issues | |
| 5 | * on its own (`crew/triage.ts`) and the line under it follows them from | |
| 6 | * planned to shipped (`core/note_line.ts`). Server-rendered; the script only | |
| 7 | * makes Enter send, Shift+Enter a new line. Everything shown is the signed-in | |
| 8 | * person's own. | |
| 9 | */ | |
| 10 | ||
| 11 | import { overruleNote } from "../../actions/note_overrule.ts"; | |
| 12 | import { keepThought, THOUGHT_MAX } from "../../actions/thought.ts"; | |
| 13 | import { noteLine, type NotePhase } from "../../core/note_line.ts"; | |
| 14 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 15 | import { publicForgeRepo } from "../../core/forge_repo.ts"; | |
| 16 | import { thoughtStream } from "../../core/thought_stream.ts"; | |
| 17 | import type { Ctx } from "../../http/context.ts"; | |
| 18 | import { forgeChangelog } from "../../release/changelog.ts"; | |
| 19 | import { signInFirst } from "../chrome.ts"; | |
| 20 | import { readForm } from "../form.ts"; | |
| 21 | import { html } from "../html.ts"; | |
| 22 | import { flashTo, page } from "../layout.ts"; | |
| 23 | import { badge, btn, empty, listCard, row, time, type Tone } from "../ui.ts"; | |
| 24 | import { cut } from "../../actions/ask_state_words.ts"; | |
| 25 | ||
| 26 | const HERE = "/notes"; | |
| 27 | const SHOWN = 100; | |
| 28 | ||
| 29 | const TONE: Record<NotePhase, Tone> = { | |
| 30 | waiting: "default", | |
| 31 | declined: "default", | |
| 32 | duplicate: "default", | |
| 33 | snoozed: "default", | |
| 34 | planned: "default", | |
| 35 | building: "attn", | |
| 36 | review: "attn", | |
| 37 | shipped: "success", | |
| 38 | superseded: "default", | |
| 39 | }; | |
| 40 | ||
| 41 | /** Static code, never built from user text. */ | |
| 42 | export const notesJs = ` | |
| 43 | const form = document.querySelector("form.note-box"); | |
| 44 | if (form) { | |
| 45 | const box = form.querySelector("textarea"); | |
| 46 | box.addEventListener("keydown", (e) => { | |
| 47 | if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { | |
| 48 | e.preventDefault(); | |
| 49 | if (box.value.trim() !== "") form.requestSubmit(); | |
| 50 | } | |
| 51 | }); | |
| 52 | } | |
| 53 | `; | |
| 54 | ||
| 55 | const css = /* css */ ` | |
| 56 | .note-box { display: grid; gap: 6px; margin-bottom: 1rem; } | |
| 57 | .note-box textarea { width: 100%; min-height: 3.5rem; font-size: 16px; box-sizing: border-box; resize: vertical; } | |
| 58 | .note-line a { margin-right: 6px; } | |
| 59 | `; | |
| 60 | ||
| 61 | // GET /notes | |
| 62 | export async function notesView(ctx: Ctx): Promise<Response> { | |
| 63 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 64 | const user = ctx.auth.user; | |
| 65 | if (user === undefined) return signInFirst(ctx); | |
| 66 | const notes = timePhase(ctx.request, "notes", () => thoughtStream(ctx.app.db, user, { limit: SHOWN })); | |
| 67 | // The release that carried a pull is known only for the forge's own repository. | |
| 68 | const own = publicForgeRepo(ctx.app); | |
| 69 | const shipped = notes.some((n) => n.state === "shipped"); | |
| 70 | const entries = own !== undefined && shipped ? ((await forgeChangelog(ctx.app))?.entries ?? []) : []; | |
| 71 | const ownSlug = own === undefined ? "" : `${own.ownerName}/${own.name}`; | |
| 72 | const versionOf = (repo: string, pull: number) => | |
| 73 | repo === ownSlug ? entries.find((e) => e.pulls.some((p) => p.number === pull))?.version : undefined; | |
| 74 | const rows = notes.map((n) => { | |
| 75 | const line = noteLine(n, versionOf); | |
| 76 | return row({ | |
| 77 | title: cut(n.text, 200), | |
| 78 | titleAttr: n.text, | |
| 79 | meta: html`<span class="note-line">${time(n.receivedAt)} · ${line.text}${line.links.length === 0 ? null : " · "}${line.links.map( | |
| 80 | (l) => html`<a href="${l.href}">${l.label}</a>`, | |
| 81 | )}</span>`, | |
| 82 | trail: badge(line.phase === "review" ? "In review" : line.phase[0]!.toUpperCase() + line.phase.slice(1), { tone: TONE[line.phase] }), | |
| 83 | ...(line.overrule === null | |
| 84 | ? {} | |
| 85 | : { | |
| 86 | after: html`<form method="post" action="${HERE}/${n.id}/overrule"> | |
| 87 | <input type="hidden" name="do" value="${line.overrule.do}" /> | |
| 88 | ${btn(line.overrule.label, { title: "Overrule the planner" })} | |
| 89 | </form>`, | |
| 90 | }), | |
| 91 | }); | |
| 92 | }); | |
| 93 | const body = html`<div class="page-head"><h1>Notes</h1></div> | |
| 94 | <form class="note-box" method="post" action="${HERE}"> | |
| 95 | <textarea class="input" name="text" rows="2" maxlength="${THOUGHT_MAX}" required enterkeyhint="send" autofocus placeholder="Write an idea. Enter saves it." aria-label="A note"></textarea> | |
| 96 | <div class="bar"><span class="muted small">Enter saves · Shift+Enter for a new line</span><span class="spacer"></span>${btn("Save", { variant: "primary" })}</div> | |
| 97 | </form> | |
| 98 | ${rows.length === 0 | |
| 99 | ? empty({ icon: "comment", title: "No notes yet", note: "Write an idea above. The crew plans it, builds it, and this page says where it is." }) | |
| 100 | : listCard(rows)}`; | |
| 101 | return timePhase(ctx.request, "render", () => | |
| 102 | page({ ctx, title: "Notes", body, styleCss: css, scriptJs: notesJs, description: "Write an idea; watch it become shipped work." }), | |
| 103 | ); | |
| 104 | } | |
| 105 | ||
| 106 | // POST /notes | |
| 107 | export async function notesSave(ctx: Ctx): Promise<Response> { | |
| 108 | const user = ctx.auth.user; | |
| 109 | if (user === undefined) return signInFirst(ctx); | |
| 110 | const form = await readForm(ctx, "rw"); | |
| 111 | if (form instanceof Response) return form; | |
| 112 | const text = form.get("text"); | |
| 113 | if (typeof text !== "string" || text.trim() === "") | |
| 114 | return flashTo(ctx, HERE, { tone: "danger", message: "Write something first." }); | |
| 115 | const kept = keepThought(ctx.app, user, text, "composer"); | |
| 116 | if (!kept.ok) | |
| 117 | return flashTo(ctx, HERE, { tone: "danger", message: `A note is at most ${THOUGHT_MAX} characters.` }); | |
| 118 | return flashTo(ctx, HERE, { tone: "success", message: "Saved. The planner will pick it up." }); | |
| 119 | } | |
| 120 | ||
| 121 | const OVERRULED = { accepted: "Built after all.", declined: "Declined." } as const; | |
| 122 | ||
| 123 | // POST /notes/:id/overrule | |
| 124 | export async function notesOverrule(ctx: Ctx): Promise<Response> { | |
| 125 | const user = ctx.auth.user; | |
| 126 | if (user === undefined) return signInFirst(ctx); | |
| 127 | const form = await readForm(ctx, "rw"); | |
| 128 | if (form instanceof Response) return form; | |
| 129 | const done = overruleNote(ctx.app, user, ctx.params["id"] ?? "", form.get("do")); | |
| 130 | if (!done.ok) return flashTo(ctx, HERE, { tone: "danger", message: "That note cannot be changed now." }); | |
| 131 | return flashTo(ctx, HERE, { tone: "success", message: OVERRULED[done.value] }); | |
| 132 | } |
src/web/studio/living.ts +1
| @@ -112,6 +112,7 @@ export function tellBox(ctx: Ctx, user: User): Html { | ||
| 112 | 112 | <option value="${FORGE_TARGET}">the forge</option> |
| 113 | 113 | </select>`} |
| 114 | 114 | ${btn("Tell", { variant: "primary" })} |
| 115 | ${btn("Notes", { href: "/notes", icon: "comment", title: "Your notes and where they stand" })} | |
| 115 | 116 | ${btn("Talk", { href: "/talk", icon: "comment", title: "Talk to the forge" })} |
| 116 | 117 | </form>`; |
| 117 | 118 | } |
test/forge-looks.test.ts +1
| @@ -20,6 +20,7 @@ quietLogs(); | ||
| 20 | 20 | |
| 21 | 21 | /** Top-level routes that are not pages, or are not worth a picture, and why. */ |
| 22 | 22 | const NOT_A_PAGE: Record<string, string> = { |
| 23 | "/asked": "no picture: the cap of 24 looks went to /notes, the page that now leads with a person's thoughts; /asked is covered by test/asked.test.ts", | |
| 23 | 24 | "/llms.txt": "plain markdown for machines, not a page to picture", |
| 24 | 25 | "/robots.txt": "plain text for crawlers", |
| 25 | 26 | "/openapi.json": "a redirect to the contract document", |
test/notes.test.ts added +265
| @@ -0,0 +1,265 @@ | ||
| 1 | /** | |
| 2 | * Notes (#8696): /notes lists what a person wrote, newest first, saves on | |
| 3 | * POST, and shows each note's live state in one line; the planner splits a | |
| 4 | * dump into several issues and attaches an idea to an issue it touches. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { openIssue } from "../src/actions/issues.ts"; | |
| 12 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 13 | import { keepThought } from "../src/actions/thought.ts"; | |
| 14 | import { noteLine } from "../src/core/note_line.ts"; | |
| 15 | import { getIssue } from "../src/core/issues.ts"; | |
| 16 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 17 | import { createSession } from "../src/core/sessions.ts"; | |
| 18 | import { thoughtStream, type PlacedLink, type StreamThought } from "../src/core/thought_stream.ts"; | |
| 19 | import { createUser, type User } from "../src/core/users.ts"; | |
| 20 | import { placeThought, splitIdeas, attachTarget } from "../src/crew/triage.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | const link = (o: Partial<PlacedLink> = {}): PlacedLink => ({ | |
| 27 | action: "opened", repo: "alice/forge", number: 5, readable: true, title: "t", issueState: "open", pulls: [], running: false, ...o, | |
| 28 | }); | |
| 29 | const note = (placed: PlacedLink[], o: Partial<StreamThought> = {}): StreamThought => ({ | |
| 30 | id: "n", text: "x", source: "composer", receivedAt: "2026-01-01T00:00:00Z", kind: "work", reply: null, asked: false, | |
| 31 | placedAt: "2026-01-01T00:01:00Z", placed, state: "placed", flagged: false, overruled: null, supersededBy: null, repeats: null, supersedes: [], ...o, | |
| 32 | }); | |
| 33 | ||
| 34 | describe("note line", () => { | |
| 35 | test("one line per state, with links", () => { | |
| 36 | expect(noteLine(note([], { placedAt: null })).text).toBe("Waiting for the planner"); | |
| 37 | expect(noteLine(note([link()])).text).toBe("Planned"); | |
| 38 | expect(noteLine(note([link({ running: true })])).text).toBe("Building"); | |
| 39 | const review = noteLine(note([link({ pulls: [{ number: 9, state: "open" }] })])); | |
| 40 | expect(review.text).toBe("In review"); | |
| 41 | expect(review.links.map((l) => l.href)).toEqual(["/alice/forge/issues/5", "/alice/forge/pulls/9"]); | |
| 42 | const merged = [link({ pulls: [{ number: 9, state: "merged" }] })]; | |
| 43 | expect(noteLine(note(merged, { state: "shipped" }), (r, p) => (r === "alice/forge" && p === 9 ? "0.7.9" : undefined)).text).toBe("Shipped in version 0.7.9"); | |
| 44 | expect(noteLine(note(merged, { state: "shipped" })).text).toBe("Shipped"); | |
| 45 | }); | |
| 46 | test("a split dump counts its issues; a vision is kept", () => { | |
| 47 | const l = noteLine(note([link(), link({ number: 6, pulls: [{ number: 9, state: "merged" }] }), link({ number: 7, running: true })])); | |
| 48 | expect(l.text).toBe("3 issues: 1 shipped, 1 building, 1 planned"); | |
| 49 | expect(l.phase).toBe("planned"); | |
| 50 | expect(noteLine(note([], { reply: "Kept as a note: what should change?" })).phase).toBe("declined"); | |
| 51 | }); | |
| 52 | }); | |
| 53 | ||
| 54 | describe("verdicts", () => { | |
| 55 | test("decline, duplicate and snooze say which and why, each with one tap to overrule", () => { | |
| 56 | const declined = noteLine(note([], { reply: "Kept as a note: what should change?" })); | |
| 57 | expect(declined.text).toBe("Declined: Kept as a note: what should change?"); | |
| 58 | expect(declined.overrule?.do).toBe("build"); | |
| 59 | const dup = noteLine(note([link({ action: "linked", running: true })], { reply: "You asked this on 2026-01-01; #5 is open.", repeats: { id: "e", text: "x", receivedAt: "" } })); | |
| 60 | expect(dup.phase).toBe("duplicate"); | |
| 61 | expect(dup.text).toContain("You asked this on 2026-01-01"); | |
| 62 | expect(dup.overrule?.do).toBe("build"); | |
| 63 | const snoozed = noteLine(note([link(), link({ action: "deferred" })], { reply: "Snoozed: you said it can wait." })); | |
| 64 | expect(snoozed.phase).toBe("snoozed"); | |
| 65 | expect(snoozed.overrule?.do).toBe("build"); | |
| 66 | expect(noteLine(note([link()])).overrule?.do).toBe("decline"); | |
| 67 | expect(noteLine(note([link({ running: true })])).overrule).toBeNull(); | |
| 68 | expect(noteLine(note([link()], { overruled: "declined" })).text).toBe("Declined by you"); | |
| 69 | expect(noteLine(note([link(), link({ action: "deferred" })], { overruled: "accepted" })).phase).toBe("planned"); | |
| 70 | }); | |
| 71 | }); | |
| 72 | ||
| 73 | describe("splitIdeas / attachTarget", () => { | |
| 74 | test("bullets and paragraphs split; one idea stays whole", () => { | |
| 75 | expect(splitIdeas("things:\n- make the search faster\n- add dark mode toggle\n in settings\n- show build time")).toEqual([ | |
| 76 | "make the search faster", "add dark mode toggle\nin settings", "show build time", | |
| 77 | ]); | |
| 78 | expect(splitIdeas("make the search faster please\n\nadd a dark mode toggle")).toHaveLength(2); | |
| 79 | expect(splitIdeas("just one idea about the search box")).toEqual(["just one idea about the search box"]); | |
| 80 | }); | |
| 81 | test("attaches only on a close match that is not a reversal", () => { | |
| 82 | const open = [{ number: 4, title: "make the search box visible on tablets" }]; | |
| 83 | expect(attachTarget("also make search box visible on tablets please", open)?.number).toBe(4); | |
| 84 | expect(attachTarget("make the search box hidden on tablets", open)).toBeUndefined(); | |
| 85 | expect(attachTarget("paint the footer green", open)).toBeUndefined(); | |
| 86 | }); | |
| 87 | }); | |
| 88 | ||
| 89 | describe("notes", () => { | |
| 90 | let dir: string; | |
| 91 | let app: App; | |
| 92 | let servers: Servers; | |
| 93 | let alice: User; | |
| 94 | let bob: User; | |
| 95 | let repo: Repo; | |
| 96 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 97 | const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`; | |
| 98 | ||
| 99 | beforeAll(async () => { | |
| 100 | dir = await mkdtemp(join(tmpdir(), "forge-notes-")); | |
| 101 | app = createApp({ dataDir: dir, port: 0 }); | |
| 102 | const mk = async (username: string, first = false) => { | |
| 103 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 104 | if (!u.ok) throw new Error(String(u.error)); | |
| 105 | return u.value; | |
| 106 | }; | |
| 107 | alice = await mk("alice", true); | |
| 108 | bob = await mk("bob"); | |
| 109 | const r = await createRepo(app, alice, { name: "forge", visibility: "private" }); | |
| 110 | if (!r.ok) throw new Error(String(r.error)); | |
| 111 | repo = r.value; | |
| 112 | await updateRepoSettings(app, alice, repo, { autonomy: "autonomous", autonomyDirection: "Steer by thought.", autonomyCeilingUsd: 10, autonomySlots: 1, autonomyBy: alice.id }); | |
| 113 | servers = await serve(app); | |
| 114 | }); | |
| 115 | afterAll(async () => { | |
| 116 | servers.stop(); | |
| 117 | await rm(dir, { recursive: true, force: true }); | |
| 118 | }); | |
| 119 | ||
| 120 | const post = (u: User | undefined, text: string, origin = servers.url) => | |
| 121 | fetch(`${servers.url}/notes`, { | |
| 122 | method: "POST", redirect: "manual", | |
| 123 | headers: { origin, ...(u ? { cookie: cookie(u) } : {}) }, | |
| 124 | body: new URLSearchParams({ text }), | |
| 125 | }); | |
| 126 | const get = (u?: User) => fetch(`${servers.url}/notes`, { redirect: "manual", headers: u ? { cookie: cookie(u) } : {} }); | |
| 127 | ||
| 128 | test("signed out goes to sign in", async () => { | |
| 129 | expect((await get()).status).toBe(303); | |
| 130 | expect((await post(undefined, "hello there")).status).toBe(303); | |
| 131 | }); | |
| 132 | ||
| 133 | test("saves a note, newest first, escaped, private to its author", async () => { | |
| 134 | expect((await post(alice, "first idea about search")).status).toBe(303); | |
| 135 | await Bun.sleep(5); | |
| 136 | expect((await post(alice, "<b>second</b> idea about tablets")).status).toBe(303); | |
| 137 | const html = await (await get(alice)).text(); | |
| 138 | expect(html).toContain("Waiting for the planner"); | |
| 139 | expect(html).toContain("<b>second</b>"); | |
| 140 | expect(html).not.toContain("<b>second</b>"); | |
| 141 | expect(html.indexOf("second")).toBeLessThan(html.indexOf("first idea")); | |
| 142 | expect(html).toContain('enterkeyhint="send"'); | |
| 143 | expect(await (await get(bob)).text()).not.toContain("first idea"); | |
| 144 | }); | |
| 145 | ||
| 146 | test("refuses empty, oversized and cross-origin writes", async () => { | |
| 147 | const before = thoughtStream(app.db, alice).length; | |
| 148 | expect((await post(alice, " ")).status).toBe(303); | |
| 149 | expect((await post(alice, "x".repeat(20_001))).status).toBe(303); | |
| 150 | expect((await post(alice, "sneaky", "https://evil.example")).status).toBe(403); | |
| 151 | expect(thoughtStream(app.db, alice).length).toBe(before); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a dump becomes several issues linked back, and the page says so", async () => { | |
| 155 | const kept = keepThought(app, alice, "- build a sparkline for spend per day\n- show the queue depth on the front page\n- export the audit log as csv", "composer"); | |
| 156 | if (!kept.ok) throw new Error(kept.error); | |
| 157 | const p = await placeThought(app, fresh(), alice, { id: kept.value.id, text: "- build a sparkline for spend per day\n- show the queue depth on the front page\n- export the audit log as csv" }); | |
| 158 | expect(p.placed.filter((x) => x.action === "opened")).toHaveLength(3); | |
| 159 | const issue = getIssue(app.db, repo.id, p.placed[0]!.number)!; | |
| 160 | expect(issue.body).toContain(kept.value.id); | |
| 161 | const html = await (await get(alice)).text(); | |
| 162 | expect(html).toContain("3 issues: 3 planned"); | |
| 163 | expect(html).toContain(`/alice/forge/issues/${p.placed[0]!.number}`); | |
| 164 | }); | |
| 165 | ||
| 166 | test("an idea that touches an open issue attaches rather than duplicates", async () => { | |
| 167 | const opened = openIssue(app, alice, fresh(), { title: "make the tablet toolbar sticky while scrolling", body: "" }); | |
| 168 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 169 | const text = "please make the tablet toolbar sticky while scrolling"; | |
| 170 | const kept = keepThought(app, alice, text, "composer"); | |
| 171 | if (!kept.ok) throw new Error(kept.error); | |
| 172 | const p = await placeThought(app, fresh(), alice, { id: kept.value.id, text }); | |
| 173 | expect(p.placed).toEqual([{ action: "amended", repo: "alice/forge", number: opened.value.number }]); | |
| 174 | }); | |
| 175 | ||
| 176 | const overrule = (u: User, id: string, to: string) => | |
| 177 | fetch(`${servers.url}/notes/${id}/overrule`, { method: "POST", redirect: "manual", headers: { origin: servers.url, cookie: cookie(u) }, body: new URLSearchParams({ do: to }) }); | |
| 178 | const rowOf = (id: string) => thoughtStream(app.db, alice).find((n) => n.id === id)!; | |
| 179 | ||
| 180 | test("a note that can wait is snoozed with its reason, and one tap lets it out", async () => { | |
| 181 | const gate = openIssue(app, alice, fresh(), { title: "Later gate: wait here" }); | |
| 182 | if (!gate.ok) throw new Error(String(gate.error)); | |
| 183 | const text = "someday add a keyboard shortcut cheatsheet overlay"; | |
| 184 | const kept = keepThought(app, alice, text, "composer"); | |
| 185 | if (!kept.ok) throw new Error(kept.error); | |
| 186 | const p = await placeThought(app, fresh(), alice, { id: kept.value.id, text }); | |
| 187 | expect(p.placed.map((x) => x.action)).toEqual(["opened", "deferred"]); | |
| 188 | const html = await (await get(alice)).text(); | |
| 189 | expect(html).toContain("Snoozed: you said it can wait"); | |
| 190 | expect(html).toContain("Build it anyway"); | |
| 191 | expect((await overrule(bob, kept.value.id, "build")).status).toBe(303); | |
| 192 | expect(rowOf(kept.value.id).overruled).toBeNull(); | |
| 193 | expect((await overrule(alice, kept.value.id, "build")).status).toBe(303); | |
| 194 | expect(rowOf(kept.value.id).overruled).toBe("accepted"); | |
| 195 | expect(noteLine(rowOf(kept.value.id)).phase).toBe("planned"); | |
| 196 | }); | |
| 197 | ||
| 198 | test("a guest and a cross-origin post cannot overrule", async () => { | |
| 199 | const kept = keepThought(app, alice, "alice/forge", "composer"); | |
| 200 | if (!kept.ok) throw new Error(kept.error); | |
| 201 | await placeThought(app, fresh(), alice, { id: kept.value.id, text: "alice/forge" }); | |
| 202 | const url = `${servers.url}/notes/${kept.value.id}/overrule`; | |
| 203 | const guest = await fetch(url, { method: "POST", redirect: "manual", headers: { origin: servers.url }, body: new URLSearchParams({ do: "build" }) }); | |
| 204 | expect(guest.status).not.toBe(200); | |
| 205 | const cross = await fetch(url, { method: "POST", redirect: "manual", headers: { origin: "https://evil.example", cookie: cookie(alice) }, body: new URLSearchParams({ do: "build" }) }); | |
| 206 | expect(cross.status).toBe(403); | |
| 207 | expect(rowOf(kept.value.id).overruled).toBeNull(); | |
| 208 | expect(rowOf(kept.value.id).placed).toHaveLength(0); | |
| 209 | }); | |
| 210 | ||
| 211 | test("a second build tap, or a build on an accepted note, opens nothing more", async () => { | |
| 212 | const kept = keepThought(app, alice, "alice/forge", "composer"); | |
| 213 | if (!kept.ok) throw new Error(kept.error); | |
| 214 | await placeThought(app, fresh(), alice, { id: kept.value.id, text: "alice/forge" }); | |
| 215 | await overrule(alice, kept.value.id, "build"); | |
| 216 | await overrule(alice, kept.value.id, "build"); | |
| 217 | expect(rowOf(kept.value.id).placed).toHaveLength(1); | |
| 218 | const text = "list the stale branches on the repository page"; | |
| 219 | const k2 = keepThought(app, alice, text, "composer"); | |
| 220 | if (!k2.ok) throw new Error(k2.error); | |
| 221 | await placeThought(app, fresh(), alice, { id: k2.value.id, text }); | |
| 222 | const before = rowOf(k2.value.id).placed.length; | |
| 223 | await overrule(alice, k2.value.id, "build"); | |
| 224 | expect(rowOf(k2.value.id).placed).toHaveLength(before); | |
| 225 | expect(rowOf(k2.value.id).overruled).toBeNull(); | |
| 226 | }); | |
| 227 | ||
| 228 | test("one 'someday' bullet snoozes its own idea only, and 'backlog' is an ordinary word", async () => { | |
| 229 | const gate = openIssue(app, alice, fresh(), { title: "Later gate: wait here" }); | |
| 230 | if (!gate.ok) throw new Error(String(gate.error)); | |
| 231 | const text = "- surface the backlog totals within the sidebar widget\n- someday add a printable receipt view\n- add an export button to the audit trail"; | |
| 232 | const kept = keepThought(app, alice, text, "composer"); | |
| 233 | if (!kept.ok) throw new Error(kept.error); | |
| 234 | const p = await placeThought(app, fresh(), alice, { id: kept.value.id, text }); | |
| 235 | expect(p.placed.filter((x) => x.action === "opened")).toHaveLength(3); | |
| 236 | const deferred = p.placed.filter((x) => x.action === "deferred"); | |
| 237 | expect(deferred).toHaveLength(1); | |
| 238 | const wait = getIssue(app.db, repo.id, deferred[0]!.number)!; | |
| 239 | expect(wait.body).toContain("printable receipt"); | |
| 240 | }); | |
| 241 | ||
| 242 | test("a declined note is built after all on one tap", async () => { | |
| 243 | const kept = keepThought(app, alice, "alice/forge", "composer"); | |
| 244 | if (!kept.ok) throw new Error(kept.error); | |
| 245 | await placeThought(app, fresh(), alice, { id: kept.value.id, text: "alice/forge" }); | |
| 246 | expect(noteLine(rowOf(kept.value.id)).phase).toBe("declined"); | |
| 247 | expect((await overrule(alice, kept.value.id, "build")).status).toBe(303); | |
| 248 | const after = rowOf(kept.value.id); | |
| 249 | expect(after.placed.map((x) => x.action)).toEqual(["opened"]); | |
| 250 | expect(noteLine(after).phase).toBe("planned"); | |
| 251 | }); | |
| 252 | ||
| 253 | test("an accepted note is declined on one tap: its issue closes", async () => { | |
| 254 | const text = "render the pull list as a compact table"; | |
| 255 | const kept = keepThought(app, alice, text, "composer"); | |
| 256 | if (!kept.ok) throw new Error(kept.error); | |
| 257 | const p = await placeThought(app, fresh(), alice, { id: kept.value.id, text }); | |
| 258 | const number = p.placed[0]!.number; | |
| 259 | expect(noteLine(rowOf(kept.value.id)).overrule?.do).toBe("decline"); | |
| 260 | expect((await overrule(alice, kept.value.id, "decline")).status).toBe(303); | |
| 261 | expect(getIssue(app.db, repo.id, number)!.state).toBe("closed"); | |
| 262 | expect(noteLine(rowOf(kept.value.id)).text).toBe("Declined by you"); | |
| 263 | expect((await overrule(alice, kept.value.id, "nonsense")).status).toBe(303); | |
| 264 | }); | |
| 265 | }); |
test/palette-roster.test.ts +3
| @@ -62,6 +62,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 62 | 62 | ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"], |
| 63 | 63 | ["new-forge", "New forge", "spawn a client's forge and get its claim link"], |
| 64 | 64 | ["asked", "Asked", "your thoughts and what became of them"], |
| 65 | ["thought-notes", "Notes", "write an idea and watch it become shipped work"], | |
| 65 | 66 | ["decider", "Decider", "what it closed, deferred and asked you about today"], |
| 66 | 67 | ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"], |
| 67 | 68 | ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"], |
| @@ -140,6 +141,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 140 | 141 | ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"], |
| 141 | 142 | ["new-forge", "New forge", "spawn a client's forge and get its claim link"], |
| 142 | 143 | ["asked", "Asked", "your thoughts and what became of them"], |
| 144 | ["thought-notes", "Notes", "write an idea and watch it become shipped work"], | |
| 143 | 145 | ["decider", "Decider", "what it closed, deferred and asked you about today"], |
| 144 | 146 | ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"], |
| 145 | 147 | ["account", "Account settings", undefined], |
| @@ -196,6 +198,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 196 | 198 | ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"], |
| 197 | 199 | ["new-forge", "New forge", "spawn a client's forge and get its claim link"], |
| 198 | 200 | ["asked", "Asked", "your thoughts and what became of them"], |
| 201 | ["thought-notes", "Notes", "write an idea and watch it become shipped work"], | |
| 199 | 202 | ["decider", "Decider", "what it closed, deferred and asked you about today"], |
| 200 | 203 | ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"], |
| 201 | 204 | ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"], |
test/tui-thought-notes.test.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | /** | |
| 2 | * /notes in the terminal (#8696): a note written there is the same row the | |
| 3 | * page lists, a guest has nothing, and the overrule is the line's own tap. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { thoughtStream } from "../src/core/thought_stream.ts"; | |
| 11 | import { createUser, type User } from "../src/core/users.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import { notesScreen, noteRows } from "../src/tui/screens/thoughtnotes.ts"; | |
| 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dir: string; | |
| 21 | let app: App; | |
| 22 | let alice: User; | |
| 23 | let bob: User; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dir = await mkdtemp(join(tmpdir(), "forge-tui-thought-notes-")); | |
| 27 | app = createApp({ dataDir: dir, port: 0 }); | |
| 28 | const mk = async (username: string, first = false) => { | |
| 29 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", ...(first ? { admin: true } : {}) }); | |
| 30 | if (!r.ok) throw new Error(String(r.error)); | |
| 31 | return r.value; | |
| 32 | }; | |
| 33 | alice = await mk("alice", true); | |
| 34 | bob = await mk("bob"); | |
| 35 | }); | |
| 36 | afterAll(async () => { | |
| 37 | app.close(); | |
| 38 | await rm(dir, { recursive: true, force: true }); | |
| 39 | }); | |
| 40 | ||
| 41 | async function drive(user: User | undefined, keys: string): Promise<string> { | |
| 42 | const terminal = headlessTerminal(120, 30); | |
| 43 | const { app: tui, session } = createForgeApp( | |
| 44 | app, | |
| 45 | { sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 46 | { terminal, exitHooks: false }, | |
| 47 | ); | |
| 48 | tui.start(); | |
| 49 | tui.ctx.push(notesScreen(session, tui.ctx)); | |
| 50 | await settleApp(tui); | |
| 51 | terminal.feed(keys); | |
| 52 | await settleApp(tui); | |
| 53 | const text = tui.screen.toText(); | |
| 54 | tui.stop?.(); | |
| 55 | return text; | |
| 56 | } | |
| 57 | ||
| 58 | test("a note written in the terminal is the page's row, and only its author's", async () => { | |
| 59 | const text = await drive(alice, "nbuild a sparkline for spend\r"); | |
| 60 | expect(text).toContain("build a sparkline for spend"); | |
| 61 | expect(text).toContain("Waiting for the planner"); | |
| 62 | expect(thoughtStream(app.db, alice).map((n) => n.text)).toEqual(["build a sparkline for spend"]); | |
| 63 | expect(noteRows(app.db, bob)).toEqual([]); | |
| 64 | expect(noteRows(app.db, undefined)).toEqual([]); | |
| 65 | expect(await drive(bob, "")).toContain("No notes yet"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("nothing typed saves nothing", async () => { | |
| 69 | await drive(bob, "n\r"); | |
| 70 | expect(thoughtStream(app.db, bob)).toHaveLength(0); | |
| 71 | }); |
new above
offline
Medium: build is not tied to the verdict. Any placed note that is not snoozed falls to the else branch and opens a new issue every call, including repeat taps and accepted notes. Refuse when note.overruled !== null or the note's line offers no build overrule.