feat(lanes): show proof gallery panel in the Workspace page (C)
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user