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) => ( + {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(); + }); +});