Public: anyone can read this; the crew can change it.
On /asked, each thought says where it went, linked: the issue it became, kept as a note, or not placed yet, as MCP `thought` answers #7547
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7509-on-asked-each-thought-says-where-it-went → main 2 commits fork 2 threads · 2 unresolved
8 files
changed +275
−33
src/actions/ask_state_words.ts +87
| @@ -7,5 +7,92 @@ | ||
| 7 | 7 | /** An ask the planner has not yet put on any issue, and nobody has replied to. */ |
| 8 | 8 | export const NOT_PLACED_YET = "the planner has not placed it yet"; |
| 9 | 9 | |
| 10 | /** A thought the planner has not placed yet, as its row on /asked ends. */ | |
| 11 | export const NOT_PLACED = "not placed yet"; | |
| 12 | ||
| 13 | /** A thought the planner placed on no issue: a vision, a decision, a note. */ | |
| 14 | export const KEPT_AS_NOTE = "kept as a note"; | |
| 15 | ||
| 10 | 16 | /** An ask whose issue has a run going and no answer yet. */ |
| 11 | 17 | export const CREW_WORKING = "The crew is working on it."; |
| 18 | ||
| 19 | /** Text on one line, cut at the last space before `n` characters (a hard cut when it has none). */ | |
| 20 | export const cut = (raw: string, n: number): string => { | |
| 21 | const text = raw.replace(/\s+/g, " ").trim(); | |
| 22 | if (text.length <= n) return text; | |
| 23 | const space = text.lastIndexOf(" ", n - 1); | |
| 24 | return `${text.slice(0, space > 0 ? space : n - 1)}…`; | |
| 25 | }; | |
| 26 | ||
| 27 | /** One piece of where a thought went: `lead` plain, `text` linked to `href` where the surface links. */ | |
| 28 | export interface WherePart { | |
| 29 | readonly lead: string; | |
| 30 | readonly text: string; | |
| 31 | readonly href?: string; | |
| 32 | } | |
| 33 | ||
| 34 | /** What `whereItWent` reads of a thought, so the words need nothing but plain data. */ | |
| 35 | export interface ThoughtWhere { | |
| 36 | readonly reply: string | null; | |
| 37 | readonly asked: boolean; | |
| 38 | readonly placedAt: string | null; | |
| 39 | readonly placed: readonly { | |
| 40 | readonly action: string; | |
| 41 | readonly repo: string; | |
| 42 | readonly number: number; | |
| 43 | readonly pull?: number; | |
| 44 | readonly readable: boolean; | |
| 45 | readonly title: string | null; | |
| 46 | }[]; | |
| 47 | } | |
| 48 | ||
| 49 | const ACTED = new Set(["opened", "linked"]); | |
| 50 | ||
| 51 | /** | |
| 52 | * Where a thought went, as MCP `thought` answers it (#7509), in the same words | |
| 53 | * on /asked and in the terminal: `→` each issue it placed; the planner's | |
| 54 | * question when it answered with one; `kept as a note` when it placed it on | |
| 55 | * none; `not placed yet` until it has. | |
| 56 | */ | |
| 57 | export function whereItWent(t: ThoughtWhere): WherePart[] { | |
| 58 | if (t.placed.length === 0) { | |
| 59 | if (t.placedAt === null) return [{ lead: NOT_PLACED, text: "" }]; | |
| 60 | if (t.asked && t.reply) return [{ lead: t.reply, text: "" }]; | |
| 61 | return [{ lead: KEPT_AS_NOTE, text: "" }]; | |
| 62 | } | |
| 63 | return t.placed.map((x) => { | |
| 64 | const act = ACTED.has(x.action) ? "" : `${x.action} `; | |
| 65 | if (!x.readable) return { lead: `→ ${act}in a repository you cannot read`, text: "" }; | |
| 66 | if (x.title === null) return { lead: `→ ${act}${x.repo}#${x.number}`, text: "" }; | |
| 67 | return { | |
| 68 | lead: `→ ${act}`, | |
| 69 | text: `${x.repo}#${x.number} ${cut(x.title, 60)}`, | |
| 70 | href: `/${x.repo}/${x.pull === x.number ? "pulls" : "issues"}/${x.number}`, | |
| 71 | }; | |
| 72 | }); | |
| 73 | } | |
| 74 | ||
| 75 | /** What a thought repeats, takes back, or was taken back by: the words, and the other thought's time. */ | |
| 76 | export interface ThoughtNote { | |
| 77 | readonly text: string; | |
| 78 | readonly at: string; | |
| 79 | } | |
| 80 | ||
| 81 | interface Ref { | |
| 82 | readonly text: string; | |
| 83 | readonly receivedAt: string; | |
| 84 | } | |
| 85 | ||
| 86 | export function thoughtNotes(t: { | |
| 87 | readonly supersededBy: Ref | null; | |
| 88 | readonly repeats: Ref | null; | |
| 89 | readonly supersedes: readonly Ref[]; | |
| 90 | }): ThoughtNote[] { | |
| 91 | const notes: ThoughtNote[] = []; | |
| 92 | if (t.supersededBy) | |
| 93 | notes.push({ text: `superseded by “${cut(t.supersededBy.text, 60)}”`, at: t.supersededBy.receivedAt }); | |
| 94 | if (t.repeats) notes.push({ text: `repeats “${cut(t.repeats.text, 60)}”`, at: t.repeats.receivedAt }); | |
| 95 | const back = t.supersedes[0]; | |
| 96 | if (back) notes.push({ text: `takes back “${cut(back.text, 60)}”`, at: back.receivedAt }); | |
| 97 | return notes; | |
| 98 | } |
src/core/placements.ts +9 −3
| @@ -73,11 +73,17 @@ export function isVisionPull(db: Database, repo: string, number: number): boolea | ||
| 73 | 73 | return rows.some((r) => toPlacement(r).placed.some((p) => p.action === "proposed" && p.repo === repo && p.pull === number)); |
| 74 | 74 | } |
| 75 | 75 | |
| 76 | /** The placements of these thoughts: one query per chunk, never one per thought (#7509). */ | |
| 76 | 77 | export function placementsOf(db: Database, thoughtIds: readonly string[]): Map<string, Placement> { |
| 77 | 78 | const out = new Map<string, Placement>(); |
| 78 | for (const id of thoughtIds) { | |
| 79 | const p = placementOf(db, id); | |
| 80 | if (p) out.set(id, p); | |
| 79 | const unique = [...new Set(thoughtIds)]; | |
| 80 | const CHUNK = 400; | |
| 81 | for (let i = 0; i < unique.length; i += CHUNK) { | |
| 82 | const batch = unique.slice(i, i + CHUNK); | |
| 83 | const rows = db | |
| 84 | .query<Row, string[]>(`SELECT * FROM placement WHERE thought_id IN (${batch.map(() => "?").join(",")})`) | |
| 85 | .all(...batch); | |
| 86 | for (const r of rows) out.set(r.thought_id, toPlacement(r)); | |
| 81 | 87 | } |
| 82 | 88 | return out; |
| 83 | 89 | } |
src/core/thought_stream.ts +3
| @@ -49,6 +49,8 @@ export interface StreamThought { | ||
| 49 | 49 | readonly receivedAt: string; |
| 50 | 50 | readonly kind: Kind | null; |
| 51 | 51 | readonly reply: string | null; |
| 52 | /** The planner's reply is a question back to the person, not a placement. */ | |
| 53 | readonly asked: boolean; | |
| 52 | 54 | readonly placedAt: string | null; |
| 53 | 55 | readonly placed: readonly PlacedLink[]; |
| 54 | 56 | readonly state: ThoughtState; |
| @@ -169,6 +171,7 @@ export function thoughtStream( | ||
| 169 | 171 | receivedAt: t.receivedAt, |
| 170 | 172 | kind: p?.kind ?? null, |
| 171 | 173 | reply: p?.reply ?? null, |
| 174 | asked: p?.asked ?? false, | |
| 172 | 175 | placedAt: p?.placedAt ?? null, |
| 173 | 176 | placed: links, |
| 174 | 177 | state, |
src/tui/screens/asked.ts +5 −5
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import type { Database } from "bun:sqlite"; |
| 14 | import { NOT_PLACED_YET } from "../../actions/ask_state_words.ts"; | |
| 14 | import { thoughtNotes, whereItWent } from "../../actions/ask_state_words.ts"; | |
| 15 | 15 | import { askedIn, askedRepos, outcomeText, type Asked, type AskedOrder, type AskedShow } from "../../core/asked.ts"; |
| 16 | 16 | import { thoughtStream, type StreamThought } from "../../core/thought_stream.ts"; |
| 17 | 17 | import type { User } from "../../core/users.ts"; |
| @@ -49,15 +49,15 @@ function toneOf(flagged: boolean, state: string): { tone?: Tone } { | ||
| 49 | 49 | return state === "building" ? { tone: "attn" } : {}; |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | /** Where it went and what it repeats or takes back, in /asked's words (`whereItWent`, `thoughtNotes`). */ | |
| 52 | 53 | function thoughtLine(t: StreamThought): AskedLine { |
| 53 | const where = t.placed.map((x) => | |
| 54 | x.readable ? `${x.action} ${x.repo} #${x.number}` : `${x.action} in a repository you cannot read`, | |
| 55 | ); | |
| 54 | const where = whereItWent(t).map((x) => `${x.lead}${x.text}`); | |
| 55 | const notes = thoughtNotes(t).map((n) => `${n.text} ${relTime(n.at)}`); | |
| 56 | 56 | return { |
| 57 | 57 | title: plain(t.text), |
| 58 | 58 | ...toneOf(t.flagged, t.state), |
| 59 | 59 | hollow: t.state === "unplaced" || t.state === "superseded", |
| 60 | secondary: plain(where.length > 0 ? where.join(" · ") : (t.reply ?? NOT_PLACED_YET)), | |
| 60 | secondary: plain([...where, ...notes].join(" · ")), | |
| 61 | 61 | meta: metaOf(t.flagged ? "nothing built" : THOUGHT_WORD[t.state], relTime(t.receivedAt)), |
| 62 | 62 | }; |
| 63 | 63 | } |
src/web/studio/asked_page.ts +10 −23
| @@ -27,7 +27,7 @@ import { | ||
| 27 | 27 | type StreamThought, |
| 28 | 28 | type ThoughtState, |
| 29 | 29 | } from "../../core/thought_stream.ts"; |
| 30 | import { NOT_PLACED_YET } from "../../actions/ask_state_words.ts"; | |
| 30 | import { cut, thoughtNotes, whereItWent } from "../../actions/ask_state_words.ts"; | |
| 31 | 31 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 32 | 32 | import type { Ctx } from "../../http/context.ts"; |
| 33 | 33 | import { repoBase, signInFirst } from "../chrome.ts"; |
| @@ -53,18 +53,12 @@ const THOUGHT_WORD: Record<ThoughtState, { text: string; tone: Tone }> = { | ||
| 53 | 53 | superseded: { text: "Superseded", tone: "default" }, |
| 54 | 54 | }; |
| 55 | 55 | |
| 56 | /** Where a thought went: each issue it touched, linked when the viewer may read it. */ | |
| 56 | /** Where a thought went (`whereItWent`), each issue linked when the viewer may read it. */ | |
| 57 | 57 | function placedLinks(t: StreamThought): Html { |
| 58 | const links = t.placed.map((x) => | |
| 59 | !x.readable | |
| 60 | ? html`${x.action} in a repository you cannot read` | |
| 61 | : x.title === null | |
| 62 | ? html`${x.action} ${x.repo} #${x.number}` | |
| 63 | : html`${x.action} <a href="/${x.repo}/${x.pull === x.number ? "pulls" : "issues"}/${x.number}">${x.repo} #${x.number}</a>`, | |
| 58 | const parts = whereItWent(t).map((x) => | |
| 59 | x.href === undefined ? html`${x.lead}${x.text}` : html`${x.lead}<a href="${x.href}">${x.text}</a>`, | |
| 64 | 60 | ); |
| 65 | return links.length === 0 | |
| 66 | ? html`${t.reply ?? NOT_PLACED_YET}` | |
| 67 | : html`${links.map((l, i) => html`${i > 0 ? " · " : ""}${l}`)}`; | |
| 61 | return html`${parts.map((l, i) => html`${i > 0 ? " · " : ""}${l}`)}`; | |
| 68 | 62 | } |
| 69 | 63 | |
| 70 | 64 | export function thoughtRow(t: StreamThought, days: number): Html { |
| @@ -78,11 +72,9 @@ export function thoughtRow(t: StreamThought, days: number): Html { | ||
| 78 | 72 | ? `Not placed ${days} days after it was sent` |
| 79 | 73 | : `Nothing merged ${days} days after it was placed` |
| 80 | 74 | : t.text, |
| 81 | meta: html`${time(t.receivedAt)} · ${t.source} · ${placedLinks(t)}${t.supersededBy | |
| 82 | ? html` · superseded by “${t.supersededBy.text.slice(0, 80)}” ${time(t.supersededBy.receivedAt)}` | |
| 83 | : null}${t.repeats ? html` · said again, first on ${time(t.repeats.receivedAt)}` : null}${t.supersedes.length > 0 | |
| 84 | ? html` · takes back “${t.supersedes[0]!.text.slice(0, 80)}” ${time(t.supersedes[0]!.receivedAt)}` | |
| 85 | : null}`, | |
| 75 | meta: html`${time(t.receivedAt)} · ${t.source} · ${placedLinks(t)}${thoughtNotes(t).map( | |
| 76 | (n) => html` · ${n.text} ${time(n.at)}`, | |
| 77 | )}`, | |
| 86 | 78 | trail: badge(word.text, { tone: word.tone }), |
| 87 | 79 | }); |
| 88 | 80 | } |
| @@ -98,13 +90,8 @@ function oldestFlaggedHref(stream: readonly StreamThought[]): string | null { | ||
| 98 | 90 | return null; |
| 99 | 91 | } |
| 100 | 92 | |
| 101 | /** The ask, cut at the last space before 80 characters (a hard cut when it has none). */ | |
| 102 | const asking = (a: Asked): string => { | |
| 103 | const text = a.issue.title.replace(/\s+/g, " ").trim(); | |
| 104 | if (text.length <= 80) return text; | |
| 105 | const space = text.lastIndexOf(" ", 79); | |
| 106 | return `${text.slice(0, space > 0 ? space : 79)}…`; | |
| 107 | }; | |
| 93 | /** The ask, its first 80 characters. */ | |
| 94 | const asking = (a: Asked): string => cut(a.issue.title, 80); | |
| 108 | 95 | |
| 109 | 96 | /** |
| 110 | 97 | * What became of the ask, as the end of its sentence, linked to the pull or |
test/asked-placement.test.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | /** On /asked each thought says where it went (#7509): the issue, a note, or not yet, and what it repeats. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { openIssue } from "../src/actions/issues.ts"; | |
| 9 | import { createIssue } from "../src/core/issues.ts"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { placementsOf, savePlacement, type Placed } from "../src/core/placements.ts"; | |
| 12 | import { linkThoughts } from "../src/core/thought_links.ts"; | |
| 13 | import { serve } from "../src/http/server.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { promote } from "./helpers/members.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dir = ""; | |
| 21 | let app: App; | |
| 22 | let alice: User; | |
| 23 | let bob: User; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dir = await mkdtemp(join(tmpdir(), "asked-placement-")); | |
| 27 | app = createApp({ dataDir: dir, port: 0 }); | |
| 28 | const mk = async (username: string, first = false) => { | |
| 29 | const u = await createUser(app.db, { | |
| 30 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 31 | password: "a-good-passphrase", adminIfFirst: first, | |
| 32 | }); | |
| 33 | if (!u.ok) throw new Error(u.error); | |
| 34 | promote(app.db, u.value); | |
| 35 | return u.value; | |
| 36 | }; | |
| 37 | bob = await mk("bob", true); // the forge's admin, first | |
| 38 | alice = await mk("alice"); | |
| 39 | }); | |
| 40 | afterAll(async () => { | |
| 41 | app.db.close(); | |
| 42 | await rm(dir, { recursive: true, force: true }); | |
| 43 | }); | |
| 44 | ||
| 45 | const think = (id: string, who: User, text: string, minutesAgo: number) => | |
| 46 | app.db | |
| 47 | .query("INSERT INTO thought (id, author, text, source, received_at) VALUES (?1, ?2, ?3, 'composer', ?4)") | |
| 48 | .run(id, who.id, text, new Date(Date.now() - minutesAgo * 60_000).toISOString()); | |
| 49 | const place = (thoughtId: string, placed: Placed[], reply = { text: "ok", asked: false }) => | |
| 50 | savePlacement(app.db, { | |
| 51 | thoughtId, kind: placed.length > 0 ? "work" : "decision", placed, reply: reply.text, asked: reply.asked, | |
| 52 | decidedBy: "rule", placedAt: new Date().toISOString(), | |
| 53 | }); | |
| 54 | ||
| 55 | async function askedAs(user: string): Promise<string> { | |
| 56 | const servers = await serve(app); | |
| 57 | try { | |
| 58 | const signin = await fetch(`${servers.url}/signin`, { | |
| 59 | method: "POST", | |
| 60 | body: new URLSearchParams({ username: user, password: "a-good-passphrase" }), | |
| 61 | redirect: "manual", | |
| 62 | }); | |
| 63 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 64 | const res = await fetch(`${servers.url}/asked`, { headers: { cookie } }); | |
| 65 | expect(res.status).toBe(200); | |
| 66 | return (await res.text()).replace(/\s+/g, " "); | |
| 67 | } finally { | |
| 68 | servers.stop(); | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | test("a placed thought links its issue, a repeat says what it repeats, a note and a waiting one say so", async () => { | |
| 73 | const made = await createRepo(app, alice, { name: "gauges" }); | |
| 74 | if (!made.ok) throw new Error(String(made.error)); | |
| 75 | const issue = openIssue(app, alice, made.value, { title: "Queue <depth> gauge" }); | |
| 76 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 77 | const n = issue.value.number; | |
| 78 | ||
| 79 | think("t-first", alice, "build a gauge for queue depth on the board please", 30); | |
| 80 | think("t-again", alice, "a queue depth gauge, again", 20); | |
| 81 | think("t-note", alice, "we prefer plain words", 10); | |
| 82 | think("t-wait", alice, "and a dark theme", 5); | |
| 83 | place("t-first", [{ action: "opened", repo: "alice/gauges", number: n }]); | |
| 84 | place("t-again", []); | |
| 85 | linkThoughts(app.db, "t-again", "t-first", "repeats"); | |
| 86 | place("t-note", []); | |
| 87 | ||
| 88 | const page = await askedAs("alice"); | |
| 89 | expect(page).toContain(`→ <a href="/alice/gauges/issues/${n}">alice/gauges#${n} Queue <depth> gauge</a>`); | |
| 90 | expect(page).toContain("repeats “build a gauge for queue depth on the board please”"); | |
| 91 | expect(page).toContain("kept as a note"); | |
| 92 | expect(page).toContain("not placed yet"); | |
| 93 | ||
| 94 | // Only the author's own thoughts carry this. | |
| 95 | const other = await askedAs("bob"); | |
| 96 | expect(other).not.toContain("alice/gauges#"); | |
| 97 | expect(other).not.toContain("kept as a note"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a question back stays visible, a proposed pull links to /pulls, an unreadable repository names nothing", async () => { | |
| 101 | const vault = await createRepo(app, bob, { name: "vault", visibility: "private" }); | |
| 102 | if (!vault.ok) throw new Error(String(vault.error)); | |
| 103 | const sealed = openIssue(app, bob, vault.value, { title: "Bob's sealed plan" }); | |
| 104 | if (!sealed.ok) throw new Error(String(sealed.error)); | |
| 105 | const repo = await createRepo(app, alice, { name: "visions" }); | |
| 106 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 107 | const pull = createIssue(app.db, { repoId: repo.value.id, authorId: alice.id, kind: "pull", title: "A vision for gauges" }); | |
| 108 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 109 | const p = pull.value.number; | |
| 110 | ||
| 111 | think("t-question", alice, "the thing from before", 4); | |
| 112 | think("t-vision", alice, "gauges everywhere", 3); | |
| 113 | think("t-hidden", alice, "the vault, too", 2); | |
| 114 | place("t-question", [], { text: "Which thing do you mean — the gauge or the theme?", asked: true }); | |
| 115 | place("t-vision", [{ action: "proposed", repo: "alice/visions", number: p, pull: p }]); | |
| 116 | place("t-hidden", [{ action: "opened", repo: "bob/vault", number: sealed.value.number }]); | |
| 117 | ||
| 118 | const page = await askedAs("alice"); | |
| 119 | expect(page).toContain("Which thing do you mean — the gauge or the theme?"); | |
| 120 | expect(page).toContain(`→ proposed <a href="/alice/visions/pulls/${p}">alice/visions#${p} A vision for gauges</a>`); | |
| 121 | expect(page).toContain("→ in a repository you cannot read"); | |
| 122 | expect(page).not.toContain("bob/vault"); | |
| 123 | expect(page).not.toContain("sealed plan"); | |
| 124 | }); | |
| 125 | ||
| 126 | test("the page's placements are read in one batch", () => { | |
| 127 | const got = placementsOf(app.db, ["t-first", "t-note", "t-wait", "t-first"]); | |
| 128 | expect([...got.keys()].sort()).toEqual(["t-first", "t-note"]); | |
| 129 | expect(got.get("t-first")!.placed[0]!.repo).toBe("alice/gauges"); | |
| 130 | }); |
test/triage.test.ts +1 −1
| @@ -299,7 +299,7 @@ describe("triage", () => { | ||
| 299 | 299 | await placeThought(app, fresh(), alice, late); |
| 300 | 300 | const rows = thoughtStream(app.db, alice); |
| 301 | 301 | const repeatRow = String(thoughtRow(rows.find((t) => t.id === again.id)!, 7)); |
| 302 | expect(repeatRow).toContain("said again, first on"); | |
| 302 | expect(repeatRow).toContain("repeats “build a <b>bold</b> gauge for queue depth”"); | |
| 303 | 303 | const reverseRow = String(thoughtRow(rows.find((t) => t.id === late.id)!, 7)); |
| 304 | 304 | expect(reverseRow).toContain("takes back"); |
| 305 | 305 | expect(reverseRow).toContain("<b>bold</b>"); |
test/tui-asked.test.ts +30 −1
| @@ -11,6 +11,7 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { createIssue, setIssueState } from "../src/core/issues.ts"; |
| 13 | 13 | import { savePlacement } from "../src/core/placements.ts"; |
| 14 | import { linkThoughts } from "../src/core/thought_links.ts"; | |
| 14 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 15 | 16 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; |
| 16 | 17 | import { askedLines, askedScreen } from "../src/tui/screens/asked.ts"; |
| @@ -111,7 +112,7 @@ describe("the asked screen", () => { | ||
| 111 | 112 | |
| 112 | 113 | test("a placement in an unreadable repository shows no slug or state", async () => { |
| 113 | 114 | const text = await screenText(alice); |
| 114 | expect(text).toContain("opened in a repository you cannot read"); | |
| 115 | expect(text).toContain("→ in a repository you cannot read"); | |
| 115 | 116 | expect(text).not.toContain("bob/vault"); |
| 116 | 117 | }); |
| 117 | 118 | |
| @@ -174,4 +175,32 @@ describe("the asked screen", () => { | ||
| 174 | 175 | expect(ids(alice)).toContain("asked"); |
| 175 | 176 | expect(ids()).not.toContain("asked"); |
| 176 | 177 | }); |
| 178 | test("a thought says where it went in /asked's words: the issue, the question back, a note, or not yet (#7509)", () => { | |
| 179 | const lobby = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'lobby'").get()!; | |
| 180 | const issue = createIssue(app.db, { repoId: lobby.id, authorId: alice.id, title: "Louder lobby" }); | |
| 181 | if (!issue.ok) throw new Error(issue.error); | |
| 182 | const n = issue.value.number; | |
| 183 | const placed = (thoughtId: string, reply: string, asked: boolean, number?: number) => | |
| 184 | savePlacement(app.db, { | |
| 185 | thoughtId, kind: number === undefined ? "decision" : "work", asked, decidedBy: "rule", reply, | |
| 186 | placed: number === undefined ? [] : [{ action: "opened", repo: "alice/lobby", number }], | |
| 187 | placedAt: new Date().toISOString(), | |
| 188 | }); | |
| 189 | thought(alice, "t4", "a louder lobby, again"); | |
| 190 | thought(alice, "t5", "which one was it"); | |
| 191 | thought(alice, "t6", "we like plain words"); | |
| 192 | placed("t1", "ok", false, n); | |
| 193 | placed("t4", "ok", false, n); | |
| 194 | linkThoughts(app.db, "t4", "t1", "repeats"); | |
| 195 | placed("t5", "Do you mean the lobby or the vault?", true); | |
| 196 | placed("t6", "ok", false); | |
| 197 | thought(alice, "t7", "and a dark theme"); | |
| 198 | ||
| 199 | const by = new Map(askedLines(app.db, alice).thoughts.map((t) => [t.title, t.secondary])); | |
| 200 | expect(by.get("make the lobby louder")).toBe(`→ alice/lobby#${n} Louder lobby`); | |
| 201 | expect(by.get("a louder lobby, again")).toStartWith(`→ alice/lobby#${n} Louder lobby · repeats “make the lobby louder”`); | |
| 202 | expect(by.get("which one was it")).toBe("Do you mean the lobby or the vault?"); | |
| 203 | expect(by.get("we like plain words")).toBe("kept as a note"); | |
| 204 | expect(by.get("and a dark theme")).toBe("not placed yet"); | |
| 205 | }); | |
| 177 | 206 | }); |
new above
offline
These words belong to the terminal too. Use them in src/tui/screens/asked.ts, where
NOT_PLACED_YETis still used, so both doors say the same thing.