fix(workspace): move lane list to a vertical column beside detail panel

The detail panel (LaneCard/PipelineMap/proof gallery/console) could
grow tall enough to visually crowd out the lane-strip carousel above
it once a run was attached in split view. Move the lane list into its
own scrolling vertical column beside the detail panel instead of
stacking it above, so neither can cover the other; also collapse the
info block by default in 2/4-pane split view (toggle to expand) and
move the pane-count control into the detail header.
This commit is contained in:
2026-08-14 16:39:15 +07:00
parent bab19e2f36
commit a2b5fa4669
5 changed files with 552 additions and 494 deletions
+242 -192
View File
@@ -54,6 +54,36 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
// ── Page ──────────────────────────────────────────────────────────────
function SplitLayoutToggle({
splitView,
setLayout,
}: {
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
}) {
const { t: tLanes } = useTranslation("lanes");
return (
<div className="flex items-center gap-1.5">
{([1, 2, 4] as const).map((n) => (
<button
key={n}
type="button"
aria-pressed={splitView.layout === n}
onClick={() => setLayout(n)}
className={`rounded border px-2 py-1 text-xs ${
splitView.layout === n
? "border-accent bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:border-border-light"
}`}
>
{tLanes("splitView.paneCount", { count: n })}
</button>
))}
</div>
);
}
function ConsoleArea({
lanes,
selectedLaneId,
@@ -68,6 +98,7 @@ function ConsoleArea({
onHasActiveRunChange,
onLaneCreated,
onLaneIdChange,
showToggle = true,
}: {
lanes: Lane[];
selectedLaneId: number | null;
@@ -82,28 +113,11 @@ function ConsoleArea({
onHasActiveRunChange: (val: boolean) => void;
onLaneCreated: (lane: Lane) => void;
onLaneIdChange: (id: number | null) => void;
showToggle?: boolean;
}) {
const { t: tLanes } = useTranslation("lanes");
return (
<>
<div className="flex items-center gap-1.5">
{([1, 2, 4] as const).map((n) => (
<button
key={n}
type="button"
aria-pressed={splitView.layout === n}
onClick={() => setLayout(n)}
className={`rounded border px-2 py-1 text-xs ${
splitView.layout === n
? "border-accent bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:border-border-light"
}`}
>
{tLanes("splitView.paneCount", { count: n })}
</button>
))}
</div>
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
{splitView.layout === 1 ? (
<LaneConsolePane
lanes={lanes}
@@ -175,6 +189,11 @@ export function Workspace() {
// Split view state
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
// Info block (pickers, LaneCard, PipelineMap, proof gallery) is always shown
// in single-pane view; in split view it starts collapsed so tall content
// never crowds the pane grid, and the user expands it on demand.
const [infoExpanded, setInfoExpanded] = useState(false);
const showInfo = splitView.layout === 1 || infoExpanded;
const setLayout = useCallback((layout: SplitLayout) => {
setSplitView((prev) => {
@@ -488,161 +507,214 @@ export function Workspace() {
</button>
</div>
{/* Lane carousel: pick a lane here, read it below. Horizontal scroll with
snap so a dozen lanes stay in one row instead of a wall of cards. */}
<div
data-testid="lane-strip"
className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
>
{lanes.map((l) => (
<LaneStripCard
key={l.id}
lane={l}
selected={selectedLaneId === l.id}
onSelect={() => setSelectedLaneId(l.id)}
/>
))}
{!lanes.length && (
<p className="text-sm text-fg-muted">
{tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
</p>
)}
</div>
{/* Lane list + detail live side by side so neither can cover the other:
the list is a scrolling column (holds any number of lanes), the
detail/console area to its right never has to compete with it for
vertical space. */}
<div className="flex min-h-0 flex-1 gap-4">
<div
data-testid="lane-strip"
className="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
>
{lanes.map((l) => (
<LaneStripCard
key={l.id}
lane={l}
selected={selectedLaneId === l.id}
onSelect={() => setSelectedLaneId(l.id)}
/>
))}
{!lanes.length && (
<p className="text-sm text-fg-muted">
{tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
</p>
)}
</div>
{/* The selected lane's pipeline, full width — the thing you actually
come to this page to read. */}
{currentLane && (
<section data-testid="lane-detail" className="card p-4">
<div className="mb-3 flex flex-wrap items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
{tLanes("cardId", { id: currentLane.id })}
</span>
<span className="truncate text-sm font-semibold text-fg-primary">
{currentLane.title || currentLane.cwd}
</span>
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
viewedFeature
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
: undefined
}
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
>
{(pipelineTemplates.length
? pipelineTemplates
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
).map((p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
))}
</select>
{/* `stage` defaults to the DB sentinel "idle" until the driving
session ever calls `ccam stage` — that string collides with
`status`'s own "idle"/"running" vocabulary, so a lane that is
actively running but has never declared a stage read as if it
were sitting idle. Show a distinct label instead of the raw
sentinel whenever it doesn't match any node this pipeline
actually has. */}
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
? currentLane.stage
: tLanes("stageUndeclared")}
</span>
{currentLane.detected_stage && (
<span
data-testid="detail-auto-stage"
title={currentLane.detected_signal || undefined}
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
>
{tLanes("autoStage", { stage: currentLane.detected_stage })}
{/* The selected lane's pipeline — the thing you actually come to this
page to read, beside the list rather than stacked under it. */}
{currentLane && (
<section data-testid="lane-detail" className="flex min-h-0 flex-1 flex-col card p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<span className="truncate text-sm font-semibold text-fg-primary">
{tLanes("laneHeader", {
id: currentLane.id,
title: currentLane.title || currentLane.cwd,
pipeline: currentLane.pipeline_name,
})}
</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
lane={currentLane}
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
childWorktrees={lanes.filter(
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
)}
onSelectLane={setSelectedLaneId}
/>
</div>
<div className="mb-3">
<PipelineMap
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>
{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"
<div className="flex items-center gap-2">
{splitView.layout !== 1 && (
<button
type="button"
data-testid="lane-detail-toggle"
aria-expanded={infoExpanded}
onClick={() => setInfoExpanded((v) => !v)}
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
>
{tLanes("proof.ticketReport")}
</a>
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
</button>
)}
{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>
))}
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
</div>
</div>
{showInfo && (
<>
<div className="mb-3 flex flex-wrap items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
{tLanes("cardId", { id: currentLane.id })}
</span>
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
viewedFeature
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
: undefined
}
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
>
{(pipelineTemplates.length
? pipelineTemplates
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
).map((p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
))}
</select>
{/* `stage` defaults to the DB sentinel "idle" until the driving
session ever calls `ccam stage` — that string collides with
`status`'s own "idle"/"running" vocabulary, so a lane that is
actively running but has never declared a stage read as if it
were sitting idle. Show a distinct label instead of the raw
sentinel whenever it doesn't match any node this pipeline
actually has. */}
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
? currentLane.stage
: tLanes("stageUndeclared")}
</span>
{currentLane.detected_stage && (
<span
data-testid="detail-auto-stage"
title={currentLane.detected_signal || undefined}
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
>
{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
lane={currentLane}
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
childWorktrees={lanes.filter(
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
)}
onSelectLane={setSelectedLaneId}
/>
</div>
<div className="mb-3">
<PipelineMap
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>
{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-1 flex-col gap-2 border-t border-border pt-3">
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
setPaneLaneId={setPaneLaneId}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
onLaneIdChange={setSelectedLaneId}
showToggle={false}
/>
</div>
</section>
)}
{!currentLane && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
@@ -661,30 +733,8 @@ export function Workspace() {
onLaneIdChange={setSelectedLaneId}
/>
</div>
</section>
)}
{!currentLane && (
<div className="flex min-h-0 flex-col gap-2">
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
setPaneLaneId={setPaneLaneId}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
onLaneIdChange={setSelectedLaneId}
/>
</div>
)}
)}
</div>
<AddLaneModal
open={addLaneOpen}