fix(board): pin the DndContext id to stop a hydration mismatch

dnd-kit derives the id of its accessibility description element from an
internal counter that starts fresh on the server and is already advanced on
the client. Every sortable card rendered aria-describedby="DndDescribedBy-0"
server-side and "DndDescribedBy-3" client-side, and React reported a
hydration mismatch across the whole board.

A stable id makes both sides agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-17 09:08:02 +02:00
parent 8888acb2c6
commit 226f8576f2
2 changed files with 129 additions and 0 deletions

119
runsum.mts Normal file
View file

@ -0,0 +1,119 @@
/**
* One-off: summarise a project's commits from the command line.
*
* Imports the same PURE modules the app uses — batch.ts, preamble.ts,
* prompt.ts — so the prompts, the batching and the index-matched parsing are
* byte-identical to what the "Summarise" button does. Only the surrounding
* plumbing (which carries `server-only`) is reimplemented here.
*/
import PocketBase from "pocketbase";
import { BATCH_SIZE, buildBatchPrompt, chunk, parseBatchResponse } from "./src/lib/ai/batch";
import { PROMPT_VERSION } from "./src/lib/ai/preamble";
import { buildSystemPrompt } from "./src/lib/ai/prompt";
import { stripThinking } from "./src/lib/ai/think-strip";
async function main() {
const pb = new PocketBase(process.env.NEXT_PUBLIC_PB_URL!);
pb.autoCancellation(false);
await pb
.collection("_superusers")
.authWithPassword(process.env.PB_SUPERUSER_EMAIL!, process.env.PB_SUPERUSER_PASSWORD!);
const settings = await pb.collection("settings").getFirstListItem("id != ''");
const key: string = settings.ai_api_key ?? "";
const model: string = settings.ai_model || "MiniMax-M2";
if (!key) throw new Error("No API key configured — paste it in Settings first.");
const system = buildSystemPrompt(settings.ai_system_prompt_extra ?? "");
const pending = await pb.collection("commits").getFullList({
filter: [
"is_merge = false",
`(summary = "" || summary_version < ${PROMPT_VERSION})`,
"summary_attempts < 3",
].join(" && "),
sort: "-authored_at",
fields: "id,subject,type,scope,summary_attempts",
});
console.log(`provider=${settings.ai_provider} model=${model}`);
console.log(`pending commits: ${pending.length}`);
if (pending.length === 0) return;
const batches = chunk(
pending.map((c) => ({
id: c.id,
subject: (c.subject as string) || "",
type: (c.type as string) || "other",
scope: (c.scope as string) || "",
})),
BATCH_SIZE
);
let summarised = 0;
let missing = 0;
let inTok = 0;
let outTok = 0;
for (const [index, batch] of batches.entries()) {
process.stdout.write(` batch ${index + 1}/${batches.length} … `);
let text = "";
try {
const res = await fetch("https://api.minimax.io/v1/chat/completions", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify({
model,
max_tokens: 4096,
n: 1,
messages: [
{ role: "system", content: system },
{ role: "user", content: buildBatchPrompt(batch) },
],
}),
});
if (!res.ok) {
console.log(`HTTP ${res.status} — ${(await res.text()).slice(0, 120)}`);
continue;
}
const json = await res.json();
text = stripThinking(json.choices?.[0]?.message?.content ?? "");
inTok += json.usage?.prompt_tokens ?? 0;
outTok += json.usage?.completion_tokens ?? 0;
} catch (error) {
console.log(`failed — ${(error as Error).message}`);
continue;
}
const parsed = parseBatchResponse(text, batch);
for (const commit of batch) {
const summary = parsed.get(commit.id);
if (!summary) {
missing += 1;
continue;
}
await pb.collection("commits").update(commit.id, {
summary,
summary_version: PROMPT_VERSION,
summary_attempts: 0,
});
summarised += 1;
}
console.log(`${parsed.size}/${batch.length}`);
}
console.log(`\nsummarised: ${summarised}`);
console.log(`not returned by the model: ${missing}`);
console.log(`tokens: ${inTok} in, ${outTok} out`);
}
main().catch((e) => {
console.error("FAILED:", e?.message ?? e);
process.exit(1);
});

View file

@ -292,6 +292,16 @@ export function Board({
return (
<div className="flex-1 overflow-hidden" data-project={projectId}>
<DndContext
// A STABLE id, not dnd-kit's default.
//
// Without it, dnd-kit derives the id of its accessibility description
// element from an internal counter that starts fresh on the server and
// is already advanced on the client. Every sortable card then renders
// aria-describedby="DndDescribedBy-0" server-side and
// aria-describedby="DndDescribedBy-3" client-side, and React reports a
// hydration mismatch for the whole tree. Pinning the id makes both
// sides agree.
id="board-dnd"
sensors={sensors}
onDragStart={onDragStart}
onDragEnd={onDragEnd}