From b3bbc7850b70215637c5285c155198e71a2be56e Mon Sep 17 00:00:00 2001
From: jaylfc
Date: Tue, 23 Jun 2026 19:18:36 +0100
Subject: [PATCH 1/2] feat(decisions): surface the L1 supersession lineage in
the archive
The supersede backend (#1339) records a parent_decision_id chain and serves it
at GET /api/decisions/{id}/history, but the app only showed a flat 'Superseded'
label with no way to see what replaced what. Add an expandable history trail on
answered cards that have a parent (a revision): it lazily fetches the chain on
first expand (oldest first) and renders each step's question + chosen answer +
when. Originals with no parent show no affordance. Read-only, reuses the shell
tokens already in the file; vitest covers the expand/fetch/render path and the
no-parent case. Visual check against the live app deferred to a live session
(same as the canvas read-only slices).
---
desktop/src/apps/DecisionsApp.test.tsx | 65 ++++++++++++++++++++
desktop/src/apps/DecisionsApp.tsx | 82 ++++++++++++++++++++++++++
2 files changed, 147 insertions(+)
diff --git a/desktop/src/apps/DecisionsApp.test.tsx b/desktop/src/apps/DecisionsApp.test.tsx
index 3394a6fb..24c949b8 100644
--- a/desktop/src/apps/DecisionsApp.test.tsx
+++ b/desktop/src/apps/DecisionsApp.test.tsx
@@ -161,4 +161,69 @@ describe("DecisionsApp", () => {
await waitFor(() => expect(screen.getByText("Excalidraw")).toBeTruthy());
});
+
+ it("offers no history affordance for an original (no parent) decision", async () => {
+ const answered = {
+ ...singleSelect,
+ status: "answered",
+ answer: { value: "excalidraw", answered_by: "jay" },
+ };
+ vi.stubGlobal(
+ "fetch",
+ mockFetch({
+ "GET /api/decisions?status=pending": { ok: true, body: [] },
+ "GET /api/decisions?status=answered": { ok: true, body: [answered] },
+ }),
+ );
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /archive/i }));
+ await flush();
+
+ await waitFor(() => expect(screen.getByText("Excalidraw")).toBeTruthy());
+ expect(screen.queryByRole("button", { name: /view history/i })).toBeNull();
+ });
+
+ it("loads and renders the supersession lineage on demand for a revision", async () => {
+ const revision = {
+ ...singleSelect,
+ id: "dec-2",
+ status: "answered",
+ question: "Which canvas engine should replace tldraw? (revised)",
+ answer: { value: "excalidraw", answered_by: "jay" },
+ parent_decision_id: "dec-1",
+ };
+ const original = {
+ ...singleSelect,
+ id: "dec-1",
+ status: "superseded",
+ question: "Which canvas engine should replace tldraw?",
+ };
+ const fetchMock = mockFetch({
+ "GET /api/decisions?status=pending": { ok: true, body: [] },
+ "GET /api/decisions?status=answered": { ok: true, body: [revision] },
+ "GET /api/decisions/dec-2/history": {
+ ok: true,
+ body: { items: [original, revision] },
+ },
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /archive/i }));
+ await flush();
+
+ const historyBtn = await waitFor(() =>
+ screen.getByRole("button", { name: /view history/i }),
+ );
+ fireEvent.click(historyBtn);
+ await flush();
+
+ // The chain is fetched lazily on expand and rendered oldest first.
+ const historyCall = fetchMock.mock.calls.find(
+ (c) => c[0] === "/api/decisions/dec-2/history",
+ );
+ expect(historyCall).toBeTruthy();
+ await waitFor(() => expect(screen.getByText(/superseded/i)).toBeTruthy());
+ });
});
diff --git a/desktop/src/apps/DecisionsApp.tsx b/desktop/src/apps/DecisionsApp.tsx
index 126ce390..2f3b575f 100644
--- a/desktop/src/apps/DecisionsApp.tsx
+++ b/desktop/src/apps/DecisionsApp.tsx
@@ -8,6 +8,7 @@ import {
X,
CheckCircle2,
AlertCircle,
+ History,
} from "lucide-react";
import { Button, Textarea } from "@/components/ui";
@@ -45,6 +46,10 @@ interface Decision {
answer?: DecisionAnswer | null;
created_at: number | string;
deadline?: number | null;
+ // The decision this one supersedes (L1). Present on a revision; absent on an
+ // original. When set, the supersession lineage can be walked via the history
+ // endpoint.
+ parent_decision_id?: string | null;
}
// created_at is stored as an epoch-seconds REAL on the backend, but the API
@@ -320,6 +325,82 @@ function answerLabel(decision: Decision): string {
return labels.join(", ");
}
+/** Expandable supersession lineage for a revised decision. Fetches the chain
+ * lazily on first expand so the archive list does not issue a request per card.
+ * The endpoint returns the chain oldest first. */
+function HistoryTrail({ decisionId }: { decisionId: string }) {
+ const [open, setOpen] = useState(false);
+ const [chain, setChain] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function toggle() {
+ if (open) {
+ setOpen(false);
+ return;
+ }
+ setOpen(true);
+ if (chain || loading) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch(`/api/decisions/${decisionId}/history`);
+ if (!res.ok) throw new Error("Could not load history.");
+ setChain(asDecisionList(await res.json()));
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Could not load history.");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+ {open && (
+
+ {loading && (
+
Loading history...
+ )}
+ {error && (
+
+ {error}
+
+ )}
+ {chain && chain.length > 0 && (
+
+ {chain.map((d, i) => (
+ -
+
+ {i + 1}.
+ {d.question}
+
+
+ {d.status === "superseded" ? "superseded" : answerLabel(d)}
+ {" ยท "}
+ {relativeTime(d.answer?.answered_at ?? d.created_at)}
+
+
+ ))}
+
+ )}
+ {chain && chain.length === 0 && !loading && (
+
No earlier versions.
+ )}
+
+ )}
+
+ );
+}
+
function AnsweredCard({ decision }: { decision: Decision }) {
return (
@@ -343,6 +424,7 @@ function AnsweredCard({ decision }: { decision: Decision }) {
{decision.status === "superseded" ? "Superseded" : answerLabel(decision)}
+ {decision.parent_decision_id && }
);
}
From 9b37a7979607522697f1cbeaf90b34ec941bd166 Mon Sep 17 00:00:00 2001
From: jaylfc
Date: Tue, 23 Jun 2026 19:22:56 +0100
Subject: [PATCH 2/2] fix(decisions): guard history-trail state updates after
unmount
Fold #1416 review: an archive card can unmount mid-fetch (tab switch or list
refresh) while /history is in flight, so the post-await setState would warn and
leak. Track liveness with a ref cleared on unmount and gate every post-await
setState on it (gitar Bug; kilo concurs). Also assert the oldest-first ordering
the description claims, via a testid on the history list (kilo).
---
desktop/src/apps/DecisionsApp.test.tsx | 24 ++++++++++++++++++------
desktop/src/apps/DecisionsApp.tsx | 23 ++++++++++++++++++-----
2 files changed, 36 insertions(+), 11 deletions(-)
diff --git a/desktop/src/apps/DecisionsApp.test.tsx b/desktop/src/apps/DecisionsApp.test.tsx
index 24c949b8..5e6a82fc 100644
--- a/desktop/src/apps/DecisionsApp.test.tsx
+++ b/desktop/src/apps/DecisionsApp.test.tsx
@@ -1,4 +1,11 @@
-import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
+import {
+ render,
+ screen,
+ fireEvent,
+ act,
+ waitFor,
+ within,
+} from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { DecisionsApp } from "./DecisionsApp";
@@ -184,12 +191,12 @@ describe("DecisionsApp", () => {
expect(screen.queryByRole("button", { name: /view history/i })).toBeNull();
});
- it("loads and renders the supersession lineage on demand for a revision", async () => {
+ it("loads and renders the supersession lineage oldest first on demand", async () => {
const revision = {
...singleSelect,
id: "dec-2",
status: "answered",
- question: "Which canvas engine should replace tldraw? (revised)",
+ question: "Revised tldraw replacement pick",
answer: { value: "excalidraw", answered_by: "jay" },
parent_decision_id: "dec-1",
};
@@ -197,7 +204,7 @@ describe("DecisionsApp", () => {
...singleSelect,
id: "dec-1",
status: "superseded",
- question: "Which canvas engine should replace tldraw?",
+ question: "Original tldraw replacement pick",
};
const fetchMock = mockFetch({
"GET /api/decisions?status=pending": { ok: true, body: [] },
@@ -219,11 +226,16 @@ describe("DecisionsApp", () => {
fireEvent.click(historyBtn);
await flush();
- // The chain is fetched lazily on expand and rendered oldest first.
+ // The chain is fetched lazily only on expand.
const historyCall = fetchMock.mock.calls.find(
(c) => c[0] === "/api/decisions/dec-2/history",
);
expect(historyCall).toBeTruthy();
- await waitFor(() => expect(screen.getByText(/superseded/i)).toBeTruthy());
+
+ // It must render oldest first: the original (superseded) before the revision.
+ const trail = await waitFor(() => screen.getByTestId("decision-history"));
+ const steps = within(trail).getAllByRole("listitem");
+ expect(steps[0].textContent).toContain("Original tldraw replacement pick");
+ expect(steps[1].textContent).toContain("Revised tldraw replacement pick");
});
});
diff --git a/desktop/src/apps/DecisionsApp.tsx b/desktop/src/apps/DecisionsApp.tsx
index 2f3b575f..f709673b 100644
--- a/desktop/src/apps/DecisionsApp.tsx
+++ b/desktop/src/apps/DecisionsApp.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback } from "react";
+import { useState, useEffect, useCallback, useRef } from "react";
import {
Inbox,
Sparkles,
@@ -333,6 +333,16 @@ function HistoryTrail({ decisionId }: { decisionId: string }) {
const [chain, setChain] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ // Guard against a state update after the card unmounts mid-fetch (an archive
+ // card can unmount on a tab switch or list refresh while /history is still in
+ // flight).
+ const aliveRef = useRef(true);
+ useEffect(() => {
+ aliveRef.current = true;
+ return () => {
+ aliveRef.current = false;
+ };
+ }, []);
async function toggle() {
if (open) {
@@ -346,11 +356,14 @@ function HistoryTrail({ decisionId }: { decisionId: string }) {
try {
const res = await fetch(`/api/decisions/${decisionId}/history`);
if (!res.ok) throw new Error("Could not load history.");
- setChain(asDecisionList(await res.json()));
+ const items = asDecisionList(await res.json());
+ if (aliveRef.current) setChain(items);
} catch (e) {
- setError(e instanceof Error ? e.message : "Could not load history.");
+ if (aliveRef.current) {
+ setError(e instanceof Error ? e.message : "Could not load history.");
+ }
} finally {
- setLoading(false);
+ if (aliveRef.current) setLoading(false);
}
}
@@ -376,7 +389,7 @@ function HistoryTrail({ decisionId }: { decisionId: string }) {
)}
{chain && chain.length > 0 && (
-
+
{chain.map((d, i) => (
-