fix(review): proof-of-work markdown links open in the in-app drawer, not a full-page raw asset

FOUNDER UX fix (qitem-20260722234754-e8db7111): a proof-of-work file link in
the PROOF tab navigated the whole browser out of the SPA to the raw
/api/files/asset for guard/qa/rev1 .md verdicts. It now opens IN-APP in the
SharedDetailDrawer — rendering the C1 proof header (slice / candidate_sha /
artifact_type / verdict / money_evidence) + body — and closes cleanly back to
the review surface.

Fix (ProofTab.tsx only): the proof/ otherFiles list routes Markdown artifacts
(.md/.mdx, case-insensitive) through the existing FileLink primitive
(root=resolved.rootName, display path proof/<name>, readPath=<proofRel>/<name>)
instead of an <a target="_blank"> raw-asset anchor. Non-Markdown other files
(logs/video/binary) keep the browser-viewable raw-asset link; the image
gallery/lightbox, inline PROOF.md asset resolution, and lazy proof/ listing are
unchanged. The DELIVERED "see all proof" drill-in already opens in-drawer via
EvidenceOpener -> ArtifactsNavigator -> FileLink (no change needed there).

Tests (frozen pre-fix): proof-tab.test.tsx FX-1..FX-4 pin the in-app control,
the lazy slice-relative /read, the five-field C1 header + body, and
close-in-place with location/history unchanged; FX-5 preserves images/lightbox +
inline PROOF.md. artifacts-navigator.test.tsx pins the real delivered-see-all ->
EvidenceOpener('proof/') drill-in opening a C1 proof file in the drawer.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-07-23 00:11:57 +00:00
1 parent 7d0997ddda
commit 44ef16c1b5
3 files changed
+343 -10

No files matched your search

