feat(lanes): add a read-only feature picker to the Workspace page (B)
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user