feat(lanes): show proof gallery panel in the Workspace page (C)

This commit is contained in:
2026-08-04 16:25:09 +07:00
parent a545230746
commit fccfa5ad17
6 changed files with 178 additions and 0 deletions
+1
View File
@@ -84,6 +84,7 @@
"features.live": "Live", "features.live": "Live",
"features.archived": "archived", "features.archived": "archived",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.", "features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
"proof.ticketReport": "Task report",
"statusDead": "DEAD", "statusDead": "DEAD",
"title": "Lanes", "title": "Lanes",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message" "tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
+1
View File
@@ -84,6 +84,7 @@
"features.live": "Phiên bản trực tiếp", "features.live": "Phiên bản trực tiếp",
"features.archived": "đã lưu trữ", "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.", "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", "statusDead": "ĐÃ CHẾT",
"title": "Làn đường", "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" "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"
+31
View File
@@ -2032,6 +2032,37 @@ export const api = {
`/lanes/${id}/features/${encodeURIComponent(slug)}` `/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 <img src>.
* @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 ──────────────────────────────── // ──────────────────────────────── Locks API ────────────────────────────────
/** Named locks across all lanes: used for serialization and gating. /** Named locks across all lanes: used for serialization and gating.
+11
View File
@@ -2304,6 +2304,15 @@ export interface LaneFeature {
pipeline_nodes: LaneNode[]; pipeline_nodes: LaneNode[];
progress: number; progress: number;
} }
/** QC proof gallery: screenshots and artifact report for a feature. */
export interface ProofFeature {
slug: string;
groups: Record<string, string[]>;
ticket_report: string;
mtime: number;
}
export interface Lane { export interface Lane {
id: number; id: number;
title: string; title: string;
@@ -2340,6 +2349,8 @@ export interface Lane {
/** Ports the lane ACTUALLY bound, by declared name. Empty until first boot. /** Ports the lane ACTUALLY bound, by declared name. Empty until first boot.
* May differ from `base + slot` when the preferred number was taken. */ * May differ from `base + slot` when the preferred number was taken. */
ports: Record<string, number>; ports: Record<string, number>;
/** 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 /** One line a lane's profile hook wrote, pushed while the hook is still running
+65
View File
@@ -58,6 +58,7 @@ import type {
Lane, Lane,
LaneFeature, LaneFeature,
LaneCounts, LaneCounts,
ProofFeature,
WSMessage, WSMessage,
} from "../lib/types"; } from "../lib/types";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
@@ -132,6 +133,7 @@ export function Workspace() {
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null); const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
const [features, setFeatures] = useState<LaneFeature[]>([]); const [features, setFeatures] = useState<LaneFeature[]>([]);
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null); const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
// Run state // Run state
const [mode, setMode] = useState<RunMode>("conversation"); const [mode, setMode] = useState<RunMode>("conversation");
@@ -804,6 +806,28 @@ export function Workspace() {
}; };
}, [currentLane?.id, viewedFeatureSlug]); }, [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. // 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 // The config-card screen needs normal page flow so the form is fully
// reachable on short windows. The run-session screen, however, owns the // reachable on short windows. The run-session screen, however, owns the
@@ -1068,6 +1092,47 @@ export function Workspace() {
</p> </p>
)} )}
</div> </div>
{proofFeature &&
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
<div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && (
<a
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
target="_blank"
rel="noreferrer"
className="text-xs text-fg-muted"
>
{tLanes("proof.ticketReport")}
</a>
)}
{Object.entries(proofFeature.groups).map(([group, images]) => (
<div key={group} className="mt-1">
<span className="text-xs text-fg-muted">
{group} · {images.length}
</span>
<div className="flex flex-wrap gap-1">
{images.slice(0, 8).map((img) => (
<img
key={img}
loading="lazy"
className="h-16 w-16 rounded object-cover"
src={api.lanes.proof.imageUrl(
currentLane.id,
proofFeature.slug,
group,
img
)}
alt={img}
/>
))}
{images.length > 8 && (
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
)}
</div>
</div>
))}
</div>
)}
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3"> <div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
{consoleSection} {consoleSection}
</div> </div>
@@ -114,6 +114,21 @@ vi.mock("../../lib/api", async (importOriginal) => {
return { feature: null }; 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: { run: {
list: vi.fn().mockImplementation(async () => { list: vi.fn().mockImplementation(async () => {
@@ -213,6 +228,7 @@ vi.mock("../../lib/eventBus", () => ({
})); }));
import { Workspace } from "../Workspace"; import { Workspace } from "../Workspace";
import { api } from "../../lib/api";
class ObserverStub { class ObserverStub {
observe() {} observe() {}
@@ -553,3 +569,56 @@ describe("Workspace — the console is its own section", () => {
expect(detail.contains(body)).toBe(true); 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();
});
});