@@ -24,6 +24,7 @@ import { useScopeMarkdown } from "../../hooks/useScopeMarkdown.js";
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
import { SectionHeader } from "../ui/section-header.js";
import { EmptyState } from "../ui/empty-state.js";
import { FileLink } from "../ui/FileLink.js";
// OPR.0.4.4.20: Lightbox extracted (verbatim) for reuse by the Review surface.
import { Lightbox } from "./Lightbox.js";
@@ -36,6 +37,11 @@ const VERDICT_TONE: Record<Verdict, string> = {
};
const IMAGE_RE = /\.(png|jpe?g|gif|webp|avif|svg)$/i;
// Markdown proof-of-work artifacts (guard/qa/rev1 verdicts) open IN-APP in the
// SharedDetailDrawer via FileLink — rendering the C1 header + body — instead of a
// full-page raw-asset navigation out of the SPA. Non-Markdown other files
// (logs/video/binary) keep their existing browser-viewable raw-asset link.
const MD_RE = /\.mdx?$/i;
/** Asset base for INLINE PROOF.md images — e.g. `![](proof/real-live.png)` in the
* Intent→Proof table, relative to the slice root where PROOF.md lives. MarkdownViewer
@@ -212,14 +218,27 @@ function ProofSliceCard({
<ul data-testid={`proof-files-${sliceId}`} className="mt-3 space-y-1 font-mono text-[10px]">
{otherFiles.map((f) => (
<li key={f.name}>
<a
href={fileAssetUrl(resolved!.rootName, `${proofRel}/${f.name}`)}
target="_blank"
rel="noreferrer"
className="text-on-surface-variant underline decoration-outline-variant underline-offset-2 hover:text-on-surface"
>
proof/{f.name}
</a>
{MD_RE.test(f.name) ? (
// Markdown proof artifact — open in the in-app drawer (C1 header +
// body), not a full-page raw-asset navigation out of the SPA.
<FileLink
root={resolved!.rootName}
path={`proof/${f.name}`}
readPath={`${proofRel}/${f.name}`}
className="text-on-surface-variant underline decoration-outline-variant underline-offset-2 hover:text-on-surface"
>
proof/{f.name}
</FileLink>
) : (
<a
href={fileAssetUrl(resolved!.rootName, `${proofRel}/${f.name}`)}
target="_blank"
rel="noreferrer"
className="text-on-surface-variant underline decoration-outline-variant underline-offset-2 hover:text-on-surface"
>
proof/{f.name}
</a>
)}
</li>
))}
</ul>
+130 -1
View File
@@ -3,9 +3,13 @@
// one is AC-3 (the lazy-load boundary — no eager file-body fetch, no tree pre-walk).
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { useState, type ReactNode } from "react";
import { render, screen, cleanup, waitFor, fireEvent, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ArtifactsNavigator } from "../src/components/project/ArtifactsNavigator.js";
import { EvidenceOpener, type EvidenceContext } from "../src/components/review/EvidenceOpener.js";
import { DrawerSelectionContext } from "../src/components/AppShell.js";
import { SharedDetailDrawer, type DrawerSelection } from "../src/components/SharedDetailDrawer.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch as unknown as typeof fetch;
@@ -33,6 +37,12 @@ const TREE: Record<string, Array<{ name: string; type: "dir" | "file" | "other";
{ name: "README.md", type: "file", size: 4096, mtime: "2026-06-23T22:01:00.000Z" },
{ name: "batch-1.change.diff", type: "file", size: 12288, mtime: "2026-06-23T22:52:00.000Z" },
{ name: "03-story-dag.intent.png", type: "file", size: 129024, mtime: "2026-06-23T22:53:00.000Z" },
{ name: "proof", type: "dir", size: null, mtime: "2026-06-23T22:52:00.000Z" },
],
// FOUNDER-FIX DELIVERED drill-in target — the slice proof/ folder EvidenceOpener
// ('proof/') scopes ArtifactsNavigator to; its C1 file must open in the drawer.
"missions/release-0.4.1/slices/15-workspace-ux/proof": [
{ name: "guard.md", type: "file", size: 480, mtime: "2026-06-23T22:52:00.000Z" },
],
};
@@ -55,10 +65,40 @@ function routeFiles({ rootsStatus = 200, rootsEmpty = false }: { rootsStatus?: n
const path = u.searchParams.get("path") ?? "";
return Promise.resolve(jsonResponse({ root: "work", path, entries: TREE[path] ?? [] }));
}
// File bodies — served ONLY when a file is opened (never on landing; AC-3 pins this).
if (url.includes("/api/files/read")) {
const u = new URL(url, "http://twin.local");
const path = u.searchParams.get("path") ?? "";
const content = FILE_BODIES[path];
if (content == null) return Promise.resolve(jsonResponse({ error: "not found" }, 404));
return Promise.resolve(jsonResponse({ root: "work", path, absolutePath: `/ws/${path}`, content, mtime: "2026-06-23T22:01:00.000Z", contentHash: "h", size: content.length }));
}
return Promise.resolve(jsonResponse({}, 404));
};
}
// C1 proof contract (docs/reference/sdlc-conventions.md §5) — five valid fields
// (artifact_type in the closed set) + a distinctive body, so the DELIVERED drawer
// render is asserted against real proof content, never a false green.
const GUARD_C1 = [
"---",
"slice: slice-15-workspace-ux",
"candidate_sha: 7d0997dddaab59f43bcc658fe2c0457128a64f53",
"artifact_type: guard",
"verdict: PASS",
"money_evidence: delivered see-all proof/ drill-in opens guard.md in the drawer",
"---",
"",
"# Guard Verdict",
"",
"DELIVERED-DRILL-IN-BODY: the proof file opened in the in-app drawer.",
].join("\n");
// Opened-file bodies keyed by slice-relative read path (drawer content).
const FILE_BODIES: Record<string, string> = {
"missions/release-0.4.1/slices/15-workspace-ux/proof/guard.md": GUARD_C1,
};
function listedPaths(): string[] {
return calls
.filter((c) => c.includes("/api/files/list"))
@@ -168,4 +208,93 @@ describe("OPR.0.4.1.21 — Artifacts navigator", () => {
// NOT the misleading out-of-scope state.
expect(screen.queryByTestId("artifacts-navigator-no-scope")).toBeNull();
});
// -------------------------------------------------------------------------
// FOUNDER FIX (qitem-20260722234754-e8db7111) — DELIVERED proof/ drill-in leg.
// The SECOND founder-named site: DELIVERED "see all proof" -> the REAL
// EvidenceOpener('proof/') folder control -> ArtifactsNavigator scoped to the
// slice proof/ dir, whose C1 file rows route through FileLink -> the drawer.
// GREEN preservation: this caller already opens IN-APP, so no production change
// is needed here — this pins it against regression through the real path.
// -------------------------------------------------------------------------
it("DELIVERED preservation: delivered-see-all -> EvidenceOpener('proof/') opens a proof C1 file IN the drawer, not a full-page asset", async () => {
mockFetch.mockImplementation(routeFiles());
const ctx: EvidenceContext = {
root: "work",
relPath: "missions/release-0.4.1/slices/15-workspace-ux",
slicePath: "/ws/missions/release-0.4.1/slices/15-workspace-ux",
};
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DrawerHost>
<EvidenceOpener evidenceRef="proof/" ctx={ctx} testId="delivered-see-all" />
</DrawerHost>
</QueryClientProvider>,
);
// pin the exact SPA location + history depth — a raw-asset navigation mutates these.
const hrefBefore = window.location.href;
const historyBefore = window.history.length;
// the DELIVERED "see all proof" folder control is the real founder-named caller.
const folderBtn = screen.getByTestId("delivered-see-all-folder");
expect(calls.some((c) => c.includes("/api/files/read"))).toBe(false); // lazy: nothing read yet
fireEvent.click(folderBtn);
// the proof/ folder drills into the navigator; its C1 file row is an in-app control.
const openCtrl = await screen.findByTestId("artifacts-file-open-guard.md");
expect(openCtrl.closest("a")).toBeNull();
expect(screen.queryByTestId("file-viewer")).toBeNull();
fireEvent.click(openCtrl);
const viewer = await screen.findByTestId("file-viewer");
// reads the exact slice proof path under the work root — never the /asset escape.
await waitFor(() =>
expect(
calls.some(
(c) =>
c.includes("/api/files/read") &&
c.includes("root=work") &&
c.includes("15-workspace-ux%2Fproof%2Fguard.md"),
),
).toBe(true),
);
expect(calls.some((c) => c.includes("/api/files/asset"))).toBe(false);
// valid C1 header (five fields) + distinctive body render in the drawer.
const fm = within(viewer).getByTestId("markdown-frontmatter");
for (const field of ["slice", "candidate_sha", "artifact_type", "verdict", "money_evidence"]) {
expect(within(fm).getByText(field)).toBeTruthy();
}
expect(within(fm).getByText("guard")).toBeTruthy();
expect(screen.getByText(/DELIVERED-DRILL-IN-BODY/)).toBeTruthy();
// close in place — the opener + drilled folder remain; location/history unmoved.
fireEvent.pointerDown(screen.getByTestId("shared-detail-drawer-outside"));
await waitFor(() => expect(screen.queryByTestId("file-viewer")).toBeNull());
expect(screen.getByTestId("delivered-see-all-folder")).toBeTruthy();
expect(screen.getByTestId("artifacts-file-open-guard.md")).toBeTruthy();
expect(window.location.href).toBe(hrefBefore);
expect(window.history.length).toBe(historyBefore);
});
});
// Minimal real drawer host — mirrors AppShell's DrawerSelection provider +
// SharedDetailDrawer so a FileLink click actually opens the drawer end-to-end.
function DrawerHost({ children }: { children: ReactNode }) {
const [selection, setSelection] = useState<DrawerSelection>(null);
return (
<DrawerSelectionContext.Provider value={{ selection, setSelection }}>
{children}
<SharedDetailDrawer
selection={selection}
onClose={() => setSelection(null)}
events={[]}
selectedDiscoveredId={null}
onSelectDiscoveredId={() => {}}
placementTarget={null}
onClearPlacement={() => {}}
/>
</DrawerSelectionContext.Provider>
);
}
+186 -1
View File
@@ -5,9 +5,12 @@
// and read-only (no /write).
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import { useState, type ReactNode } from "react";
import { render, screen, cleanup, waitFor, within, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ScopeProofRollup, SliceProofTab } from "../src/components/project/ProofTab.js";
import { DrawerSelectionContext } from "../src/components/AppShell.js";
import { SharedDetailDrawer, type DrawerSelection } from "../src/components/SharedDetailDrawer.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch as unknown as typeof fetch;
@@ -45,6 +48,39 @@ const SLICES: Record<string, { proofMd?: string; proofEntries?: Array<{ name: st
proofMd: "# PROOF — OPR.0.4.1.18 Queue summary\n\nClosed by: <seat> Date: <date> Verdict: <pass | pass-with-residue | ...>\n\n## What this proves\n\n<1-3 sentences>",
proofEntries: [],
},
// FOUNDER-FIX drawer slice — a markdown proof-of-work file (guard.md) that must
// open in the in-app drawer, alongside an image capture that stays in the gallery
// + an inline PROOF.md image (both preserved on the /api/files/asset path).
"missions/m/slices/20-drawer": {
proofMd: "# OPR.0.4.1.20 Proof\n\n**Verdict: PASS** · 2026-07-22\n\n## Intent -> Proof\n\n![cap](proof/shot.png)",
proofEntries: [
{ name: "guard.md", type: "file", size: 480, mtime: "2026-07-22T22:00:00.000Z" },
{ name: "shot.png", type: "file", size: 120000, mtime: "2026-07-22T22:01:00.000Z" },
],
},
};
// C1 proof contract (docs/reference/sdlc-conventions.md §5): five frontmatter
// fields + a distinctive body, so the drawer render is asserted against real
// proof content (header AND body), never a false green.
const GUARD_C1 = [
"---",
"slice: slice-04-review-tab-observability",
"candidate_sha: 7d0997dddaab59f43bcc658fe2c0457128a64f53",
"artifact_type: guard",
"verdict: PASS",
"money_evidence: proof/guard.md opens in the in-app drawer",
"---",
"",
"# Guard Verdict — slice-04",
"",
"DISTINCTIVE-BODY-MARKER: the guard proof-of-work rendered inside the drawer.",
].join("\n");
// Proof-of-work file bodies (proof/<name>, NOT PROOF.md) keyed by slice-relative
// read path — served by the /api/files/read route below only when opened.
const PROOF_FILES: Record<string, string> = {
"missions/m/slices/20-drawer/proof/guard.md": GUARD_C1,
};
function routeFiles(input: unknown) {
@@ -55,6 +91,11 @@ function routeFiles(input: unknown) {
}
if (url.includes("/api/files/read")) {
const path = new URL(url, "http://t.local").searchParams.get("path") ?? "";
// proof-of-work file read (proof/<name>, not PROOF.md) — the drawer content.
const proofFile = PROOF_FILES[path];
if (proofFile != null) {
return Promise.resolve(jsonResponse({ root: "work", path, absolutePath: `/ws/${path}`, content: proofFile, mtime: "2026-07-22T22:05:00.000Z", contentHash: "pf", size: proofFile.length }));
}
const slice = path.replace(/\/PROOF\.md$/, "");
const md = SLICES[slice]?.proofMd;
if (md == null) return Promise.resolve(jsonResponse({ error: "not found" }, 404));
@@ -158,3 +199,147 @@ describe("OPR.0.4.1.23 — PROOF tab", () => {
expect(screen.getByTestId("proof-verdict-OPR.0.4.1.16").textContent).toBe("PASS");
});
});
// ---------------------------------------------------------------------------
// FOUNDER FIX (qitem-20260722234754-e8db7111): proof-of-work file links must
// open IN-APP in the SharedDetailDrawer — rendering the C1 proof content — NOT
// navigate the whole browser to the raw /api/files/asset (a full-page escape
// out of the SPA). These tests mount the REAL drawer stack (DrawerSelection +
// SharedDetailDrawer + FileViewer) so a click exercises the true in-app path,
// and pin the preservation of images/lightbox + inline PROOF.md and the lazy
// no-read-before-click boundary. RED against ProofTab.tsx's raw <a target=_blank>.
// ---------------------------------------------------------------------------
// Minimal real drawer host — mirrors AppShell's DrawerSelection provider +
// SharedDetailDrawer so a FileReferenceTrigger click actually opens the drawer.
function DrawerHost({ children }: { children: ReactNode }) {
const [selection, setSelection] = useState<DrawerSelection>(null);
return (
<DrawerSelectionContext.Provider value={{ selection, setSelection }}>
{children}
<SharedDetailDrawer
selection={selection}
onClose={() => setSelection(null)}
events={[]}
selectedDiscoveredId={null}
onSelectDiscoveredId={() => {}}
placementTarget={null}
onClearPlacement={() => {}}
/>
</DrawerSelectionContext.Provider>
);
}
const DRAWER_SLICE = { id: "OPR.0.4.1.20", path: "/ws/missions/m/slices/20-drawer" };
// The canonical slice-relative proof path the drawer must read (URL-encoded).
const GUARD_READ = "missions%2Fm%2Fslices%2F20-drawer%2Fproof%2Fguard.md";
function renderSliceInDrawer() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<DrawerHost>
<SliceProofTab sliceId={DRAWER_SLICE.id} title="brief" slicePath={DRAWER_SLICE.path} />
</DrawerHost>
</QueryClientProvider>,
);
}
describe("PROOF tab — proof file opens in the in-app drawer (founder fix e8db7111)", () => {
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockImplementation(routeFiles);
calls = [];
});
afterEach(() => cleanup());
it("FX-1 (RED): a markdown proof link is an in-app control, not a full-page raw-asset anchor", async () => {
renderSliceInDrawer();
const list = await screen.findByTestId(`proof-files-${DRAWER_SLICE.id}`);
const label = within(list).getByText("proof/guard.md");
// desired: an in-app button (FileReferenceTrigger), NOT an <a> that escapes the SPA.
expect(label.closest("button")).toBeTruthy();
expect(label.closest("a")).toBeNull();
// no full-page raw-asset escape anywhere in the proof-files list.
expect(list.querySelector('a[target="_blank"]')).toBeNull();
expect(list.querySelector('a[href*="/api/files/asset"]')).toBeNull();
});
it("FX-2 (RED): the read is LAZY — clicking opens the drawer and reads the slice-relative proof path exactly", async () => {
renderSliceInDrawer();
const list = await screen.findByTestId(`proof-files-${DRAWER_SLICE.id}`);
// lazy: no read of guard.md before the click (only PROOF.md + the proof/ listing so far).
expect(calls.some((c) => c.includes("/api/files/read") && c.includes("guard.md"))).toBe(false);
expect(screen.queryByTestId("file-viewer")).toBeNull();
fireEvent.click(within(list).getByText("proof/guard.md"));
const viewer = await screen.findByTestId("file-viewer");
// reads the canonical slice-relative proof path under the work root — the money assertion.
await waitFor(() =>
expect(
calls.some((c) => c.includes("/api/files/read") && c.includes("root=work") && c.includes(GUARD_READ)),
).toBe(true),
);
expect(within(viewer).getByTestId("file-viewer-root-path").textContent).toContain(
"missions/m/slices/20-drawer/proof/guard.md",
);
// DISPLAY path stays the friendly slice-relative label proof/guard.md (the drawer
// header), distinct from the resolved read path above — pins the FileLink display.
expect(within(viewer).getByText("proof/guard.md")).toBeTruthy();
});
it("FX-3 (RED): the drawer renders the C1 proof contract — five frontmatter fields + the distinctive body", async () => {
renderSliceInDrawer();
const list = await screen.findByTestId(`proof-files-${DRAWER_SLICE.id}`);
fireEvent.click(within(list).getByText("proof/guard.md"));
await screen.findByTestId("file-viewer");
const fm = await screen.findByTestId("markdown-frontmatter");
for (const field of ["slice", "candidate_sha", "artifact_type", "verdict", "money_evidence"]) {
expect(within(fm).getByText(field)).toBeTruthy();
}
// distinctive field VALUES (not just keys) — prevents a false green on an empty header.
expect(within(fm).getByText("guard")).toBeTruthy();
expect(within(fm).getByText("7d0997dddaab59f43bcc658fe2c0457128a64f53")).toBeTruthy();
// the BODY (not just the header) renders inside the drawer.
expect(screen.getByText(/DISTINCTIVE-BODY-MARKER/)).toBeTruthy();
});
it("FX-4 (RED): the drawer closes in place — same PROOF surface, location/history unchanged (no navigation)", async () => {
renderSliceInDrawer();
const list = await screen.findByTestId(`proof-files-${DRAWER_SLICE.id}`);
// pin the exact SPA location + history depth BEFORE the interaction — a full-page
// raw-asset navigation (the defect) is precisely what mutates these.
const hrefBefore = window.location.href;
const historyBefore = window.history.length;
fireEvent.click(within(list).getByText("proof/guard.md"));
await screen.findByTestId("file-viewer");
// opening the drawer is in-app: no navigation, no history push.
expect(window.location.href).toBe(hrefBefore);
expect(window.history.length).toBe(historyBefore);
fireEvent.pointerDown(screen.getByTestId("shared-detail-drawer-outside"));
await waitFor(() => expect(screen.queryByTestId("file-viewer")).toBeNull());
// still on the same slice proof card, and the location/history never moved.
expect(screen.getByTestId(`proof-slice-${DRAWER_SLICE.id}`)).toBeTruthy();
expect(screen.getByTestId(`proof-files-${DRAWER_SLICE.id}`)).toBeTruthy();
expect(window.location.href).toBe(hrefBefore);
expect(window.history.length).toBe(historyBefore);
});
it("FX-5 (GREEN preservation): image captures + inline PROOF.md images still resolve via /api/files/asset (drawer is markdown-only)", async () => {
const { container } = renderSliceInDrawer();
await screen.findByTestId(`proof-slice-${DRAWER_SLICE.id}`);
// the proof/ image capture stays in the gallery/lightbox path (browser-viewable asset), not the drawer.
expect(screen.getByTestId(`proof-gallery-${DRAWER_SLICE.id}`)).toBeTruthy();
expect(screen.getByTestId("proof-thumb-shot.png")).toBeTruthy();
// inline PROOF.md image still resolves under /api/files/asset (guard fcf1126f invariant).
const inline = container.querySelector('img[alt="cap"]') as HTMLImageElement | null;
expect(inline).toBeTruthy();
expect(inline!.getAttribute("src") ?? "").toContain("/api/files/asset");
// and the guard.md control never triggered a read (drawer stays closed here).
expect(calls.some((c) => c.includes("/api/files/read") && c.includes("guard.md"))).toBe(false);
});
});