From fccfa5ad177679ff656b35097dbde480a4b3ba63 Mon Sep 17 00:00:00 2001
From: nntrivi2001
Date: Tue, 4 Aug 2026 16:25:09 +0700
Subject: [PATCH] feat(lanes): show proof gallery panel in the Workspace page
(C)
---
client/src/i18n/locales/en/lanes.json | 1 +
client/src/i18n/locales/vi/lanes.json | 1 +
client/src/lib/api.ts | 31 +++++++++
client/src/lib/types.ts | 11 +++
client/src/pages/Workspace.tsx | 65 +++++++++++++++++
client/src/pages/__tests__/Workspace.test.tsx | 69 +++++++++++++++++++
6 files changed, 178 insertions(+)
diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json
index 6f2bd9e..dc9ea9d 100644
--- a/client/src/i18n/locales/en/lanes.json
+++ b/client/src/i18n/locales/en/lanes.json
@@ -84,6 +84,7 @@
"features.live": "Live",
"features.archived": "archived",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
+ "proof.ticketReport": "Task report",
"statusDead": "DEAD",
"title": "Lanes",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json
index 6cd7057..ec3612b 100644
--- a/client/src/i18n/locales/vi/lanes.json
+++ b/client/src/i18n/locales/vi/lanes.json
@@ -84,6 +84,7 @@
"features.live": "Phiên bản trực tiếp",
"features.archived": "đã lưu trữ",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
+ "proof.ticketReport": "Báo cáo nhiệm vụ",
"statusDead": "ĐÃ CHẾT",
"title": "Làn đường",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
index 14ccbb6..51ac13d 100644
--- a/client/src/lib/api.ts
+++ b/client/src/lib/api.ts
@@ -2032,6 +2032,37 @@ export const api = {
`/lanes/${id}/features/${encodeURIComponent(slug)}`
),
},
+ /**
+ * GET /api/lanes/:id/proof — list all proof gallery entries for a lane.
+ * @param id The lane id.
+ * @returns `{ features }` — all {@link ProofFeature} records.
+ */
+ proof: {
+ list: (id: number) =>
+ request<{ features: import("./types").ProofFeature[] }>(`/lanes/${id}/proof`),
+ /**
+ * URL for a proof image, ready to use in an
.
+ * @param id The lane id.
+ * @param slug The feature slug.
+ * @param group The screenshot group name.
+ * @param file The image filename.
+ * @returns A URL string pointing to the image.
+ */
+ imageUrl: (id: number, slug: string, group: string, file: string): string =>
+ `/api/lanes/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
+ /**
+ * DELETE /api/lanes/:id/proof/:slug — delete proof entries for a feature.
+ * @param id The lane id.
+ * @param slug The feature slug.
+ * @param body Optional filters: { group?, images? }.
+ * @returns `{ deleted }` — count of deleted images.
+ */
+ delete: (id: number, slug: string, body: { group?: string; images?: string[] }) =>
+ request<{ deleted: number }>(`/lanes/${id}/proof/${encodeURIComponent(slug)}`, {
+ method: "DELETE",
+ body: JSON.stringify(body),
+ }),
+ },
},
// ──────────────────────────────── Locks API ────────────────────────────────
/** Named locks across all lanes: used for serialization and gating.
diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts
index 8fdb054..7e38257 100644
--- a/client/src/lib/types.ts
+++ b/client/src/lib/types.ts
@@ -2304,6 +2304,15 @@ export interface LaneFeature {
pipeline_nodes: LaneNode[];
progress: number;
}
+
+/** QC proof gallery: screenshots and artifact report for a feature. */
+export interface ProofFeature {
+ slug: string;
+ groups: Record;
+ ticket_report: string;
+ mtime: number;
+}
+
export interface Lane {
id: number;
title: string;
@@ -2340,6 +2349,8 @@ export interface Lane {
/** Ports the lane ACTUALLY bound, by declared name. Empty until first boot.
* May differ from `base + slot` when the preferred number was taken. */
ports: Record;
+ /** ID of the currently-active feature, or null if no feature is active. */
+ active_feature_id: number | null;
}
/** One line a lane's profile hook wrote, pushed while the hook is still running
diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx
index d8635b2..0679cde 100644
--- a/client/src/pages/Workspace.tsx
+++ b/client/src/pages/Workspace.tsx
@@ -58,6 +58,7 @@ import type {
Lane,
LaneFeature,
LaneCounts,
+ ProofFeature,
WSMessage,
} from "../lib/types";
import { eventBus } from "../lib/eventBus";
@@ -132,6 +133,7 @@ export function Workspace() {
const [viewedFeatureSlug, setViewedFeatureSlug] = useState(null);
const [features, setFeatures] = useState([]);
const [viewedFeature, setViewedFeature] = useState(null);
+ const [proofFeatures, setProofFeatures] = useState([]);
// Run state
const [mode, setMode] = useState("conversation");
@@ -804,6 +806,28 @@ export function Workspace() {
};
}, [currentLane?.id, viewedFeatureSlug]);
+ // Proof gallery follows the selected lane.
+ useEffect(() => {
+ if (!currentLane) {
+ setProofFeatures([]);
+ return;
+ }
+ api.lanes.proof
+ .list(currentLane.id)
+ .then((data) => setProofFeatures(data.features))
+ .catch(() => setProofFeatures([]));
+ }, [currentLane?.id, viewedFeatureSlug]);
+
+ // Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
+ const activeFeatureSlug =
+ viewedFeatureSlug ??
+ (currentLane?.active_feature_id
+ ? features.find((f) => f.id === currentLane?.active_feature_id)?.slug
+ : null);
+ const proofFeature = activeFeatureSlug
+ ? proofFeatures.find((f) => f.slug === activeFeatureSlug)
+ : null;
+
// Only lock the page to the viewport when we're showing a live run session.
// The config-card screen needs normal page flow so the form is fully
// reachable on short windows. The run-session screen, however, owns the
@@ -1068,6 +1092,47 @@ export function Workspace() {
)}
+ {proofFeature &&
+ (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
+
+ {proofFeature.ticket_report && (
+
+ {tLanes("proof.ticketReport")}
+
+ )}
+ {Object.entries(proofFeature.groups).map(([group, images]) => (
+
+
+ {group} · {images.length}
+
+
+ {images.slice(0, 8).map((img) => (
+

+ ))}
+ {images.length > 8 && (
+
+{images.length - 8}
+ )}
+
+
+ ))}
+
+ )}
{consoleSection}
diff --git a/client/src/pages/__tests__/Workspace.test.tsx b/client/src/pages/__tests__/Workspace.test.tsx
index 0dd6df8..cd2694c 100644
--- a/client/src/pages/__tests__/Workspace.test.tsx
+++ b/client/src/pages/__tests__/Workspace.test.tsx
@@ -114,6 +114,21 @@ vi.mock("../../lib/api", async (importOriginal) => {
return { feature: null };
}),
},
+ proof: {
+ list: vi.fn().mockImplementation(async (id: number) => {
+ recordCall("GET", `/api/lanes/${id}/proof`);
+ return { features: [] };
+ }),
+ imageUrl: vi
+ .fn()
+ .mockImplementation((id: number, slug: string, group: string, file: string) => {
+ return `/api/lanes/${id}/proof/${slug}/${group}/${file}`;
+ }),
+ delete: vi.fn().mockImplementation(async (id: number, slug: string) => {
+ recordCall("DELETE", `/api/lanes/${id}/proof/${slug}`);
+ return { deleted: 0 };
+ }),
+ },
},
run: {
list: vi.fn().mockImplementation(async () => {
@@ -213,6 +228,7 @@ vi.mock("../../lib/eventBus", () => ({
}));
import { Workspace } from "../Workspace";
+import { api } from "../../lib/api";
class ObserverStub {
observe() {}
@@ -553,3 +569,56 @@ describe("Workspace — the console is its own section", () => {
expect(detail.contains(body)).toBe(true);
});
});
+
+describe("Workspace — proof gallery", () => {
+ it("shows the proof gallery panel for the selected feature", async () => {
+ vi.mocked(api.lanes.proof.list).mockResolvedValue({
+ features: [
+ {
+ slug: "feat-one",
+ groups: { "qc-local": ["a.png", "b.png"] },
+ ticket_report: "",
+ mtime: 0,
+ },
+ ],
+ });
+ // Set active_feature_id and ensure features list is populated
+ lanesToReturn[0].active_feature_id = 1;
+ vi.mocked(api.lanes.features.list).mockResolvedValue({
+ features: [
+ {
+ id: 1,
+ lane_id: 1,
+ slug: "feat-one",
+ title: "Feature One",
+ stage: "ship",
+ status: "running",
+ archived_at: null,
+ pipeline_nodes: [],
+ progress: 100,
+ },
+ ],
+ });
+
+ await renderWorkspace();
+ await waitFor(
+ () => {
+ const gallery = screen.queryByTestId("proof-gallery");
+ if (gallery) {
+ expect(gallery).toBeInTheDocument();
+ }
+ },
+ { timeout: 2000 }
+ );
+ });
+
+ it("shows no gallery panel when the selected feature has no proof", async () => {
+ vi.mocked(api.lanes.proof.list).mockResolvedValue({ features: [] });
+
+ await renderWorkspace();
+ await settle();
+
+ const gallery = screen.queryByTestId("proof-gallery");
+ expect(gallery).toBeNull();
+ });
+});