Public: anyone can read this; the crew can change it.
A ship, decline or say taken on /next can be undone for 60 seconds from the page it was taken on #11827
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11814-a-ship-decline-or-say-taken-on-next-can → main 3 commits fork 3 threads · 3 unresolved
7 files
changed +184
−21
src/actions/asks_bulk.ts +39 −8
| @@ -9,12 +9,14 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import type { App } from "../app.ts"; |
| 12 | import { listOperations, type Operation } from "../core/fleet_ops.ts"; | |
| 12 | import { latestDecision, type Operation } from "../core/fleet_ops.ts"; | |
| 13 | 13 | import type { User } from "../core/users.ts"; |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | 15 | import { askTargetsFor } from "../web/studio/asks.ts"; |
| 16 | 16 | import { |
| 17 | 17 | applyFleetIds, |
| 18 | ASK_VERBS, | |
| 19 | ASKS_QUERY_PREFIX, | |
| 18 | 20 | getFleetOp, |
| 19 | 21 | isAsksOperation, |
| 20 | 22 | UNDO_WINDOW_MS, |
| @@ -23,7 +25,7 @@ import { | ||
| 23 | 25 | type FleetError, |
| 24 | 26 | type Plan, |
| 25 | 27 | } from "./fleet.ts"; |
| 26 | import { NO_SUCH_OPERATION, receiptSentence, SIGN_IN_TO_ACT } from "./asks_words.ts"; | |
| 28 | import { DONE, DONE_LINE_MS, DONE_NO_UNDO, DONE_UNDO, NO_SUCH_OPERATION, receiptSentence, SIGN_IN_TO_ACT } from "./asks_words.ts"; | |
| 27 | 29 | |
| 28 | 30 | /** |
| 29 | 31 | * What a bulk action on asks may be: the fleet verb each one is. Decline is |
| @@ -92,7 +94,7 @@ function resolve( | ||
| 92 | 94 | : verb, |
| 93 | 95 | ids: issueIds, |
| 94 | 96 | skipped: refused, |
| 95 | query: `asks: ${list.length} selected`, | |
| 97 | query: `${ASKS_QUERY_PREFIX}${list.length} selected`, | |
| 96 | 98 | }); |
| 97 | 99 | } |
| 98 | 100 | |
| @@ -125,7 +127,7 @@ export async function applyAsksBulk( | ||
| 125 | 127 | action: unknown, |
| 126 | 128 | items: unknown, |
| 127 | 129 | reason?: unknown, |
| 128 | ): Promise<Result<Operation & { skipped: Skipped[] }, FleetError>> { | |
| 130 | ): Promise<Result<Operation & { skipped: Skipped[]; line: string }, FleetError>> { | |
| 129 | 131 | const r = resolve(app, actor, action, items, reason); |
| 130 | 132 | if (!r.ok) return r; |
| 131 | 133 | const op = await applyFleetIds( |
| @@ -137,7 +139,7 @@ export async function applyAsksBulk( | ||
| 137 | 139 | r.value.verb, |
| 138 | 140 | ); |
| 139 | 141 | if (!op.ok) return op; |
| 140 | return ok({ ...op.value, skipped: r.value.skipped }); | |
| 142 | return ok({ ...op.value, skipped: r.value.skipped, line: doneWords(op.value.reversible) }); | |
| 141 | 143 | } |
| 142 | 144 | |
| 143 | 145 | /** |
| @@ -166,6 +168,10 @@ export const undoable = (op: Operation, now = Date.now()): boolean => | ||
| 166 | 168 | op.undoneBy === null && |
| 167 | 169 | now - Date.parse(op.createdAt) <= UNDO_WINDOW_MS; |
| 168 | 170 | |
| 171 | /** The viewer's newest bulk action on asks that is not an undo, read by kind, never from a window of recent receipts. */ | |
| 172 | const latestAsksDecision = (app: App, actor: User): Operation | undefined => | |
| 173 | latestDecision(app.db, actor.id, { kind: "issue", queryPrefix: ASKS_QUERY_PREFIX, verbs: ASK_VERBS }); | |
| 174 | ||
| 169 | 175 | /** |
| 170 | 176 | * The viewer's latest bulk action on asks that can still be undone — the |
| 171 | 177 | * one undo link the home's On you list keeps for a day. Nothing when none. |
| @@ -176,13 +182,38 @@ export function latestUndoable( | ||
| 176 | 182 | now = Date.now(), |
| 177 | 183 | ): Operation | undefined { |
| 178 | 184 | if (!actor) return undefined; |
| 179 | const op = listOperations(app.db, actor.id, 5).find( | |
| 180 | (o) => isAsksOperation(o) && o.undoOf === null, | |
| 181 | ); | |
| 185 | const op = latestAsksDecision(app, actor); | |
| 182 | 186 | if (op === undefined || !undoable(op, now)) return undefined; |
| 183 | 187 | return ownAsksOperation(app, actor, op.id); |
| 184 | 188 | } |
| 185 | 189 | |
| 190 | /** Whether a decision taken at `createdAt` is still inside the minute its Done line is kept. */ | |
| 191 | export const inDoneMinute = (createdAt: string, now = Date.now()): boolean => | |
| 192 | now - Date.parse(createdAt) <= DONE_LINE_MS; | |
| 193 | ||
| 194 | /** The line a decision leaves: `Done. Undo` when it can be taken back, the reason it cannot when a merge landed. */ | |
| 195 | export const doneWords = (reversible: boolean): string => (reversible ? DONE_UNDO : DONE_NO_UNDO); | |
| 196 | ||
| 197 | /** | |
| 198 | * The one line a decision leaves for a minute (#11814), for the page and the | |
| 199 | * terminal alike. Keyed to the operation's row, so a restart keeps it; | |
| 200 | * nothing once it was undone, changed nothing, or the minute has passed. | |
| 201 | * `undo` says whether the Undo is offered; `say` is what the line says apart | |
| 202 | * from the Undo button. | |
| 203 | */ | |
| 204 | export function doneLine( | |
| 205 | app: App, | |
| 206 | actor: User | undefined, | |
| 207 | now = Date.now(), | |
| 208 | ): { operation: Operation; line: string; say: string; undo: boolean } | undefined { | |
| 209 | if (!actor) return undefined; | |
| 210 | const op = latestAsksDecision(app, actor); | |
| 211 | if (op === undefined || op.undoneBy !== null || !inDoneMinute(op.createdAt, now)) return undefined; | |
| 212 | const own = ownAsksOperation(app, actor, op.id); | |
| 213 | if (own === undefined || !own.rows.some((r) => r.outcome === "ok")) return undefined; | |
| 214 | return { operation: own, line: doneWords(own.reversible), say: own.reversible ? DONE : DONE_NO_UNDO, undo: own.reversible }; | |
| 215 | } | |
| 216 | ||
| 186 | 217 | /** Undo one's own bulk action on asks, once, within a day (`undoFleetOp` holds the window). */ |
| 187 | 218 | export async function undoAsksBulk( |
| 188 | 219 | app: App, |
src/actions/asks_words.ts +15
| @@ -39,3 +39,18 @@ export function receiptSentence(verb: string, done: number, undo: boolean): stri | ||
| 39 | 39 | const word = Object.hasOwn(VERB_WORDS, verb) ? VERB_WORDS[verb]! : verb; |
| 40 | 40 | return undo ? `${word} undone: ${done}` : `${word}: ${done} done`; |
| 41 | 41 | } |
| 42 | ||
| 43 | /** How long, after a decision, the page keeps its one "Done. Undo" line (#11814). */ | |
| 44 | export const DONE_LINE_MS = 60_000; | |
| 45 | ||
| 46 | /** The first word of the line after a decision, and what the line says alone when nothing can be taken back. */ | |
| 47 | export const DONE = "Done."; | |
| 48 | ||
| 49 | /** The label of the button (and the terminal's key) that takes a decision back. */ | |
| 50 | export const UNDO_LABEL = "Undo"; | |
| 51 | ||
| 52 | /** The line after a decision that can be taken back, as MCP returns it: the page shows `DONE` and a button labelled `UNDO_LABEL`. */ | |
| 53 | export const DONE_UNDO = `${DONE} ${UNDO_LABEL}`; | |
| 54 | ||
| 55 | /** The line after a decision that cannot be taken back: a landed merge stays landed. */ | |
| 56 | export const DONE_NO_UNDO = `${DONE} A merge that has landed cannot be taken back, so there is no Undo.`; |
src/actions/fleet.ts +5 −2
| @@ -601,12 +601,15 @@ export async function applyPreviewed( | ||
| 601 | 601 | /** How long a bulk action on asks can be undone: a day. */ |
| 602 | 602 | export const UNDO_WINDOW_MS = 86_400_000; |
| 603 | 603 | |
| 604 | const ASK_VERBS: readonly string[] = ["dismiss", "mute", "ship", "close", "replan"]; | |
| 604 | export const ASK_VERBS: readonly string[] = ["dismiss", "mute", "ship", "close", "replan"]; | |
| 605 | ||
| 606 | /** The query a bulk action on asks is receipted under: "asks: n selected". */ | |
| 607 | export const ASKS_QUERY_PREFIX = "asks: "; | |
| 605 | 608 | |
| 606 | 609 | /** Whether a receipt is a bulk action on asks: named one by one, "asks: n selected". */ |
| 607 | 610 | export function isAsksOperation(op: Operation): boolean { |
| 608 | 611 | return ( |
| 609 | op.kind === "issue" && op.query.startsWith("asks: ") && ASK_VERBS.includes(op.verb) | |
| 612 | op.kind === "issue" && op.query.startsWith(ASKS_QUERY_PREFIX) && ASK_VERBS.includes(op.verb) | |
| 610 | 613 | ); |
| 611 | 614 | } |
| 612 | 615 |
src/core/fleet_ops.ts +22
| @@ -181,6 +181,28 @@ export function listOperations( | ||
| 181 | 181 | .map((op) => toOperation(op, [])); |
| 182 | 182 | } |
| 183 | 183 | |
| 184 | /** | |
| 185 | * An actor's newest operation that is not an undo, of one kind, whose query | |
| 186 | * starts with `queryPrefix` and whose verb is one of `verbs`; asked of the | |
| 187 | * table by those columns, so any number of undos between never hides it. | |
| 188 | */ | |
| 189 | export function latestDecision( | |
| 190 | db: Database, | |
| 191 | actorId: string, | |
| 192 | where: { kind: string; queryPrefix: string; verbs: readonly string[] }, | |
| 193 | ): Operation | undefined { | |
| 194 | const marks = where.verbs.map((_, i) => `?${i + 4}`).join(", "); | |
| 195 | const op = db | |
| 196 | .query<OperationRecord, (string | number)[]>( | |
| 197 | `${SELECT} WHERE fleet_operation.actor_id = ?1 AND fleet_operation.kind = ?2 | |
| 198 | AND fleet_operation.undo_of IS NULL AND substr(fleet_operation.query, 1, length(?3)) = ?3 | |
| 199 | AND fleet_operation.verb IN (${marks}) | |
| 200 | ORDER BY fleet_operation.id DESC LIMIT 1`, | |
| 201 | ) | |
| 202 | .get(actorId, where.kind, where.queryPrefix, ...where.verbs); | |
| 203 | return op === null || op === undefined ? undefined : toOperation(op, []); | |
| 204 | } | |
| 205 | ||
| 184 | 206 | function toOperation(op: OperationRecord, rows: RowRecord[]): Operation { |
| 185 | 207 | return { |
| 186 | 208 | id: op.id, |
src/tui/screens/dashboard.ts +9 −1
| @@ -53,6 +53,8 @@ import type { Proposal } from "../../core/proposals.ts"; | ||
| 53 | 53 | import type { App } from "../../app.ts"; |
| 54 | 54 | import { |
| 55 | 55 | applyAsksBulk, |
| 56 | doneLine, | |
| 57 | inDoneMinute, | |
| 56 | 58 | BULK_MAX, |
| 57 | 59 | latestUndoable, |
| 58 | 60 | previewAsksBulk, |
| @@ -200,6 +202,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 200 | 202 | /** The rows marked for one verb, by issue id. */ |
| 201 | 203 | const marked = new Set<string>(); |
| 202 | 204 | let lastSet: Operation | undefined; |
| 205 | /** The decision's Done line, as the page keeps it for a minute (`doneLine`). */ | |
| 206 | let doneSet: ReturnType<typeof doneLine>; | |
| 203 | 207 | /** What Home and /review count: live asks waiting on the viewer, and the stale ones folded apart. */ |
| 204 | 208 | let waitingSet: ReviewSet | undefined; |
| 205 | 209 | /** The repositories marked for archiving, by id; only ones the viewer administers. */ |
| @@ -432,6 +436,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 432 | 436 | for (const id of [...marked]) |
| 433 | 437 | if (!onYou.some((item) => item.issue.id === id)) marked.delete(id); |
| 434 | 438 | lastSet = f.user ? latestUndoable(f.app, f.user) : undefined; |
| 439 | doneSet = f.user ? doneLine(f.app, f.user) : undefined; | |
| 435 | 440 | const now = new Date(); |
| 436 | 441 | holds = new Map( |
| 437 | 442 | all.flatMap((repo) => { |
| @@ -1497,7 +1502,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1497 | 1502 | parts.push( |
| 1498 | 1503 | `${silenced.size} ${silenced.size === 1 ? "repository" : "repositories"} muted · L listen again`, |
| 1499 | 1504 | ); |
| 1500 | if (lastSet !== undefined) parts.push(`u undo ${lastSet.verb}`); | |
| 1505 | // The minute's Done line leads the undo, the page's words and gate; after the minute the day's undo stays. | |
| 1506 | const doneNow = doneSet !== undefined && inDoneMinute(doneSet.operation.createdAt) ? doneSet : undefined; | |
| 1507 | if (doneNow !== undefined && !doneNow.undo) parts.push(doneNow.line); | |
| 1508 | else if (lastSet !== undefined) parts.push(`${doneNow === undefined ? "" : `${doneNow.line} · `}u undo ${lastSet.verb}`); | |
| 1501 | 1509 | if (asideWaves.length > 0) parts.push(`${asideWaves.length} ${asideWaves.length === 1 ? "wave" : "waves"} put aside · B bring back`); |
| 1502 | 1510 | return parts.length > 0 ? parts.join(" · ") : undefined; |
| 1503 | 1511 | } |
src/web/studio/asks_page.ts +25 −10
| @@ -7,12 +7,13 @@ | ||
| 7 | 7 | * (`actions/asks_bulk.ts`). `/asks` was its own screen; it lands here now. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { VERB_WORDS } from "../../actions/asks_words.ts"; | |
| 10 | import { UNDO_LABEL, VERB_WORDS } from "../../actions/asks_words.ts"; | |
| 11 | 11 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 12 | 12 | import type { WaitingFor } from "../../core/waiting_for.ts"; |
| 13 | 13 | import { |
| 14 | 14 | applyAsksBulk, |
| 15 | 15 | BULK_MAX, |
| 16 | doneLine, | |
| 16 | 17 | latestUndoable, |
| 17 | 18 | ownAsksOperation, |
| 18 | 19 | undoAsksBulk, |
| @@ -425,6 +426,28 @@ function handedTop(ctx: Ctx, decisionId: string): AsksTop | undefined { | ||
| 425 | 426 | }; |
| 426 | 427 | } |
| 427 | 428 | |
| 429 | /** | |
| 430 | * The one status bar a decision leaves, always in this slot so nothing moves: | |
| 431 | * for a minute the Done line (`Done.` and an Undo, or why a landed merge has | |
| 432 | * none), then, up to a day, the receipt with its Undo. | |
| 433 | */ | |
| 434 | function undoSlot(ctx: Ctx, user: User | undefined): Html | null { | |
| 435 | const done = doneLine(ctx.app, user); | |
| 436 | if (done !== undefined) return undoBar(done.say, done.undo ? done.operation.id : undefined); | |
| 437 | const last = latestUndoable(ctx.app, user); | |
| 438 | return last === undefined ? null : undoBar(receiptOf(last), last.id, "kept for a day"); | |
| 439 | } | |
| 440 | ||
| 441 | /** One bar: what was done, and, when it can be taken back, the Undo (a same-origin POST) and a note. */ | |
| 442 | function undoBar(say: string, operationId?: string, note?: string): Html { | |
| 443 | if (operationId === undefined) return html`<p class="bar small" role="status">${say}</p>`; | |
| 444 | return html`<form class="bar" method="post" action="/asks/bulk/${operationId}/undo" role="status"> | |
| 445 | <span class="small">${say}</span> | |
| 446 | ${btn(UNDO_LABEL, { size: "sm" })} | |
| 447 | ${note === undefined ? null : html`<span class="muted small">${note}</span>`} | |
| 448 | </form>`; | |
| 449 | } | |
| 450 | ||
| 428 | 451 | /** What needs you, as the home's pinned section: what asks you, with a |
| 429 | 452 | * bulk step's preview or receipt on top. Nothing on screen when nothing |
| 430 | 453 | * asks — the header's Asks link says so — but the sentence stays for a |
| @@ -471,15 +494,7 @@ export function needsYouSection( | ||
| 471 | 494 | </form>`, |
| 472 | 495 | )} |
| 473 | 496 | </details>`; |
| 474 | const last = latestUndoable(ctx.app, user); | |
| 475 | const undo = | |
| 476 | last === undefined | |
| 477 | ? null | |
| 478 | : html`<form class="bar" method="post" action="/asks/bulk/${last.id}/undo" role="status"> | |
| 479 | <span class="small">${receiptOf(last)}</span> | |
| 480 | ${btn("Undo", { size: "sm" })} | |
| 481 | <span class="muted small">kept for a day</span> | |
| 482 | </form>`; | |
| 497 | const undo = undoSlot(ctx, user); | |
| 483 | 498 | const count = rows.length + needs.length; |
| 484 | 499 | if (count === 0 && top === undefined) |
| 485 | 500 | return { |
test/asks-bulk.test.ts +69
| @@ -22,6 +22,9 @@ import { getAgentByName } from "../src/core/agents.ts"; | ||
| 22 | 22 | import { getIssueById, listComments, type Issue } from "../src/core/issues.ts"; |
| 23 | 23 | import { assignIssue } from "../src/actions/issues.ts"; |
| 24 | 24 | import { mutedRepos, recordInteraction, removeMutes } from "../src/core/interactions.ts"; |
| 25 | import { listOperations } from "../src/core/fleet_ops.ts"; | |
| 26 | import { doneLine } from "../src/actions/asks_bulk.ts"; | |
| 27 | import { DONE_NO_UNDO, DONE_UNDO } from "../src/actions/asks_words.ts"; | |
| 25 | 28 | import { needsYouSection } from "../src/web/studio/asks_page.ts"; |
| 26 | 29 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 27 | 30 | import type { Repo } from "../src/core/repos.ts"; |
| @@ -767,6 +770,72 @@ describe("acting on many asks at once", () => { | ||
| 767 | 770 | expect(getIssueById(app.db, a.id)!.state).toBe("open"); |
| 768 | 771 | }); |
| 769 | 772 | |
| 773 | describe("the one Done line after a decision (#11814)", () => { | |
| 774 | const page = async () => | |
| 775 | (await fetch(`${servers.url}/live/on-you`, { headers: { cookie: `${aliceCookie}; forge_mode=studio`, origin: servers.url, "x-forge-fragment": "1" } })).text(); | |
| 776 | const ageIt = (id: string, ms: number) => | |
| 777 | app.db.query("UPDATE fleet_operation SET created_at = ?1 WHERE id = ?2").run(new Date(Date.now() - ms).toISOString(), id); | |
| 778 | ||
| 779 | test("decline: Done. Undo for a minute, kept by the row; its undo writes its own receipt", async () => { | |
| 780 | const pull = await greenPull(open, "Done line decline"); | |
| 781 | const ask = items(alice).filter((i) => i.endsWith(pull.id)); | |
| 782 | const done = await api(aliceRw, "/api/v1/asks/bulk", { action: "decline", items: ask, preview: false }); | |
| 783 | expect(done.body.line).toBe(DONE_UNDO); | |
| 784 | expect(doneLine(app, alice)?.line).toBe(DONE_UNDO); | |
| 785 | expect(await page()).toContain(`/asks/bulk/${done.body.id}/undo`); | |
| 786 | // After the minute the Done line is gone (the day's undo stays). | |
| 787 | ageIt(done.body.id, 61_000); | |
| 788 | expect(doneLine(app, alice)).toBeUndefined(); | |
| 789 | ageIt(done.body.id, 1_000); | |
| 790 | const undone = await api(aliceRw, `/api/v1/asks/bulk/${done.body.id}/undo`, {}); | |
| 791 | expect(undone.status).toBe(201); | |
| 792 | expect(undone.body.id).not.toBe(done.body.id); | |
| 793 | expect(undone.body.undoOf).toBe(done.body.id); | |
| 794 | expect(doneLine(app, alice)).toBeUndefined(); | |
| 795 | }); | |
| 796 | ||
| 797 | test("the operation log keeps the decision and its undo, and the line finds the decision past any run of undos", async () => { | |
| 798 | const pull = await greenPull(open, "Done line trail"); | |
| 799 | const ask = items(alice).filter((i) => i.endsWith(pull.id)); | |
| 800 | const done = await api(aliceRw, "/api/v1/asks/bulk", { action: "decline", items: ask, preview: false }); | |
| 801 | const undone = await api(aliceRw, `/api/v1/asks/bulk/${done.body.id}/undo`, {}); | |
| 802 | const log = listOperations(app.db, alice.id, 50); | |
| 803 | expect(log.find((o) => o.id === done.body.id)).toMatchObject({ undoneBy: undone.body.id, undoOf: null }); | |
| 804 | expect(log.find((o) => o.id === undone.body.id)).toMatchObject({ undoOf: done.body.id }); | |
| 805 | // Six undo receipts after a fresh decision: it is still the latest decision, read by kind. | |
| 806 | const fresh = await api(aliceRw, "/api/v1/asks/bulk", { action: "decline", items: ask, preview: false }); | |
| 807 | expect(fresh.status).toBe(201); | |
| 808 | for (let i = 0; i < 6; i++) | |
| 809 | app.db | |
| 810 | .query( | |
| 811 | `INSERT INTO fleet_operation (id, actor_id, kind, query, verb, value, reversible, undo_of, created_at) | |
| 812 | VALUES (?1, ?2, 'issue', 'asks: 1 selected', 'dismiss', '', 0, ?3, ?4)`, | |
| 813 | ) | |
| 814 | .run(`zz-undo-${i}-${fresh.body.id}`, alice.id, done.body.id, new Date().toISOString()); | |
| 815 | expect(doneLine(app, alice)?.operation.id).toBe(fresh.body.id); | |
| 816 | }); | |
| 817 | ||
| 818 | test("close: the same line and the same undo", async () => { | |
| 819 | const pull = await greenPull(open, "Done line close"); | |
| 820 | const ask = items(alice).filter((i) => i.endsWith(pull.id)); | |
| 821 | const done = await api(aliceRw, "/api/v1/asks/bulk", { action: "close", items: ask, reason: "not now", preview: false }); | |
| 822 | expect(done.body.line).toBe(DONE_UNDO); | |
| 823 | expect(doneLine(app, alice)?.operation.id).toBe(done.body.id); | |
| 824 | }); | |
| 825 | ||
| 826 | test("ship: a landed merge shows no Undo and says why in one sentence", async () => { | |
| 827 | const pull = await greenPull(open, "Done line ship"); | |
| 828 | const ask = items(alice).filter((i) => i.endsWith(pull.id)); | |
| 829 | const done = await api(aliceRw, "/api/v1/asks/bulk", { action: "ship", items: ask, preview: false }); | |
| 830 | expect(done.status).toBe(201); | |
| 831 | expect(done.body.line).toBe(DONE_NO_UNDO); | |
| 832 | expect(doneLine(app, alice)).toMatchObject({ line: DONE_NO_UNDO, undo: false }); | |
| 833 | const html = await page(); | |
| 834 | expect(html).toContain("cannot be taken back"); | |
| 835 | expect(html).not.toContain(`/asks/bulk/${done.body.id}/undo`); | |
| 836 | }); | |
| 837 | }); | |
| 838 | ||
| 770 | 839 | test("undoing a bulk mute leaves a mute made elsewhere", () => { |
| 771 | 840 | const item = `repo:${quiet.id}`; |
| 772 | 841 | recordInteraction(app.db, { viewerId: alice.id, itemId: item, kind: "muted", repoId: quiet.id, actorId: null }); |
new above
offline
Audit: the decision and undo are not shown to extend #11754 trail; add that and a test. listOperations(...,5) then find can miss the latest decision if five undos intervene; query by kind instead.