From a2b5fa4669d21375328a2b638ded9f60663d85db Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 14 Aug 2026 16:39:15 +0700 Subject: [PATCH] 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. --- client/src/components/lanes/LaneStripCard.tsx | 12 +- client/src/i18n/locales/en/lanes.json | 2 + client/src/i18n/locales/vi/lanes.json | 2 + client/src/pages/Workspace.tsx | 434 +++++++------ .../screens.snapshot.test.tsx.snap | 596 +++++++++--------- 5 files changed, 552 insertions(+), 494 deletions(-) diff --git a/client/src/components/lanes/LaneStripCard.tsx b/client/src/components/lanes/LaneStripCard.tsx index ba6b266..865c99b 100644 --- a/client/src/components/lanes/LaneStripCard.tsx +++ b/client/src/components/lanes/LaneStripCard.tsx @@ -1,9 +1,9 @@ /** - * @file The compact lane tile used in the Workspace carousel. It carries only - * what you need to pick a lane — which lane, is it alive, what stage, how far — - * because the full card, its controls and its working-copy facts live in the - * detail panel below. Keeping the tile small is what lets a dozen lanes stay - * scannable in one horizontal row. + * @file The compact lane tile used in the Workspace's vertical lane list. It + * carries only what you need to pick a lane — which lane, is it alive, what + * stage, how far — because the full card, its controls and its working-copy + * facts live in the detail panel beside it. Keeping the tile small and full + * width is what lets many lanes stay scannable in one scrolling column. * @author Nguyễn Ngọc Trí Vĩ */ @@ -42,7 +42,7 @@ export default function LaneStripCard({ aria-pressed={selected} onClick={onSelect} title={lane.cwd} - className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left shadow-sm transition-colors ${ + className={`w-full shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${ selected ? "border-accent bg-accent/10" : "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3" diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 91bbe91..2b693cf 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -90,6 +90,8 @@ "git.uncommitted": "{{dirty}} modified · {{untracked}} untracked", "kind.adopted": "adopted", "kind.managed": "managed", + "laneDetail.hide": "Hide details", + "laneDetail.show": "Lane details", "laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}", "locks.held_one": "{{count}} lock held", "locks.held_other": "{{count}} locks held", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index dd40056..8601d5f 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -90,6 +90,8 @@ "git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi", "kind.adopted": "đã nhận", "kind.managed": "được quản lý", + "laneDetail.hide": "Ẩn chi tiết", + "laneDetail.show": "Chi tiết lane", "laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}", "locks.held_one": "Đang giữ {{count}} khóa", "locks.held_other": "Đang giữ {{count}} khóa", diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index c91c8c7..4e8f9b8 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -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 ( +
+ {([1, 2, 4] as const).map((n) => ( + + ))} +
+ ); +} + 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 ( <> -
- {([1, 2, 4] as const).map((n) => ( - - ))} -
+ {showToggle && } {splitView.layout === 1 ? ( (() => 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() { - {/* 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. */} -
- {lanes.map((l) => ( - setSelectedLaneId(l.id)} - /> - ))} - {!lanes.length && ( -

- {tLanes("emptyState")} ccam lanes add --cwd $(pwd) -

- )} -
+ {/* 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. */} +
+
+ {lanes.map((l) => ( + setSelectedLaneId(l.id)} + /> + ))} + {!lanes.length && ( +

+ {tLanes("emptyState")} ccam lanes add --cwd $(pwd) +

+ )} +
- {/* The selected lane's pipeline, full width — the thing you actually - come to this page to read. */} - {currentLane && ( -
-
- - {tLanes("cardId", { id: currentLane.id })} - - - {currentLane.title || currentLane.cwd} - - - {/* `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. */} - - {currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage) - ? currentLane.stage - : tLanes("stageUndeclared")} - - {currentLane.detected_stage && ( - - {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 && ( +
+
+ + {tLanes("laneHeader", { + id: currentLane.id, + title: currentLane.title || currentLane.cwd, + pipeline: currentLane.pipeline_name, + })} - )} - {features.length > 0 && ( - - )} -
-
- handleLaneAction(currentLane.id, a, b)} - childWorktrees={lanes.filter( - (l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id - )} - onSelectLane={setSelectedLaneId} - /> -
-
- - {viewedFeature && ( -

- {tLanes("features.viewingArchived", { slug: viewedFeature.slug })} -

- )} -
- {proofFeature && - (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && ( -
- {proofFeature.ticket_report && ( - + {splitView.layout !== 1 && ( + )} - {Object.entries(proofFeature.groups).map(([group, images]) => ( -
- - {group} · {images.length} - -
- {images.slice(0, 8).map((img) => ( - {img} - ))} - {images.length > 8 && ( - +{images.length - 8} - )} -
-
- ))} +
+
+ {showInfo && ( + <> +
+ + {tLanes("cardId", { id: currentLane.id })} + + + {/* `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. */} + + {currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage) + ? currentLane.stage + : tLanes("stageUndeclared")} + + {currentLane.detected_stage && ( + + {tLanes("autoStage", { stage: currentLane.detected_stage })} + + )} + {features.length > 0 && ( + + )} +
+
+ handleLaneAction(currentLane.id, a, b)} + childWorktrees={lanes.filter( + (l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id + )} + onSelectLane={setSelectedLaneId} + /> +
+
+ + {viewedFeature && ( +

+ {tLanes("features.viewingArchived", { slug: viewedFeature.slug })} +

+ )} +
+ {proofFeature && + (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && ( +
+ {proofFeature.ticket_report && ( + + {tLanes("proof.ticketReport")} + + )} + {Object.entries(proofFeature.groups).map(([group, images]) => ( +
+ + {group} · {images.length} + +
+ {images.slice(0, 8).map((img) => ( + {img} + ))} + {images.length > 8 && ( + +{images.length - 8} + )} +
+
+ ))} +
+ )} + )} -
+
+ + setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])) + } + onLaneIdChange={setSelectedLaneId} + showToggle={false} + /> +
+
+ )} + + {!currentLane && ( +
- - )} - - {!currentLane && ( -
- - setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])) - } - onLaneIdChange={setSelectedLaneId} - /> -
- )} + )} +
Run 1`] = `
-

- No lanes yet. Create one from a working directory: - - - ccam lanes add --cwd $(pwd) - -

-
-
- - - + No lanes yet. Create one from a working directory: + + + ccam lanes add --cwd $(pwd) + +

-
-
- - - -
-
-
-

- Run Claude -

- - - Live - -
-

- Spin up a Claude Code session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions. -

-
- -
-
+ 1 pane + + + +
+
+
- - + +
-
-
- -