feat(workspace): add 1/2/4-pane split terminal view toggle

This commit is contained in:
2026-08-14 12:55:37 +07:00
parent 14f116bf00
commit 06817b7901
3 changed files with 151 additions and 21 deletions
+1
View File
@@ -122,6 +122,7 @@
"proof.ticketReport": "Task report", "proof.ticketReport": "Task report",
"splitView.emptyPane": "No lane selected for this pane.", "splitView.emptyPane": "No lane selected for this pane.",
"splitView.paneLaneLabel": "Pane lane selector", "splitView.paneLaneLabel": "Pane lane selector",
"splitView.paneCount": "{{count}} pane",
"splitView.pickLane": "Pick a lane", "splitView.pickLane": "Pick a lane",
"statusDead": "DEAD", "statusDead": "DEAD",
"title": "Lanes", "title": "Lanes",
+1
View File
@@ -122,6 +122,7 @@
"proof.ticketReport": "Báo cáo nhiệm vụ", "proof.ticketReport": "Báo cáo nhiệm vụ",
"splitView.emptyPane": "Chưa chọn lane cho ô này.", "splitView.emptyPane": "Chưa chọn lane cho ô này.",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô", "splitView.paneLaneLabel": "Bộ chọn lane cho ô",
"splitView.paneCount": "{{count}} ô",
"splitView.pickLane": "Chọn lane", "splitView.pickLane": "Chọn lane",
"statusDead": "ĐÃ CHẾT", "statusDead": "ĐÃ CHẾT",
"title": "Làn đường", "title": "Làn đường",
+128
View File
@@ -49,6 +49,8 @@ import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard from "../components/lanes/LaneCard"; import LaneCard from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard"; import LaneStripCard from "../components/lanes/LaneStripCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal"; import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
// ── Page ────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────
@@ -80,6 +82,28 @@ export function Workspace() {
const [defaultCwd, setDefaultCwd] = useState<string>(""); const [defaultCwd, setDefaultCwd] = useState<string>("");
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false); const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
// Split view state
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
const setLayout = useCallback((layout: SplitLayout) => {
setSplitView((prev) => {
const paneLaneIds = Array.from({ length: layout }, (_, i) => prev.paneLaneIds[i] ?? null);
const next = { layout, paneLaneIds };
writeSplitViewState(next);
return next;
});
}, []);
const setPaneLaneId = useCallback((index: number, id: number) => {
setSplitView((prev) => {
const paneLaneIds = [...prev.paneLaneIds];
paneLaneIds[index] = id;
const next = { ...prev, paneLaneIds };
writeSplitViewState(next);
return next;
});
}, []);
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount // Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
const refreshLanes = useCallback(async () => { const refreshLanes = useCallback(async () => {
try { try {
@@ -167,6 +191,20 @@ export function Workspace() {
[refreshLanes] [refreshLanes]
); );
// Clean up stale pane lane IDs when lanes load
useEffect(() => {
if (!lanes.length) return;
setSplitView((prev) => {
const paneLaneIds = prev.paneLaneIds.map((id) =>
id !== null && lanes.some((l) => l.id === id) ? id : null
);
if (paneLaneIds.every((id, i) => id === prev.paneLaneIds[i])) return prev;
const next = { ...prev, paneLaneIds };
writeSplitViewState(next);
return next;
});
}, [lanes]);
const refreshList = useCallback(() => { const refreshList = useCallback(() => {
api.run api.run
.list() .list()
@@ -514,6 +552,24 @@ export function Workspace() {
</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-col gap-2 border-t border-border pt-3">
<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>
{splitView.layout === 1 ? (
<LaneConsolePane <LaneConsolePane
lanes={lanes} lanes={lanes}
laneId={selectedLaneId} laneId={selectedLaneId}
@@ -529,12 +585,57 @@ export function Workspace() {
defaultCwd={defaultCwd} defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun} onHasActiveRunChange={setPaneHasActiveRun}
/> />
) : (
<div
className={`grid flex-1 min-h-0 gap-3 ${
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
}`}
>
{splitView.paneLaneIds.map((id, i) => (
<LaneConsolePane
key={i}
lanes={lanes}
laneId={id}
showLaneSelector
onLaneIdChange={(newId) => setPaneLaneId(i, newId)}
onLaneCreated={(lane) =>
setLanes((prev) =>
prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]
)
}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
/>
))}
</div>
)}
</div> </div>
</section> </section>
)} )}
{!currentLane && ( {!currentLane && (
<div className="flex min-h-0 flex-col gap-2"> <div className="flex min-h-0 flex-col gap-2">
<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>
{splitView.layout === 1 ? (
<LaneConsolePane <LaneConsolePane
lanes={lanes} lanes={lanes}
laneId={selectedLaneId} laneId={selectedLaneId}
@@ -550,6 +651,33 @@ export function Workspace() {
defaultCwd={defaultCwd} defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun} onHasActiveRunChange={setPaneHasActiveRun}
/> />
) : (
<div
className={`grid flex-1 min-h-0 gap-3 ${
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
}`}
>
{splitView.paneLaneIds.map((id, i) => (
<LaneConsolePane
key={i}
lanes={lanes}
laneId={id}
showLaneSelector
onLaneIdChange={(newId) => setPaneLaneId(i, newId)}
onLaneCreated={(lane) =>
setLanes((prev) =>
prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]
)
}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
/>
))}
</div>
)}
</div> </div>
)} )}