Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified apps/board/assets/latch/error-retrying-budget.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/board/assets/latch/error-retrying-cooldown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/board/assets/latch/error-retrying-next-sweep.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/board/assets/latch/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@
"dotsY": 78
},
"superseded": {
"dotsX": 634,
"dotsX": 547,
"dotsY": 78
}
}
Binary file modified apps/board/assets/latch/queued-budget.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/board/assets/latch/queued-cooldown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/board/assets/latch/queued-next-sweep.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/board/assets/latch/queued-review-in-flight.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/board/assets/latch/superseded.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,23 @@ title in Silkscreen 18 (`#ece8f4`) and a subtitle in IBM Plex Mono 13
| Armed (first time) | `armed` | open | Ready for another pass? | Resolve this thread to ask | `#f08fb0` | leg shuffle, eye blink |
| Queued | `queued` | resolved | Follow-up review queued | why it is waiting (below) | `#e6b860` | dots tick after the title |
| Running | `running` | resolved | Follow-up review running | Results post here when done | `#7fbfe8` | creature marches; ring loader on the right |
| Error, retrying | `error` | resolved | Follow-up review hit a snag | Retrying on the next sweep | `#f2917e` | quick shake, dots tick |
| Error, retrying | `error` | resolved | Follow-up review hit a snag | Trying again in a few minutes | `#f2917e` | quick shake, dots tick |
| Error, stuck | `error` | resolved | Follow-up review stuck | Reviewer notified | `#f2917e` | quick shake, blink |
| Done, comments | `armed` | open | Follow-up review done | N new comments. Resolve when ready | `#b9a0f2` | one hop, blink |
| Done, approved | `spent` | resolved | Follow-up review done | Approved. Nothing more to do | `#86cfa0` | hop with sparkles |
| Approved, no follow-up | `spent` | resolved | Approved | Nothing more to do | `#86cfa0` | hop with sparkles |
| Superseded | `spent` | resolved | Superseded | A newer latch is live | `#8e88a0` | still |
| Replaced | `spent` | resolved | Replaced | Use the newer thread | `#8e88a0` | still |

Queued subtitles, by why the request is waiting: "Starts on the next
sweep" (nothing blocks it), "Waiting out the cooldown", "Daily limit hit;
starts tomorrow", "Waiting for the running review" (`review-in-flight`).
Queued subtitles, by why the request is waiting: "Starts in a few
minutes" (nothing blocks it), "Waiting between reviews" (the cooldown),
"Daily limit reached. Starts tomorrow", "Waiting on another review"
(`review-in-flight`).
These are the only reasons `decideRequest` can give a latch request that
clear by themselves; respond never blocks a latch.

Error, retrying names the wait the same way when a retry is blocked:
"Retrying on the next sweep", "Retrying after the cooldown", "Daily limit
hit; retrying tomorrow".
"Trying again in a few minutes", "Trying again after a pause", "Daily
limit reached. Trying again tomorrow".

Done-comments counts what actually landed: the reviewer's discussions on
the MR created after the follow-up launched (the launch stamp, below).
Expand All @@ -79,9 +80,13 @@ count the board cannot read, shows "Review posted. Resolve when ready".
(My call: the report JSON's findings are the drafted set, and the
reviewer can drop findings before posting.)

The note body under the image keeps a plain sentence per state, so
blocked images still say what is happening. The current armed and spent
copy stays; new states get one sentence each.
A sentence goes under the image only when the author has something to
do: armed and done-comments. Every other state is the banner alone; its
title and subtitle are the image's alt text, so a blocked image or a
screen reader still says what is happening. A cached upload is reused only
while its alt text matches the current copy, so a copy change re-draws the
banner on each MR's next write. Banner and comment copy never names the
board's own terms (latch, spent, sweep, cooldown).

## Replies and notifications

Expand Down
8 changes: 6 additions & 2 deletions apps/board/scripts/preview-latch-banners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,16 @@ function write(name: string, bytes: Uint8Array): string {
return `gifs/${name}`;
}

