feat(lanes): split panes on one lane, in-place file editing, hunk review bar
- Split view: every pane shows the selected lane, each in its own tmux session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's Active runs hides what the other panes show; stop/reset/remove kill every slot. No per-pane lane picker any more. - Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js) with the read path's guards, existing text files only, and a baseSha stale check (409 ESTALE). A file it cannot take says why. - Per-hunk review bar under each hunk's last changed line: prev/next, "n of N", Undo, Keep. Review diffs no longer nest their own scroll box (unless windowed), so navigation moves the one real scroller. - Files tree lists untracked-not-ignored files and colours entries by git status (M/U/A/D/R), folders holding changes get a dot. - Changes panel: Unstage all on the single commit/Stage-all row; sizes Narrow (30%) / Medium / Full screen, no 70/30. - Lane card: the needs-you banner is gone (the strip tile keeps it).
This commit is contained in:
@@ -40,7 +40,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
Pencil,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
ChevronsLeftRight,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
@@ -89,24 +88,22 @@ const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||||
* diff and watching a console want opposite amounts of room, and which one is
|
||||
* "work" changes several times an hour — hence ONE control that steps
|
||||
* narrow → 50/50 → 70/30 → full → closed, rather than a width button beside a
|
||||
* 30/70 → 50/50 → full → closed, rather than a width button beside a
|
||||
* collapse button that both live in the panel's tightest corner. */
|
||||
const CHANGES_SIZES = ["compact", "half", "wide", "full"] as const;
|
||||
const CHANGES_SIZES = ["compact", "half", "full"] as const;
|
||||
type ChangesSize = (typeof CHANGES_SIZES)[number];
|
||||
const CHANGES_WIDTH: Record<ChangesSize, string> = {
|
||||
compact: "w-[28rem] xl:w-[34rem]",
|
||||
compact: "w-[30%]",
|
||||
half: "w-1/2",
|
||||
wide: "w-[70%]",
|
||||
// The console beside it is HIDDEN, not unmounted: a live run's terminal
|
||||
// and a half-typed prompt must survive a round trip through full width.
|
||||
full: "w-full",
|
||||
};
|
||||
/** The icon the one resize control shows AT each size: what the next click
|
||||
* gives you — an even split, a wider panel, then closed. */
|
||||
* gives you — an even split, the full width, then closed. */
|
||||
const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
|
||||
compact: Columns2,
|
||||
half: ChevronsLeftRight,
|
||||
wide: Maximize2,
|
||||
half: Maximize2,
|
||||
full: PanelRightClose,
|
||||
};
|
||||
|
||||
@@ -163,7 +160,6 @@ function ConsoleArea({
|
||||
selectedLaneId,
|
||||
splitView,
|
||||
setLayout,
|
||||
setPaneLaneId,
|
||||
binaryStatus,
|
||||
cwdSuggestions,
|
||||
activeRuns,
|
||||
@@ -179,7 +175,6 @@ function ConsoleArea({
|
||||
selectedLaneId: number | null;
|
||||
splitView: SplitViewState;
|
||||
setLayout: (layout: SplitLayout) => void;
|
||||
setPaneLaneId: (index: number, id: number) => void;
|
||||
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
activeRuns: RunListResponse | null;
|
||||
@@ -191,6 +186,30 @@ function ConsoleArea({
|
||||
onLaneIdChange: (id: number | null) => void;
|
||||
showToggle?: boolean;
|
||||
}) {
|
||||
// Every split pane shows the SELECTED lane, each in its own tmux session
|
||||
// (slot = pane index). Tracking what each pane shows lets the others leave
|
||||
// that run out of their Active runs, so no terminal is attached twice.
|
||||
const [paneRunIds, setPaneRunIds] = useState<(string | null)[]>([]);
|
||||
const onPaneHandle = useCallback((i: number, id: string | null) => {
|
||||
setPaneRunIds((prev) => {
|
||||
if (prev[i] === id) return prev;
|
||||
const next = [...prev];
|
||||
next[i] = id;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const paneHandlers = useMemo(
|
||||
() => [0, 1, 2, 3].map((i) => (id: string | null) => onPaneHandle(i, id)),
|
||||
[onPaneHandle]
|
||||
);
|
||||
const hiddenFor = useMemo(
|
||||
() =>
|
||||
[0, 1, 2, 3].map((i) =>
|
||||
paneRunIds.filter((id, j): id is string => j !== i && j < splitView.layout && !!id)
|
||||
),
|
||||
[paneRunIds, splitView.layout]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
|
||||
@@ -198,6 +217,7 @@ function ConsoleArea({
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
onHandleChange={paneHandlers[0]}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
@@ -215,13 +235,16 @@ function ConsoleArea({
|
||||
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
|
||||
}`}
|
||||
>
|
||||
{splitView.paneLaneIds.map((id, i) => (
|
||||
{Array.from({ length: splitView.layout }, (_, i) => (
|
||||
<LaneConsolePane
|
||||
key={i}
|
||||
lanes={lanes}
|
||||
laneId={id}
|
||||
showLaneSelector
|
||||
onLaneIdChange={(newId) => setPaneLaneId(i, newId)}
|
||||
laneId={selectedLaneId}
|
||||
slot={i}
|
||||
hiddenRunIds={hiddenFor[i]}
|
||||
onHandleChange={paneHandlers[i]}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
@@ -335,16 +358,6 @@ export function Workspace() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
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 {
|
||||
@@ -960,7 +973,6 @@ export function Workspace() {
|
||||
selectedLaneId={selectedLaneId}
|
||||
splitView={splitView}
|
||||
setLayout={setLayout}
|
||||
setPaneLaneId={setPaneLaneId}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
|
||||
Reference in New Issue
Block a user