Skip to content

Commit 1e28bda

Browse files
authored
feat(decisions): surface L1 supersession lineage in the archive (#1416)
* 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). * 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).
1 parent a717ed7 commit 1e28bda

2 files changed

Lines changed: 174 additions & 2 deletions

File tree

desktop/src/apps/DecisionsApp.test.tsx

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
1+
import {
2+
render,
3+
screen,
4+
fireEvent,
5+
act,
6+
waitFor,
7+
within,
8+
} from "@testing-library/react";
29
import { describe, it, expect, vi, beforeEach } from "vitest";
310
import { DecisionsApp } from "./DecisionsApp";
411

@@ -161,4 +168,74 @@ describe("DecisionsApp", () => {
161168

162169
await waitFor(() => expect(screen.getByText("Excalidraw")).toBeTruthy());
163170
});
171+
172+
it("offers no history affordance for an original (no parent) decision", async () => {
173+
const answered = {
174+
...singleSelect,
175+
status: "answered",
176+
answer: { value: "excalidraw", answered_by: "jay" },
177+
};
178+
vi.stubGlobal(
179+
"fetch",
180+
mockFetch({
181+
"GET /api/decisions?status=pending": { ok: true, body: [] },
182+
"GET /api/decisions?status=answered": { ok: true, body: [answered] },
183+
}),
184+
);
185+
render(<DecisionsApp windowId="w1" />);
186+
await flush();
187+
fireEvent.click(screen.getByRole("button", { name: /archive/i }));
188+
await flush();
189+
190+
await waitFor(() => expect(screen.getByText("Excalidraw")).toBeTruthy());
191+
expect(screen.queryByRole("button", { name: /view history/i })).toBeNull();
192+
});
193+
194+
it("loads and renders the supersession lineage oldest first on demand", async () => {
195+
const revision = {
196+
...singleSelect,
197+
id: "dec-2",
198+
status: "answered",
199+
question: "Revised tldraw replacement pick",
200+
answer: { value: "excalidraw", answered_by: "jay" },
201+
parent_decision_id: "dec-1",
202+
};
203+
const original = {
204+
...singleSelect,
205+
id: "dec-1",
206+
status: "superseded",
207+
question: "Original tldraw replacement pick",
208+
};
209+
const fetchMock = mockFetch({
210+
"GET /api/decisions?status=pending": { ok: true, body: [] },
211+
"GET /api/decisions?status=answered": { ok: true, body: [revision] },
212+
"GET /api/decisions/dec-2/history": {
213+
ok: true,
214+
body: { items: [original, revision] },
215+
},
216+
});
217+
vi.stubGlobal("fetch", fetchMock);
218+
render(<DecisionsApp windowId="w1" />);
219+
await flush();
220+
fireEvent.click(screen.getByRole("button", { name: /archive/i }));
221+
await flush();
222+
223+
const historyBtn = await waitFor(() =>
224+
screen.getByRole("button", { name: /view history/i }),
225+
);
226+
fireEvent.click(historyBtn);
227+
await flush();
228+
229+
// The chain is fetched lazily only on expand.
230+
const historyCall = fetchMock.mock.calls.find(
231+
(c) => c[0] === "/api/decisions/dec-2/history",
232+
);
233+
expect(historyCall).toBeTruthy();
234+
235+
// It must render oldest first: the original (superseded) before the revision.
236+
const trail = await waitFor(() => screen.getByTestId("decision-history"));
237+
const steps = within(trail).getAllByRole("listitem");
238+
expect(steps[0].textContent).toContain("Original tldraw replacement pick");
239+
expect(steps[1].textContent).toContain("Revised tldraw replacement pick");
240+
});
164241
});

