feat(workspace): add 1/2/4-pane split terminal view toggle
This commit is contained in:
@@ -122,6 +122,7 @@
|
||||
"proof.ticketReport": "Task report",
|
||||
"splitView.emptyPane": "No lane selected for this pane.",
|
||||
"splitView.paneLaneLabel": "Pane lane selector",
|
||||
"splitView.paneCount": "{{count}} pane",
|
||||
"splitView.pickLane": "Pick a lane",
|
||||
"statusDead": "DEAD",
|
||||
"title": "Lanes",
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
||||
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
|
||||
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
|
||||
"splitView.paneCount": "{{count}} ô",
|
||||
"splitView.pickLane": "Chọn lane",
|
||||
"statusDead": "ĐÃ CHẾT",
|
||||
"title": "Làn đường",
|
||||
|
||||
+149
-21
@@ -49,6 +49,8 @@ import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard from "../components/lanes/LaneCard";
|
||||
import LaneStripCard from "../components/lanes/LaneStripCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
||||
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -80,6 +82,28 @@ export function Workspace() {
|
||||
const [defaultCwd, setDefaultCwd] = useState<string>("");
|
||||
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
|
||||
const refreshLanes = useCallback(async () => {
|
||||
try {
|
||||
@@ -167,6 +191,20 @@ export function Workspace() {
|
||||
[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(() => {
|
||||
api.run
|
||||
.list()
|
||||
@@ -514,6 +552,90 @@ export function Workspace() {
|
||||
</div>
|
||||
)}
|
||||
<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
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={(id) => setSelectedLaneId(id)}
|
||||
onLaneCreated={(lane) =>
|
||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||
}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!currentLane && (
|
||||
<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
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
@@ -529,27 +651,33 @@ export function Workspace() {
|
||||
defaultCwd={defaultCwd}
|
||||
onHasActiveRunChange={setPaneHasActiveRun}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!currentLane && (
|
||||
<div className="flex min-h-0 flex-col gap-2">
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={(id) => setSelectedLaneId(id)}
|
||||
onLaneCreated={(lane) =>
|
||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||
}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user