function card(src: string, sentence: string, caption?: string): string {
function card(
src: string,
sentence: string | undefined,
caption?: string
): string {
return `<figure class="card">
${caption ? `<figcaption>${esc(caption)}</figcaption>` : ''}
<div class="note-head"><span class="avatar"></span><b>Board reviewer</b> <span class="muted">@reviewer, just now</span></div>
<img src="${src}" alt="">
${markdown(sentence)}
${sentence ? markdown(sentence) : ''}
</figure>`;
}

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/board/src/__tests__/fixtures/latch-reference/latch-queued.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion apps/board/src/__tests__/latch-land.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ describe('landFollowUp', () => {
phase: 'error',
stuck: true,
variant: 'error-stuck',
replyDue: copyFor({ state: 'error-stuck' }).sentence,
replyDue:
"The follow-up review didn't finish after 3 tries. The reviewer has been told.",
})
);
expect(await h.land()).toBe('landed');
Expand Down
10 changes: 8 additions & 2 deletions apps/board/src/__tests__/latch-markers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ describe('bodies', () => {
expect(body).toContain(IMG);
});

test('spent body defaults to the approved wording', () => {
expect(spentLatchBody(IMG)).toContain('Approved, so this latch is spent');
test('spent body is the image alone, with no sentence', () => {
expect(spentLatchBody(IMG)).toBe(`${LATCH_MARKER_SPENT}\n\n${IMG}\n`);
});

test('neither body uses an em dash or en dash', () => {
Expand Down Expand Up @@ -64,6 +64,12 @@ describe('latchBody', () => {
`${LATCH_MARKER_RUNNING}\n\n${GIF}\n\nIn progress.\n`
);
});

test('no sentence: marker, blank, image', () => {
expect(latchBody('running', GIF)).toBe(
`${LATCH_MARKER_RUNNING}\n\n${GIF}\n`
);
});
});

describe('imageMarkdownFor', () => {
Expand Down
35 changes: 29 additions & 6 deletions apps/board/src/__tests__/latch-post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe('bannerMarkdown', () => {
{ filename: 'latch-2317-armed.gif', contentType: 'image/gif' },
]);
expect(md).toBe(
'![Ready for another pass?](/uploads/ab12/latch-2317-armed.gif)'
'![Ready for another pass? Resolve this thread to ask](/uploads/ab12/latch-2317-armed.gif)'
);
});

Expand All @@ -125,12 +125,35 @@ describe('bannerMarkdown', () => {
);
const md = await bannerMarkdown(gw, 42, MR, 2317, { state: 'running' }, db);
const want =
'![Follow-up review running](/uploads/ab12/latch-2317-running.gif)';
'![Follow-up review running. Results post here when done](/uploads/ab12/latch-2317-running.gif)';
expect(md).toBe(want);
expect(calls).toEqual(['upload:latch-2317-running.gif']);
expect(cachedUpload(MR, 'running', db)).toBe(want);
});

test('a banner cached with older copy is uploaded again and replaced', async () => {
const db = tempDb();
const { gw, calls } = gateway();
rememberUpload(
MR,
'queued-next-sweep',
'![Follow-up review queued. Starts on the next sweep](/uploads/old1/latch-2317-queued-next-sweep.gif)',
db
);
const md = await bannerMarkdown(
gw,
42,
MR,
2317,
{ state: 'queued', wait: 'next-sweep' },
db
);
expect(md).toBe(
'![Follow-up review queued. Starts in a few minutes](/uploads/ab12/latch-2317-queued-next-sweep.gif)'
);
expect(calls).toEqual(['upload:latch-2317-queued-next-sweep.gif']);
});

test('a different variant uploads its own banner', async () => {
const db = tempDb();
const { gw, calls } = gateway();
Expand Down Expand Up @@ -262,8 +285,8 @@ describe('spendLatch', () => {
'resolve:d1',
]);
expect(latchKindOf(bodies[0]!)).toBe('spent');
expect(bodies[0]).toContain('Approved, so this latch is spent');
expect(bodies[0]).toContain('![Approved](');
expect(bodies[0]).toContain('![Approved. Nothing more to do](');
expect(bodies[0]!.trimEnd().endsWith(')')).toBe(true);
});