desktop/src/apps/DecisionsApp.tsx

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useCallback } from "react";
1+
import { useState, useEffect, useCallback, useRef } from "react";
22
import {
33
Inbox,
44
Sparkles,
@@ -8,6 +8,7 @@ import {
88
X,
99
CheckCircle2,
1010
AlertCircle,
11+
History,
1112
} from "lucide-react";
1213
import { Button, Textarea } from "@/components/ui";
1314

@@ -45,6 +46,10 @@ interface Decision {
4546
answer?: DecisionAnswer | null;
4647
created_at: number | string;
4748
deadline?: number | null;
49+
// The decision this one supersedes (L1). Present on a revision; absent on an
50+
// original. When set, the supersession lineage can be walked via the history
51+
// endpoint.
52+
parent_decision_id?: string | null;
4853
}
4954

5055
// created_at is stored as an epoch-seconds REAL on the backend, but the API
@@ -320,6 +325,95 @@ function answerLabel(decision: Decision): string {
320325
return labels.join(", ");
321326
}
322327

328+
/** Expandable supersession lineage for a revised decision. Fetches the chain
329+
* lazily on first expand so the archive list does not issue a request per card.
330+
* The endpoint returns the chain oldest first. */
331+
function HistoryTrail({ decisionId }: { decisionId: string }) {
332+
const [open, setOpen] = useState(false);
333+
const [chain, setChain] = useState<Decision[] | null>(null);
334+
const [loading, setLoading] = useState(false);
335+
const [error, setError] = useState<string | null>(null);
336+
// Guard against a state update after the card unmounts mid-fetch (an archive
337+
// card can unmount on a tab switch or list refresh while /history is still in
338+
// flight).
339+
const aliveRef = useRef(true);
340+
useEffect(() => {
341+
aliveRef.current = true;
342+
return () => {
343+
aliveRef.current = false;
344+
};
345+
}, []);
346+
347+
async function toggle() {
348+
if (open) {
349+
setOpen(false);
350+
return;
351+
}
352+
setOpen(true);
353+
if (chain || loading) return;
354+
setLoading(true);
355+
setError(null);
356+
try {
357+
const res = await fetch(`/api/decisions/${decisionId}/history`);
358+
if (!res.ok) throw new Error("Could not load history.");
359+
const items = asDecisionList(await res.json());
360+
if (aliveRef.current) setChain(items);
361+
} catch (e) {
362+
if (aliveRef.current) {
363+
setError(e instanceof Error ? e.message : "Could not load history.");
364+
}
365+
} finally {
366+
if (aliveRef.current) setLoading(false);
367+
}
368+
}
369+
370+
return (
371+
<div className="flex flex-col gap-1.5">
372+
<button
373+
type="button"
374+
onClick={toggle}
375+
aria-expanded={open}
376+
className="flex items-center gap-1 self-start text-xs font-medium text-shell-text-secondary transition-colors hover:text-shell-text"
377+
>
378+
<History size={12} className="shrink-0" />
379+
{open ? "Hide history" : "View history"}
380+
</button>
381+
{open && (
382+
<div className="flex flex-col gap-1.5 border-l border-shell-border pl-3">
383+
{loading && (
384+
<p className="text-xs text-shell-text-tertiary">Loading history...</p>
385+
)}
386+
{error && (
387+
<p className="text-xs text-red-400" role="alert">
388+
{error}
389+
</p>
390+
)}
391+
{chain && chain.length > 0 && (
392+
<ol className="flex flex-col gap-1.5" data-testid="decision-history">
393+
{chain.map((d, i) => (
394+
<li key={d.id} className="flex flex-col gap-0.5 text-xs">
395+
<span className="flex items-start gap-1.5 text-shell-text-secondary">
396+
<span className="shrink-0 text-shell-text-tertiary">{i + 1}.</span>
397+
{d.question}
398+
</span>
399+
<span className="pl-4 text-shell-text-tertiary">
400+
{d.status === "superseded" ? "superseded" : answerLabel(d)}
401+
{" · "}
402+
{relativeTime(d.answer?.answered_at ?? d.created_at)}
403+
</span>
404+
</li>
405+
))}
406+
</ol>
407+
)}
408+
{chain && chain.length === 0 && !loading && (
409+
<p className="text-xs text-shell-text-tertiary">No earlier versions.</p>
410+
)}
411+
</div>
412+
)}
413+
</div>
414+
);
415+
}
416+
323417
function AnsweredCard({ decision }: { decision: Decision }) {
324418
return (
325419
<li className="flex flex-col gap-2 rounded-xl border border-shell-border bg-shell-surface p-4">
@@ -343,6 +437,7 @@ function AnsweredCard({ decision }: { decision: Decision }) {
343437
{decision.status === "superseded" ? "Superseded" : answerLabel(decision)}
344438
</span>
345439
</div>
440+
{decision.parent_decision_id && <HistoryTrail decisionId={decision.id} />}
346441
</li>
347442
);
348443
}

0 commit comments

Comments
 (0)