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 + + + +
+
+
- - + +
-
-
- -