test('an already-spent latch that is resolved draws no writes', async () => {
Expand Down Expand Up @@ -315,7 +338,7 @@ describe('spendLatch', () => {
NOW,
db
);
expect(bodies[0]).toContain('Superseded by a newer latch on this MR');
expect(bodies[0]).toContain('![Replaced. Use the newer thread](');
expect(bodies[0]).not.toContain('Approved');
});

Expand Down Expand Up @@ -435,7 +458,7 @@ describe('spendAllLatches', () => {
db
);
expect(
bodies.every(b => b.includes('Superseded by a newer latch on this MR'))
bodies.every(b => b.includes('![Replaced. Use the newer thread]('))
).toBe(true);
});
});
58 changes: 23 additions & 35 deletions apps/board/src/__tests__/latch-variants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,7 @@ describe('copy', () => {
'#86cfa0',
],
[{ state: 'approved' }, 'Approved', 'Nothing more to do', '#86cfa0'],
[
{ state: 'superseded' },
'Superseded',
'A newer latch is live',
'#8e88a0',
],
[{ state: 'superseded' }, 'Replaced', 'Use the newer thread', '#8e88a0'],
];
for (const [v, title, sub, accent] of rows) {
expect(copyFor(v)).toMatchObject({ title, sub, accent });
Expand All @@ -161,28 +156,28 @@ describe('copy', () => {

test('queued subtitles name the wait', () => {
expect(SUB({ state: 'queued', wait: 'next-sweep' })).toBe(
'Starts on the next sweep'
'Starts in a few minutes'
);
expect(SUB({ state: 'queued', wait: 'cooldown' })).toBe(
'Waiting out the cooldown'
'Waiting between reviews'
);
expect(SUB({ state: 'queued', wait: 'budget' })).toBe(
'Daily limit hit; starts tomorrow'
'Daily limit reached. Starts tomorrow'
);
expect(SUB({ state: 'queued', wait: 'review-in-flight' })).toBe(
'Waiting for the running review'
'Waiting on another review'
);
});

test('error-retrying subtitles name the wait', () => {
expect(SUB({ state: 'error-retrying', wait: 'next-sweep' })).toBe(
'Retrying on the next sweep'
'Trying again in a few minutes'
);
expect(SUB({ state: 'error-retrying', wait: 'cooldown' })).toBe(
'Retrying after the cooldown'
'Trying again after a pause'
);
expect(SUB({ state: 'error-retrying', wait: 'budget' })).toBe(
'Daily limit hit; retrying tomorrow'
'Daily limit reached. Trying again tomorrow'
);
});

Expand All @@ -207,34 +202,27 @@ describe('copy', () => {
);
});

test('sentences under the image', () => {
test('a sentence only where the author has something to do', () => {
expect(copyFor({ state: 'armed' }).sentence).toBe(
"**Addressed everything?** Resolve this thread and I'll take another pass over the MR.\n\nLeave it open while there's still work in flight."
);
expect(copyFor({ state: 'approved' }).sentence).toBe(
'Approved, so this latch is spent. Nothing further to do here.'
);
expect(copyFor({ state: 'superseded' }).sentence).toBe(
'Superseded by a newer latch on this MR; nothing to do here.'
);
expect(copyFor({ state: 'queued', wait: 'budget' }).sentence).toBe(
"Follow-up review requested. I'll start it as soon as I can."
);
expect(copyFor({ state: 'running' }).sentence).toBe(
"Follow-up review in progress. I'll reply here when it's done."
);
expect(
copyFor({ state: 'error-retrying', wait: 'cooldown' }).sentence
).toBe("The follow-up review didn't finish. I'll try again.");
expect(copyFor({ state: 'error-stuck' }).sentence).toBe(
"The follow-up review didn't finish after 3 tries. The reviewer has been told."
);
expect(copyFor({ state: 'done-comments', count: 4 }).sentence).toBe(
"Follow-up review done. Resolve this thread again when you've addressed the new comments."
);
expect(copyFor({ state: 'done-approved' }).sentence).toBe(
'Follow-up review done and approved, so this latch is spent.'
);
for (const key of allVariantKeys()) {
const v = variantOfKey(key)!;
if (v.state === 'armed' || v.state === 'done-comments') continue;
expect(copyFor(v).sentence).toBeUndefined();
}
});

test('no posted copy names the latch', () => {
for (const key of allVariantKeys()) {
const c = copyFor(variantOfKey(key)!);
expect(`${c.title} ${c.sub} ${c.sentence ?? ''}`).not.toMatch(
/latch|spent|supersed|sweep|cooldown/i
);
}
});

test('indicator follows the state', () => {
Expand Down
22 changes: 9 additions & 13 deletions apps/board/src/__tests__/triage-latch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
} from '../latch/latch-state.ts';
import { armedLatchBody, latchBody, spentLatchBody } from '../latch/markers.ts';
import type { LatchGateway } from '../latch/post.ts';
import { copyFor } from '../latch/variants.ts';
import type { ReviewState } from '../review-state.ts';
import type { AuditEntry } from '../triage/audit.ts';
import { parseTriageBlock } from '../triage/config.ts';
Expand All @@ -20,6 +19,7 @@ import {
latchInScope,
runLatchPass,
runStamp,
STUCK_REPLY,
waitReasonOf,
type LatchMrFacts,
type LatchPassDeps,
Expand Down Expand Up @@ -663,9 +663,7 @@ describe('dedupe', () => {
),
});
await runLatchPass(deps);
expect(noteBodies.get(1)).toContain(
'Superseded by a newer latch on this MR'
);
expect(noteBodies.get(1)).toContain('![Replaced. Use the newer thread](');
expect(noteBodies.get(1)).not.toContain('Approved');
});
});
Expand Down Expand Up @@ -1668,7 +1666,7 @@ describe('error, retry and stuck', () => {
expect((await runLatchPass(deps)).stuck).toBe(1);
expect(uploads).toEqual(['latch-2317-error-stuck.gif']);
expect(calls).toEqual(['upload', 'updateNote:1', 'reply:d1']);
expect(replies).toEqual([copyFor({ state: 'error-stuck' }).sentence]);
expect(replies).toEqual([STUCK_REPLY]);
expect(stuckNotices).toEqual([
{
title: 'Follow-up review stuck on !2317',
Expand Down Expand Up @@ -1804,7 +1802,7 @@ describe('error, retry and stuck', () => {
expect((await runLatchPass(h.deps)).repaired).toBe(1);
expect(h.calls).toEqual(['upload', 'updateNote:1', 'reply:d1']);
expect(h.noteBodies.get(1)).toStartWith(ERROR);
expect(h.replies).toEqual([copyFor({ state: 'error-stuck' }).sentence]);
expect(h.replies).toEqual([STUCK_REPLY]);
expect(h.stuckNotices).toHaveLength(1);
expect(readLatchRow(MR, db)?.variant).toBe('error-stuck');

Expand All @@ -1824,7 +1822,7 @@ describe('error, retry and stuck', () => {
detail(disc('d1', note, '2026-09-01T10:00:00Z', true)),
});
failNextReply(h.deps.gateway);
const stuck = copyFor({ state: 'error-stuck' }).sentence;
const stuck = STUCK_REPLY;
expect((await runLatchPass(h.deps)).stuck).toBe(1);
expect(h.stuckNotices).toHaveLength(1);
expect(h.replies).toEqual([]);
Expand Down Expand Up @@ -1857,9 +1855,7 @@ describe('error, retry and stuck', () => {
});
failNextReply(h.deps.gateway);
expect((await runLatchPass(h.deps)).stuck).toBe(1);
expect(readLatchRow(MR, db)?.replyDue).toBe(
copyFor({ state: 'error-stuck' }).sentence
);
expect(readLatchRow(MR, db)?.replyDue).toBe(STUCK_REPLY);

note = errorBody();
lane = { ...commented, status: 'reviewing', runStartedAt: NOW };
Expand All @@ -1878,7 +1874,7 @@ describe('error, retry and stuck', () => {

test('a stuck latch that gets spent never posts its stuck reply', async () => {
const db = tempDb();
const stuck = copyFor({ state: 'error-stuck' }).sentence;
const stuck = STUCK_REPLY;
putLatchRow(
errorRow({
failures: 3,
Expand Down Expand Up @@ -1948,7 +1944,7 @@ describe('error, retry and stuck', () => {
detail: detail(disc('d1', errorBody(), '2026-09-01T10:00:00Z', true)),
});
expect((await runLatchPass(deps)).stuck).toBe(1);
expect(replies).toEqual([copyFor({ state: 'error-stuck' }).sentence]);
expect(replies).toEqual([STUCK_REPLY]);
expect(stuckNotices).toHaveLength(1);
expect(memory.mrs[MR]?.attemptsToday ?? 0).toBe(0);
expect(readLatchRow(MR, db)).toMatchObject({
Expand Down Expand Up @@ -2053,7 +2049,7 @@ describe('error, retry and stuck', () => {
return update(p, i, id, body);
};
expect((await runLatchPass(h.deps)).failed).toBe(1);
expect(h.replies).toEqual([copyFor({ state: 'error-stuck' }).sentence]);
expect(h.replies).toEqual([STUCK_REPLY]);
expect(readLatchRow(MR, db)?.variant).toBe('error-stuck');
});
});
Expand Down
Loading
Loading