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:
2026-09-24 15:49:39 +07:00
parent 67210bcd61
commit d397d3a1f6
29 changed files with 1289 additions and 254 deletions
+250 -59
View File
@@ -37,11 +37,11 @@ import {
FoldVertical,
Folder,
Minus,
Pencil,
Plus,
RefreshCw,
Rows3,
Trash2,
Undo2,
UnfoldVertical,
} from "lucide-react";
import { api } from "../../lib/api";
@@ -99,6 +99,24 @@ function Delta({
);
}
/** Why a whole-file read came back without an edit stamp (`sha`): the i18n
* key naming it, so the reader is never left with a button that does nothing. */
function noEditReason(text: { binary?: boolean; truncated: boolean }) {
if (text.binary) return "changes.noEditBinary";
if (text.truncated) return "changes.noEditTooBig";
return "changes.noEdit";
}
/** The explorer's decoration for a porcelain code: one letter + its colour,
* VS Code's convention (U untracked, A added, D deleted, R renamed, M else). */
function treeMark(code: string): { letter: string; tone: string } {
if (code === "??") return { letter: "U", tone: "text-status-success" };
if (code.includes("A")) return { letter: "A", tone: "text-status-success" };
if (code.includes("D")) return { letter: "D", tone: "text-status-danger" };
if (code.includes("R")) return { letter: "R", tone: "text-status-warning" };
return { letter: "M", tone: "text-status-warning" };
}
/**
* One folder level of the browse tree, rendered in place and recursing into
* every expanded child — so opening a nested file no longer means navigating
@@ -123,44 +141,62 @@ function TreeNodes({
if (!entries) return null;
return (
<>
{entries.map((e) => (
<li key={e.path}>
<button
type="button"
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
className="flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{e.dir ? (
expanded.has(e.path) ? (
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
{entries.map((e) => {
const mark = e.status ? treeMark(e.status) : null;
return (
<li key={e.path}>
<button
type="button"
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
mark ? mark.tone : e.changed ? "text-status-warning" : "text-fg-primary"
}`}
>
{e.dir ? (
expanded.has(e.path) ? (
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
)
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
)
) : (
<span className="w-3 shrink-0" />
<span className="w-3 shrink-0" />
)}
{e.dir ? (
<Folder className="h-3 w-3 shrink-0 text-accent" />
) : (
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<span
className={`min-w-0 flex-1 truncate ${e.status?.includes("D") ? "line-through" : ""}`}
>
{e.name}
</span>
{mark ? (
<span className="shrink-0 pr-1 font-semibold" title={e.status}>
{mark.letter}
</span>
) : e.changed ? (
// A folder only says "something in here" — the dot, not a letter.
<span className="mr-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-status-warning" />
) : null}
</button>
{e.dir && expanded.has(e.path) && (
<ul>
<TreeNodes
path={e.path}
depth={depth + 1}
children={children}
expanded={expanded}
onToggleDir={onToggleDir}
onOpenFile={onOpenFile}
/>
</ul>
)}
{e.dir ? (
<Folder className="h-3 w-3 shrink-0 text-accent" />
) : (
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<span className="min-w-0 flex-1 truncate">{e.name}</span>
</button>
{e.dir && expanded.has(e.path) && (
<ul>
<TreeNodes
path={e.path}
depth={depth + 1}
children={children}
expanded={expanded}
onToggleDir={onToggleDir}
onOpenFile={onOpenFile}
/>
</ul>
)}
</li>
))}
</li>
);
})}
</>
);
}
@@ -194,6 +230,11 @@ function IconButton({
);
}
/** A hunk's Undo/Keep chip in its review bar: labelled, so the two read apart
* at a glance instead of as two grey glyphs. */
const HUNK_CHIP =
"flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] disabled:cursor-not-allowed disabled:opacity-40";
type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
/** A work-range file's stage / unstage / discard buttons — the overview row's
@@ -335,9 +376,25 @@ export function LaneChangesPanel({
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const [hits, setHits] = useState<string[] | null>(null);
const [text, setText] = useState<{ path: string; lines: string[]; truncated: boolean } | null>(
null
);
const [text, setText] = useState<{
path: string;
lines: string[];
truncated: boolean;
binary?: boolean;
sha?: string;
} | null>(null);
// The whole-file editor's text while it is open; null = viewing.
const [draft, setDraft] = useState<string | null>(null);
const editWanted = useRef(false);
useEffect(() => {
if (!editWanted.current || !text) return;
editWanted.current = false;
if (text.sha) setDraft(text.lines.join("\n"));
// Asked for from the diff, refused once the file arrived: say why here,
// not with an Edit button that silently does nothing.
else setError(t(noEditReason(text)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
@@ -498,7 +555,17 @@ export function LaneChangesPanel({
.gitFile(laneId, openFile, range === "commit" && sha ? sha : "work")
.then(
forLane(laneId, (r) =>
setText(r.available ? { path: r.path, lines: r.lines, truncated: r.truncated } : null)
setText(
r.available
? {
path: r.path,
lines: r.lines,
truncated: r.truncated,
binary: r.binary,
sha: r.sha,
}
: null
)
)
)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
@@ -518,6 +585,7 @@ export function LaneChangesPanel({
setError(null);
setCommits(null);
setData(null);
setDraft(null);
setCollapsed(new Set());
// An armed discard names a PATH, not a file in a lane — carried over, one
// click would discard the same path in the lane just switched to.
@@ -550,9 +618,33 @@ export function LaneChangesPanel({
setContext(CONTEXT_STEPS[0]);
setWhole(false);
setText(null);
setDraft(null);
setMode("file");
};
// Save the editor over exactly the bytes it opened (`sha`): a file the agent
// rewrote meanwhile comes back 409 ESTALE and the draft stays for a retry.
const saveDraft = async () => {
if (draft === null || !text?.sha) return;
const lane = laneId;
setBusy(true);
setError(null);
try {
const r = await api.lanes.gitSaveFile(lane, text.path, draft, text.sha);
if (laneNow.current !== lane) return;
if (r.available) {
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
}
setDraft(null);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// An action that resolves after a lane switch belongs to the lane left: its
// `load` is the pre-switch one and would take a fresh ticket and paint the
// old lane's files here, so everything after the await is dropped.
@@ -612,23 +704,32 @@ export function LaneChangesPanel({
const armed = armedHunk === key;
return (
<>
<IconButton
label={t("changes.stageHunk")}
<button
type="button"
aria-label={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
title={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
disabled={busy}
onClick={() => void actHunk("stage-hunk", f.path, hunk.header)}
>
<Plus className="h-3 w-3" />
</IconButton>
<IconButton
label={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
disabled={busy}
danger
onClick={() =>
armed ? void actHunk("discard-hunk", f.path, hunk.header) : setArmedHunk(key)
}
className={`${HUNK_CHIP} ${
armed
? "border-status-danger bg-status-danger/20 text-status-danger"
: "border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger"
}`}
>
<Undo2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
</IconButton>
{armed ? t("changes.undoHunkShortConfirm") : t("changes.undoHunkShort")}
</button>
<button
type="button"
aria-label={t("changes.stageHunk")}
title={t("changes.stageHunk")}
disabled={busy}
onClick={() => void actHunk("stage-hunk", f.path, hunk.header)}
className={`${HUNK_CHIP} border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35`}
>
{t("changes.stageHunkShort")}
</button>
</>
);
}
@@ -833,6 +934,7 @@ export function LaneChangesPanel({
<button
type="button"
aria-pressed={whole}
disabled={draft !== null}
onClick={() => setWhole(!whole)}
className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${
whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
@@ -974,10 +1076,14 @@ export function LaneChangesPanel({
{(stagedCount > 0 || toStage.length > 0) && (
<div
data-testid="lane-changes-actions"
className="sticky top-0 z-10 mb-1.5 space-y-1.5 border-b border-border-light bg-surface-3 pb-2"
className="sticky top-0 z-10 mb-1.5 flex items-center gap-1.5 border-b border-border-light bg-surface-3 pb-2"
>
{/* ONE row: message, commit, then the two bulk index moves. */}
{stagedCount > 0 && (
<div data-testid="lane-changes-commit" className="flex items-center gap-1.5">
<div
data-testid="lane-changes-commit"
className="flex min-w-0 flex-1 items-center gap-1.5"
>
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
@@ -992,7 +1098,7 @@ export function LaneChangesPanel({
type="button"
disabled={busy || !message.trim()}
onClick={() => void commitStaged()}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{t("changes.commitStaged", { count: stagedCount })}
</button>
@@ -1008,12 +1114,29 @@ export function LaneChangesPanel({
toStage.map((f) => f.path)
)
}
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Plus className="h-3 w-3" />
{t("changes.stageAll", { count: toStage.length })}
</button>
)}
{stagedCount > 0 && (
<button
type="button"
data-testid="lane-changes-unstage-all"
disabled={busy}
onClick={() =>
void act(
"unstage",
files.filter((f) => f.staged).map((f) => f.path)
)
}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Minus className="h-3 w-3" />
{t("changes.unstageAll", { count: stagedCount })}
</button>
)}
</div>
)}
<ul data-testid="lane-changes-overview" className="space-y-0.5">
@@ -1042,8 +1165,71 @@ export function LaneChangesPanel({
{current && (
<Delta added={current.added} deleted={current.deleted} binary={current.binary} />
)}
{/* Only the working file is editable: a commit is history. */}
{range !== "commit" &&
(draft === null && whole && text && !text.sha ? (
<span
data-testid="lane-file-noedit"
className="shrink-0 self-center text-[10px] text-status-warning"
>
{t(noEditReason(text))}
</span>
) : draft === null ? (
<button
type="button"
data-testid="lane-file-edit"
disabled={busy}
onClick={() => {
// From the diff the file is not loaded yet: the
// editor opens as soon as the whole-file read lands.
if (whole && text?.sha) setDraft(text.lines.join("\n"));
else editWanted.current = true;
setWhole(true);
}}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Pencil className="h-3 w-3" />
{t("changes.edit")}
</button>
) : (
<>
<button
type="button"
data-testid="lane-file-save"
disabled={busy}
onClick={() => void saveDraft()}
className="shrink-0 self-center rounded border border-accent bg-accent/15 px-1.5 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:opacity-40"
>
{t("changes.save")}
</button>
<button
type="button"
disabled={busy}
onClick={() => setDraft(null)}
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{t("changes.cancelEdit")}
</button>
</>
))}
</div>
{whole ? (
{whole && draft !== null ? (
<textarea
data-testid="lane-file-editor"
aria-label={t("changes.editorLabel", { path: openFile })}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (!busy) void saveDraft();
}
}}
spellCheck={false}
wrap="off"
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none"
/>
) : whole ? (
text ? (
<LaneFileView
path={text.path}
@@ -1071,7 +1257,11 @@ export function LaneChangesPanel({
{files.map((f) => {
const shut = collapsed.has(f.path);
return (
<div key={f.path} className="rounded border border-border-light">
<div
key={f.path}
data-review-file=""
className="rounded border border-border-light"
>
{/* The whole header folds the file; only the PATH opens it.
A header that opened the file on any click made folding a
target-practice exercise against one chevron. */}
@@ -1094,6 +1284,7 @@ export function LaneChangesPanel({
? t("changes.expandFile", { path: f.path })
: t("changes.collapseFile", { path: f.path })
}
data-sticky-top=""
className={`sticky top-0 z-10 flex cursor-pointer items-baseline gap-1.5 rounded-t bg-surface-1 px-2 py-1 hover:bg-surface-2 ${
shut ? "" : "border-b border-border-light"
}`}