diff --git a/desktop/src/apps/DecisionsApp.test.tsx b/desktop/src/apps/DecisionsApp.test.tsx index 3394a6fb..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"; @@ -161,4 +168,74 @@ 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 oldest first on demand", async () => { + const revision = { + ...singleSelect, + id: "dec-2", + status: "answered", + question: "Revised tldraw replacement pick", + answer: { value: "excalidraw", answered_by: "jay" }, + parent_decision_id: "dec-1", + }; + const original = { + ...singleSelect, + id: "dec-1", + status: "superseded", + question: "Original tldraw replacement pick", + }; + 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 only on expand. + const historyCall = fetchMock.mock.calls.find( + (c) => c[0] === "/api/decisions/dec-2/history", + ); + expect(historyCall).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 126ce390..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, @@ -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,95 @@ 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); + // 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) { + 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."); + const items = asDecisionList(await res.json()); + if (aliveRef.current) setChain(items); + } catch (e) { + if (aliveRef.current) { + setError(e instanceof Error ? e.message : "Could not load history."); + } + } finally { + if (aliveRef.current) setLoading(false); + } + } + + return ( +
+ + {open && ( +
+ {loading && ( +

Loading history...

+ )} + {error && ( +

+ {error} +

+ )} + {chain && chain.length > 0 && ( +
    + {chain.map((d, i) => ( +
  1. + + {i + 1}. + {d.question} + + + {d.status === "superseded" ? "superseded" : answerLabel(d)} + {" ยท "} + {relativeTime(d.answer?.answered_at ?? d.created_at)} + +
  2. + ))} +
+ )} + {chain && chain.length === 0 && !loading && ( +

No earlier versions.

+ )} +
+ )} +
+ ); +} + function AnsweredCard({ decision }: { decision: Decision }) { return (
  • @@ -343,6 +437,7 @@ function AnsweredCard({ decision }: { decision: Decision }) { {decision.status === "superseded" ? "Superseded" : answerLabel(decision)} + {decision.parent_decision_id && }
  • ); }