feat(lanes): add a read-only feature picker to the Workspace page (B)

This commit is contained in:
2026-08-04 15:00:41 +07:00
parent 5b2f98ab4d
commit 3a83e849cf
6 changed files with 121 additions and 3 deletions
+62 -2
View File
@@ -56,6 +56,7 @@ import type {
TranscriptMessage,
TranscriptContent,
Lane,
LaneFeature,
LaneCounts,
WSMessage,
} from "../lib/types";
@@ -128,6 +129,9 @@ export function Workspace() {
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
const [laneActionError, setLaneActionError] = useState<string | null>(null);
const [addLaneOpen, setAddLaneOpen] = useState(false);
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
const [features, setFeatures] = useState<LaneFeature[]>([]);
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
// Run state
const [mode, setMode] = useState<RunMode>("conversation");
@@ -765,6 +769,41 @@ export function Workspace() {
const hasFinished = status === "completed" || status === "error" || status === "killed";
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
// Feature list follows the selected lane, resets the viewer on lane switch.
useEffect(() => {
setViewedFeatureSlug(null);
setViewedFeature(null);
if (currentLane === null || currentLane === undefined) {
setFeatures([]);
return;
}
api.lanes.features
.list(currentLane.id)
.then((data) => setFeatures(data.features))
.catch(() => setFeatures([]));
}, [currentLane?.id]);
// Fetch the archived snapshot when the picker selects one — read-only, never
// touches the live lane.
useEffect(() => {
if (!currentLane || !viewedFeatureSlug) {
setViewedFeature(null);
return;
}
let cancelled = false;
api.lanes.features
.show(currentLane.id, viewedFeatureSlug)
.then((data) => {
if (!cancelled) setViewedFeature(data.feature);
})
.catch(() => {
if (!cancelled) setViewedFeature(null);
});
return () => {
cancelled = true;
};
}, [currentLane?.id, viewedFeatureSlug]);
// 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
@@ -995,6 +1034,22 @@ export function Workspace() {
{tLanes("autoStage", { stage: currentLane.detected_stage })}
</span>
)}
{features.length > 0 && (
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
value={viewedFeatureSlug ?? ""}
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
>
<option value="">{tLanes("features.live")}</option>
{features.map((f) => (
<option key={f.slug} value={f.slug}>
{f.slug}
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
</option>
))}
</select>
)}
</div>
<div className="mb-3">
<LaneCard
@@ -1004,9 +1059,14 @@ export function Workspace() {
</div>
<div className="mb-3">
<PipelineMap
nodes={currentLane.pipeline_nodes}
detectedSignal={currentLane.detected_signal}
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
/>
{viewedFeature && (
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
</p>
)}
</div>
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
{consoleSection}