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
+2 -5
View File
@@ -499,11 +499,8 @@ export default function LaneCard({
</div>
)}
{lane.needs_action && (
<div className="mb-2 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1 text-xs text-status-warning">
⚠ {lane.needs_action}
</div>
)}
{/* No needs-you banner here: the strip card already flags it, and the
console directly below is where the prompt actually waits. */}
{/* Runtime strip: the lane's own app stack, shown only for lanes that
declare a profile. A port that drifted from `base + slot` is called
+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"
}`}
+159 -30
View File
@@ -31,6 +31,7 @@ import {
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
@@ -38,11 +39,31 @@ export type DiffLayout = "unified" | "split";
/**
* How tall a diff box may be. `fill` (the single-file view) takes the height
* its parent gives it; otherwise — the review list, which stacks many files —
* each file is capped so its own horizontal scrollbar stays on screen.
* its parent gives it. In the review list, which stacks many files, a diff
* grows to its full height so the PANEL is the one vertical scroller — a
* 60vh box per file nested inside it made hunk navigation scroll two boxes
* at once and land nowhere predictable. Only a windowed diff (above
* WINDOW_THRESHOLD rows) keeps the cap, because windowing needs its own
* scroller.
*/
function boxHeight(fill: boolean) {
return fill ? "min-h-0 flex-1" : "max-h-[60vh]";
function boxHeight(fill: boolean, windowed: boolean) {
return fill ? "min-h-0 flex-1" : windowed ? "max-h-[60vh]" : "";
}
/**
* Bring `el` to the top of the scroller that actually scrolls it, below
* `pad` px of sticky header. Not `scrollIntoView`: that moves EVERY scrolling
* ancestor, the page included.
*/
function scrollToTop(el: HTMLElement, pad: number) {
let s = el.parentElement;
while (
s &&
!(s.scrollHeight > s.clientHeight && /auto|scroll/.test(getComputedStyle(s).overflowY))
)
s = s.parentElement;
if (!s) return;
s.scrollTop += el.getBoundingClientRect().top - s.getBoundingClientRect().top - pad;
}
/** Rows above which a view windows; at or below it renders exactly as before. */
@@ -284,21 +305,79 @@ function markerClass(type: LaneDiffLine["type"]) {
return "text-fg-muted";
}
/** Per-hunk controls (stage / undo) beside each `@@` line. A context rather
* than a prop because the header renders from four places. */
const HunkActions = createContext<((hunk: LaneDiffHunk) => ReactNode) | null>(null);
/** Per-hunk review controls (Undo / Keep), drawn in a bar under each hunk's
* last changed line. A context rather than a prop because lines render from
* four places; `hunks` is what the bar's "n of N" and its arrows walk. */
const HunkActions = createContext<{
actions: (hunk: LaneDiffHunk) => ReactNode;
hunks: LaneDiffHunk[];
go: (index: number) => void;
} | null>(null);
function HunkHeader({
hunk,
fixed,
noActions,
}: {
hunk: LaneDiffHunk;
fixed?: boolean;
/** The split view's left column: one set of controls per hunk, not two. */
noActions?: boolean;
}) {
const actions = useContext(HunkActions);
/** Index of a hunk's last +/- line (unified), or last row with one (split). */
function lastChangedLine(hunk: LaneDiffHunk) {
for (let i = hunk.lines.length - 1; i >= 0; i -= 1) {
if (hunk.lines[i]!.type !== "ctx") return i;
}
return -1;
}
function lastChangedRow(hunk: LaneDiffHunk) {
const rows = analyseHunk(hunk).rows;
for (let i = rows.length - 1; i >= 0; i -= 1) {
const r = rows[i]!;
if ((r.left && r.left.type !== "ctx") || (r.right && r.right.type !== "ctx")) return i;
}
return -1;
}
/** The review bar: hangs just BELOW the last changed line, pinned to the
* visible right edge, so it sits on the change it acts on — the editor-style
* "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */
function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
const { t } = useTranslation(["lanes"]);
const ctx = useContext(HunkActions);
if (!ctx) return null;
const index = ctx.hunks.indexOf(hunk);
const total = ctx.hunks.length;
const nav =
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
return (
<span className="sticky right-2 z-10 ml-auto h-0 w-0 self-start">
<span
data-testid="lane-hunk-bar"
className="absolute right-0 top-[18px] flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"
>
<button
type="button"
aria-label={t("changes.prevHunk")}
title={t("changes.prevHunk")}
disabled={index <= 0}
onClick={() => ctx.go(index - 1)}
className={nav}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
aria-label={t("changes.nextHunk")}
title={t("changes.nextHunk")}
disabled={index >= total - 1}
onClick={() => ctx.go(index + 1)}
className={nav}
>
<ChevronDown className="h-3 w-3" />
</button>
<span className="px-0.5 tabular-nums text-fg-muted">
{t("changes.hunkOf", { n: index + 1, total })}
</span>
{ctx.actions(hunk)}
</span>
</span>
);
}
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
const ctx = useContext(HunkActions);
const at = hunk.header.split("@@")[1];
// Windowed, the header is one ROW_PX row like the lines around it.
return (
@@ -308,14 +387,12 @@ function HunkHeader({
}`}
style={fixed ? FIXED : undefined}
data-diff-item={fixed ? "" : undefined}
data-hunk-index={ctx ? ctx.hunks.indexOf(hunk) : undefined}
>
<span className="min-w-0 flex-1 truncate">
{at ? `@@${at}@@` : hunk.header}
{hunk.context ? ` ${hunk.context}` : ""}
</span>
{actions && !noActions && (
<span className="sticky right-2 flex shrink-0 items-center gap-0.5">{actions(hunk)}</span>
)}
</div>
);
}
@@ -325,11 +402,14 @@ function UnifiedLine({
lang,
emph,
style,
bar,
}: {
line: LaneDiffLine;
lang: string;
emph?: [number, number];
style?: CSSProperties;
/** This is the hunk's last changed line: hang the review bar under it. */
bar?: LaneDiffHunk;
}) {
return (
<Row tint={rowTint(line.type)} style={style}>
@@ -341,17 +421,25 @@ function UnifiedLine({
</span>
<Code text={line.text} lang={lang} emph={emph} />
</span>
{bar && <HunkBar hunk={bar} />}
</Row>
);
}
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
const { emph } = analyseHunk(hunk);
const last = lastChangedLine(hunk);
return (
<div>
<HunkHeader hunk={hunk} />
{hunk.lines.map((line, i) => (
<UnifiedLine key={i} line={line} lang={lang} emph={emph.get(i)} />
<UnifiedLine
key={i}
line={line}
lang={lang}
emph={emph.get(i)}
bar={i === last ? hunk : undefined}
/>
))}
</div>
);
@@ -384,11 +472,13 @@ function SplitLine({
lang,
side,
style,
bar,
}: {
row: PairRow;
lang: string;
side: "left" | "right";
style?: CSSProperties;
bar?: LaneDiffHunk;
}) {
const line = side === "left" ? row.left : row.right;
return (
@@ -403,6 +493,7 @@ function SplitLine({
/>
) : null}
</span>
{bar && <HunkBar hunk={bar} />}
</Row>
);
}
@@ -421,9 +512,16 @@ function SplitColumn({
<>
{hunks.map((hunk, h) => (
<div key={`${hunk.header}:${h}`}>
<HunkHeader hunk={hunk} noActions={side === "left"} />
<HunkHeader hunk={hunk} />
{analyseHunk(hunk).rows.map((row, i) => (
<SplitLine key={i} row={row} lang={lang} side={side} />
<SplitLine
key={i}
row={row}
lang={lang}
side={side}
// One bar per hunk: on the right (new) column only.
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
/>
))}
</div>
))}
@@ -449,7 +547,7 @@ function SplitSlice({
.slice(range.start, range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={range.start + k} hunk={hunk} fixed noActions={side === "left"} />
<HunkHeader key={range.start + k} hunk={hunk} fixed />
) : (
<SplitLine
key={range.start + k}
@@ -457,6 +555,7 @@ function SplitSlice({
lang={lang}
side={side}
style={FIXED}
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
/>
)
)}
@@ -521,7 +620,7 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
);
return (
<div className={`flex ${boxHeight(fill)}`}>
<div className={`flex ${boxHeight(fill, win.on)}`}>
<div
ref={left}
data-testid="lane-diff-split-left"
@@ -553,9 +652,10 @@ export function LaneDiffView({
layout: DiffLayout;
/** Take the parent's height (single-file view) instead of the 60vh cap. */
fill?: boolean;
/** Controls drawn on each hunk's header row. */
/** Controls drawn in each hunk's review bar. */
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
}) {
const root = useRef<HTMLDivElement>(null);
const { t } = useTranslation(["lanes"]);
const lang = langFor(file.path);
const hunks = file.hunks;
@@ -567,6 +667,31 @@ export function LaneDiffView({
);
// Unified only; split windows inside its own two scrollers.
const win = useWindow(layout === "unified" ? items.length : 0);
const splitWindowed = useMemo(
() => layout === "split" && !!hunks && flatten(hunks, "split").length > WINDOW_THRESHOLD,
[layout, hunks]
);
// The bar's arrows: bring hunk `index`'s header into view. A windowed
// unified diff may not have it in the DOM, so there it is found by offset.
const go = (index: number) => {
const el = root.current;
if (!el || !hunks) return;
const header = el.querySelector<HTMLElement>(`[data-hunk-index="${index}"]`);
// The review list pins each file's header over its own diff.
const sticky = el
.closest("[data-review-file]")
?.querySelector<HTMLElement>("[data-sticky-top]");
const pad = sticky?.offsetHeight ?? 0;
if (header) {
scrollToTop(header, pad);
return;
}
// Windowed: the header is not in the DOM, but its offset is known.
const at = items.findIndex((it) => it.i < 0 && it.hunk === hunks[index]);
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
};
const review = hunkActions && hunks ? { actions: hunkActions, hunks, go } : null;
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
@@ -574,13 +699,16 @@ export function LaneDiffView({
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
return (
<HunkActions.Provider value={hunkActions ?? null}>
<HunkActions.Provider value={review}>
<div
ref={root}
data-testid="lane-diff-view"
data-layout={layout}
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split" ? boxHeight(fill) : `overflow-auto ${boxHeight(fill)}`
layout === "split"
? boxHeight(fill, splitWindowed)
: `overflow-auto ${boxHeight(fill, win.on)}`
}`}
>
{layout === "split" ? (
@@ -599,6 +727,7 @@ export function LaneDiffView({
lang={lang}
emph={analyseHunk(hunk).emph.get(i)}
style={FIXED}
bar={i === lastChangedLine(hunk) ? hunk : undefined}
/>
)
)}
@@ -646,7 +775,7 @@ export function LaneFileView({
<div
data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill)}`}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
>
{win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
@@ -167,9 +167,9 @@ describe("LaneCard rebuilt layout", () => {
expect(screen.queryByText("48%")).toBeNull();
});
it("surfaces a needs-you message", () => {
it("leaves the needs-you message to the strip card", () => {
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
expect(screen.queryByText(/waiting on approval/)).toBeNull();
});
it("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
@@ -26,6 +26,7 @@ vi.mock("../../../lib/api", () => ({
gitAction: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitSaveFile: vi.fn(),
gitLs: vi.fn(),
gitTree: vi.fn(),
gitTreeAll: vi.fn(),
@@ -473,6 +474,34 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("src")).toBeInTheDocument();
});
it("highlights modified and untracked files in the Files tree, and folders holding them", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [
{ name: "src", path: "src", dir: true, changed: true },
{ name: "docs", path: "docs", dir: true },
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
{ name: "keep.txt", path: "keep.txt", dir: false },
],
truncated: false,
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M");
expect(await row("a.txt")).toHaveClass("text-status-warning");
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
expect(await row("new.txt")).toHaveClass("text-status-success");
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
});
it("quick-opens a tracked file as its whole content, not a diff", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -486,6 +515,113 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
});
it("edits a whole file in place and saves over the exact version it opened", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
vi.mocked(api.lanes.gitSaveFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 2;"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await screen.findByTestId("lane-file-view");
await user.click(screen.getByTestId("lane-file-edit"));
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
await user.clear(editor);
await user.type(editor, "const whole = 2;");
await user.click(screen.getByTestId("lane-file-save"));
expect(api.lanes.gitSaveFile).toHaveBeenCalledWith(
7,
"src/main.ts",
"const whole = 2;",
"b".repeat(40)
);
// Back to reading, showing what was saved.
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: true, // over the 1 MB view cap: no `sha`, nothing safe to save
lines: ["partial"],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
expect(await screen.findByTestId("lane-file-noedit")).toHaveTextContent(
"Too large to edit here (over 1 MB)"
);
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("keeps the draft and says why when the file changed on disk meanwhile", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["x"],
sha: "b".repeat(40),
});
vi.mocked(api.lanes.gitSaveFile).mockRejectedValue(
Object.assign(new Error("the file changed on disk since it was opened"), { code: "ESTALE" })
);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await screen.findByTestId("lane-file-view");
await user.click(screen.getByTestId("lane-file-edit"));
await user.type(await screen.findByTestId("lane-file-editor"), "y");
await user.click(screen.getByTestId("lane-file-save"));
expect(await screen.findByRole("alert")).toHaveTextContent(/changed on disk/);
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
});
it("unstages every staged file in one call, on the same row as Stage all and commit", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const row = screen.getByTestId("lane-changes-actions");
expect(row).toContainElement(screen.getByTestId("lane-changes-commit"));
expect(row).toContainElement(screen.getByRole("button", { name: "Stage all (2)" }));
await user.click(screen.getByRole("button", { name: "Unstage all (1)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "unstage", { paths: ["docs/API.md"] });
});
it("switches the diff to side-by-side", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -715,6 +851,23 @@ describe("LaneChangesPanel", () => {
});
});
it("hangs one review bar under each actionable hunk, counting them", async () => {
const user = userEvent.setup();
await openReview(user);
// Two hunks on the tracked file; the untracked file gets no bar.
const bars = screen.getAllByTestId("lane-hunk-bar");
expect(bars.map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 2"),
expect.stringContaining("2 of 2"),
]);
// Undo sits beside Keep inside the bar — not on the @@ line.
expect(bars[0]).toContainElement(
screen.getAllByRole("button", { name: "Undo this hunk" })[0]!
);
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
expect(screen.getAllByRole("button", { name: "Next change" })[1]).toBeDisabled();
});
it("needs two clicks to undo a hunk, and arms only that hunk", async () => {
const user = userEvent.setup();
await openReview(user);
@@ -59,11 +59,28 @@ describe("LaneDiffView — side by side", () => {
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
});
it("bounds its own height so the horizontal bar stays on screen", () => {
// A stacked review list caps each file; the single-file view takes the
// height its parent gives it. Either way the diff scrolls itself rather
// than growing a 2000-line container whose scrollbar is off the bottom.
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
// windowed one keeps the cap because windowing needs its own scroller.
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
const big: LaneChangeFile = {
...FILE,
hunks: [
{
...FILE.hunks![0]!,
lines: Array.from({ length: WINDOW_THRESHOLD + 1 }, (_, i) => ({
type: "ctx" as const,
oldLine: i + 1,
newLine: i + 1,
text: `line ${i}`,
})),
},
],
};
rerender(<LaneDiffView file={big} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
@@ -379,3 +396,66 @@ describe("LaneDiffView — windowing", () => {
expect(container.textContent).toContain("row 4000");
});
});
describe("LaneDiffView — hunk review bar navigation", () => {
const hunk = (n: number) => ({
header: `@@ -${n},1 +${n},1 @@`,
context: "",
oldStart: n,
newStart: n,
lines: [
{ type: "del" as const, oldLine: n, newLine: null, text: `old ${n}` },
{ type: "add" as const, oldLine: null, newLine: n, text: `new ${n}` },
],
});
const three: LaneChangeFile = { ...FILE, hunks: [hunk(1), hunk(20), hunk(40)] };
// jsdom lays nothing out: give the ONE real scroller a scrollable box and
// put hunk k's `@@` row k×100px below its top, wherever it has scrolled to.
function renderInScroller() {
const view = render(
<div data-testid="scroller" style={{ overflowY: "auto" }}>
<LaneDiffView file={three} layout="unified" hunkActions={() => <span />} />
</div>
);
const scroller = screen.getByTestId("scroller");
Object.defineProperty(scroller, "scrollHeight", { value: 1000 });
Object.defineProperty(scroller, "clientHeight", { value: 100 });
scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
for (const el of view.container.querySelectorAll<HTMLElement>("[data-hunk-index]")) {
const k = Number(el.dataset.hunkIndex);
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
}
return scroller;
}
it("steps to the NEXT hunk and back to the PREVIOUS one, not the first", () => {
const scroller = renderInScroller();
const next = screen.getAllByRole("button", { name: "Next change" });
const prev = screen.getAllByRole("button", { name: "Previous change" });
fireEvent.click(next[0]!); // bar 1 → hunk 2
expect(scroller.scrollTop).toBe(100);
fireEvent.click(next[1]!); // bar 2 → hunk 3
expect(scroller.scrollTop).toBe(200);
fireEvent.click(prev[2]!); // bar 3 → hunk 2, not hunk 1
expect(scroller.scrollTop).toBe(100);
});
it("counts the hunks and disables the arrows at either end", () => {
renderInScroller();
const bars = screen.getAllByTestId("lane-hunk-bar");
expect(bars.map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 3"),
expect.stringContaining("2 of 3"),
expect.stringContaining("3 of 3"),
]);
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
});
it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => {
render(<LaneDiffView file={three} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
});
});
+66 -30
View File
@@ -16,8 +16,9 @@
* and its live tmux session — instead of leaving the previous lane's terminal
* on screen under a new lane's header.
*
* A lane's pane is one tmux session, so starting or resuming into a lane
* already holding a live run answers 409 ERUNLIVE rather than overwriting a
* Each pane is one tmux session on its lane — split panes all show the same
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
* that and attach to the still-live session instead of failing outright
* (with a notice explaining what happened), since that session is still
@@ -76,6 +77,19 @@ export interface LaneConsolePaneProps {
wsConnected: boolean;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void;
/** Which split pane this is. Every pane shows the SAME lane, each in its own
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
slot?: number;
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
* one terminal is never attached twice side by side. */
hiddenRunIds?: string[];
/** Reports the run this pane shows (null on the setup form). */
onHandleChange?: (runId: string | null) => void;
}
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
function paneRunId(laneId: number, slot: number): string {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
export function LaneConsolePane({
@@ -91,6 +105,9 @@ export function LaneConsolePane({
wsConnected,
defaultCwd,
onHasActiveRunChange,
slot = 0,
hiddenRunIds,
onHandleChange,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
@@ -117,8 +134,13 @@ export function LaneConsolePane({
() =>
laneId === null || !activeRuns
? activeRuns
: { ...activeRuns, items: activeRuns.items.filter((r) => r.laneId === laneId) },
[activeRuns, laneId]
: {
...activeRuns,
items: activeRuns.items.filter(
(r) => r.laneId === laneId && !hiddenRunIds?.includes(r.id)
),
},
[activeRuns, laneId, hiddenRunIds]
);
const laneSessions = useMemo(
() =>
@@ -133,6 +155,10 @@ export function LaneConsolePane({
onHasActiveRunChange?.(handle !== null);
}, [handle, onHasActiveRunChange]);
useEffect(() => {
onHandleChange?.(handle?.id ?? null);
}, [handle, onHandleChange]);
useEffect(() => {
if (!currentLane && defaultCwd && cwd === "") {
setCwd(defaultCwd);
@@ -171,10 +197,11 @@ export function LaneConsolePane({
setResumeSession(null);
setError(null);
setBusy(null);
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
const own = laneId === null ? null : paneRunId(laneId, slot);
setHandle(runs?.items.find((r) => r.id === own && r.status === "running") ?? null);
autoAttachedForLane.current = null;
refreshList();
}, [laneId, refreshList]);
}, [laneId, slot, refreshList]);
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
// slash command (or the codex stage-checklist prose) is sent as soon as the
@@ -191,12 +218,13 @@ export function LaneConsolePane({
useEffect(() => {
if (handle || laneId === null) return;
if (autoAttachedForLane.current === laneId) return;
const running = activeRuns?.items.find((r) => r.laneId === laneId && r.status === "running");
const own = paneRunId(laneId, slot);
const running = activeRuns?.items.find((r) => r.id === own && r.status === "running");
if (running) {
autoAttachedForLane.current = laneId;
setHandle(running);
}
}, [activeRuns, laneId, handle]);
}, [activeRuns, laneId, slot, handle]);
// The cwd tracks the lane's own folder separately, keyed on the resolved
// path rather than on `laneId` alone: the pane can mount before the lane
@@ -275,6 +303,7 @@ export function LaneConsolePane({
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
slot,
prompt: args.initialPrompt || "",
model: args.model || undefined,
permissionMode: args.permissionMode,
@@ -289,29 +318,27 @@ export function LaneConsolePane({
// already has a live run"), never the literal strings "409" or
// "ERUNLIVE" — matching against `.message` here is dead code that
// always falls through to the raw error below.
// This pane's own session is the live one — land on it.
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) {
await attachToRun(updatedLane.run_id);
setError(t("errors.attachedToLiveInstead"));
return;
}
await attachToRun(paneRunId(targetLaneId, slot));
setError(t("errors.attachedToLiveInstead"));
return;
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error(t("errors.noRunIdReturned"));
}
try {
const fetched = await api.run.get(laneStartResult.lane.run_id);
const fetched = await api.run.get(startedId);
setHandle(fetched);
refreshList();
} catch {
try {
await attachToRun(laneStartResult.lane.run_id);
await attachToRun(startedId);
refreshList();
} catch (fallbackErr: unknown) {
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
@@ -325,7 +352,18 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, t, lanes, laneId, laneTool, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
[
busy,
t,
lanes,
laneId,
laneTool,
slot,
onLaneIdChange,
onLaneCreated,
attachToRun,
refreshList,
]
);
const onResumeFromHistory = useCallback(
@@ -353,6 +391,7 @@ export function LaneConsolePane({
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
slot,
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
@@ -367,23 +406,20 @@ export function LaneConsolePane({
// — the server's `.message` is prose, never the literal string
// "ERUNLIVE" or "409".
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) {
onLaneIdChange(targetLaneId);
await attachToRun(updatedLane.run_id);
setError(t("errors.attachedToLiveInstead"));
return;
}
onLaneIdChange(targetLaneId);
await attachToRun(paneRunId(targetLaneId, slot));
setError(t("errors.attachedToLiveInstead"));
return;
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error("No run_id returned from lane start");
}
fetched = await api.run.get(laneStartResult.lane.run_id);
fetched = await api.run.get(startedId);
onLaneIdChange(targetLaneId);
} else {
fetched = await api.run.start({
@@ -407,7 +443,7 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange, attachToRun]
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
);
const onViewFromHistory = useCallback(
@@ -383,4 +383,74 @@ describe("LaneConsolePane", () => {
expect(screen.queryByText("nested session")).toBeNull();
expect(screen.queryByText("sibling prefix")).toBeNull();
});
describe("split panes on one lane", () => {
const run = (id: string, promptPreview = id) => ({
id,
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: "2026-09-24T01:00:00Z",
promptPreview,
tool: "claude" as const,
});
it("attaches a pane only to its own slot's session", async () => {
const activeRuns = { items: [run("agenthub-lane-1"), run("agenthub-lane-1-2")] };
render(<LaneConsolePane {...baseProps()} activeRuns={activeRuns} slot={2} />);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
"data-run-id",
"agenthub-lane-1-2"
)
);
});
it("starts into its own slot and shows the session the server opened", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "agenthub-lane-1" },
run_id: "agenthub-lane-1-1",
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue(run("agenthub-lane-1-1"));
render(<LaneConsolePane {...baseProps()} slot={1} />);
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ slot: 1 })
)
);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
"data-run-id",
"agenthub-lane-1-1"
)
);
});
it("leaves runs other panes show out of Active runs", () => {
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
slot={1}
activeRuns={{
items: [run("agenthub-lane-1", "shown"), run("agenthub-lane-1-3", "free")],
}}
hiddenRunIds={["agenthub-lane-1"]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /1 active run/ }));
expect(screen.getByText("free")).toBeTruthy();
expect(screen.queryByText("shown")).toBeNull();
});
});
});
+17 -4
View File
@@ -108,10 +108,10 @@
"changes.refresh": "Refresh",
"changes.resize": "Resize the changes panel (next: {{size}})",
"changes.size.compact": "Narrow",
"changes.size.half": "50 / 50",
"changes.size.wide": "70 / 30",
"changes.size.full": "Full width",
"changes.size.half": "Medium",
"changes.size.full": "Full screen",
"changes.stageAll": "Stage all ({{count}})",
"changes.unstageAll": "Unstage all ({{count}})",
"changes.stage": "Stage this file",
"changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.",
@@ -237,5 +237,18 @@
"worktrees.heading_other": "{{count}} worktrees",
"changes.stageHunk": "Stage this hunk",
"changes.undoHunk": "Undo this hunk",
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone"
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone",
"changes.stageHunkShort": "Keep",
"changes.undoHunkShort": "Undo",
"changes.undoHunkShortConfirm": "Confirm undo",
"changes.edit": "Edit",
"changes.save": "Save",
"changes.cancelEdit": "Cancel",
"changes.editorLabel": "Edit {{path}}",
"changes.prevHunk": "Previous change",
"changes.nextHunk": "Next change",
"changes.hunkOf": "{{n}} of {{total}}",
"changes.noEditBinary": "Binary file — not editable here",
"changes.noEditTooBig": "Too large to edit here (over 1 MB)",
"changes.noEdit": "Not editable here"
}
+16 -3
View File
@@ -108,10 +108,10 @@
"changes.refresh": "Tải lại",
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
"changes.size.compact": "Hẹp",
"changes.size.half": "50 / 50",
"changes.size.wide": "70 / 30",
"changes.size.half": "Vừa",
"changes.size.full": "Toàn màn hình",
"changes.stageAll": "Stage tất cả ({{count}})",
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
"changes.stage": "Đưa file này vào index",
"changes.title": "Thay đổi",
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
@@ -237,5 +237,18 @@
"worktrees.heading_other": "{{count}} worktree",
"changes.stageHunk": "Stage đoạn này",
"changes.undoHunk": "Hoàn tác đoạn này",
"changes.undoHunkConfirm": "Bấm lần nữa để hoàn tác đoạn này — không thể khôi phục"
"changes.undoHunkConfirm": "Bấm lần nữa để hoàn tác đoạn này — không thể khôi phục",
"changes.stageHunkShort": "Giữ",
"changes.undoHunkShort": "Hoàn tác",
"changes.undoHunkShortConfirm": "Xác nhận",
"changes.edit": "Sửa",
"changes.save": "Lưu",
"changes.cancelEdit": "Huỷ",
"changes.editorLabel": "Sửa {{path}}",
"changes.prevHunk": "Thay đổi trước",
"changes.nextHunk": "Thay đổi sau",
"changes.hunkOf": "{{n}} / {{total}}",
"changes.noEditBinary": "File nhị phân — không sửa được ở đây",
"changes.noEditTooBig": "File quá lớn để sửa ở đây (trên 1 MB)",
"changes.noEdit": "Không sửa được ở đây"
}
+15 -1
View File
@@ -2447,6 +2447,16 @@ export const api = {
request<LaneFileContent>(
`/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}`
),
/**
* PUT /api/lanes/:id/git/file — save the editor's text over the working
* file. `baseSha` is the `sha` it was opened at; a file that changed on
* disk since answers 409 ESTALE rather than being overwritten.
*/
gitSaveFile: (id: number, path: string, content: string, baseSha: string) =>
request<LaneFileContent>(`/lanes/${id}/git/file`, {
method: "PUT",
body: JSON.stringify({ path, content, baseSha }),
}),
/**
* GET /api/lanes/:id/git/ls — tracked paths matching a substring, for
* quick-open. Ignored files never appear (it reads `git ls-files`).
@@ -2611,7 +2621,9 @@ export const api = {
* @returns `{ lane, ok }` — the updated lane (if applicable) or a boolean result.
*/
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
request<{ lane?: Lane; ok?: boolean }>(`/lanes/${id}/${action}`, {
// `run_id` answers a `start`: the session it opened, which for a split
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, {
method: "POST",
body: JSON.stringify(body),
}),
@@ -3604,6 +3616,8 @@ export interface RunStartArgs {
export interface RunHandle {
id: string;
laneId: number | null;
/** Which Workspace split pane's session this is: 0 is the lane's own. */
slot?: number | null;
status: RunStatus;
cwd: string | null;
model: string | null;
+7
View File
@@ -2651,6 +2651,9 @@ export type LaneFileContent =
binary: boolean;
truncated: boolean;
lines: string[];
/** Version stamp of a whole, textual `work` file — what a save sends
* back as `baseSha`. Absent at a commit, or when binary/truncated. */
sha?: string;
}
| { available: false };
@@ -2660,6 +2663,10 @@ export interface LaneTreeEntry {
/** Path relative to the repo root. */
path: string;
dir: boolean;
/** A changed file's porcelain code (` M`, `??`, `A `, ` D`…); absent when clean. */
status?: string;
/** A folder with a changed or untracked file anywhere under it. */
changed?: true;
}
/** One row of a rendered diff — both gutters already resolved server-side. */
+38 -26
View File
@@ -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}
@@ -1,7 +1,7 @@
/**
* @file Workspace.changesPanel.test.tsx
* @description The Changes panel is ONE control: it steps narrow → 50/50 →
* 70/30 → closed, and the rail reopens it narrow. The width survives a reload.
* @description The Changes panel is ONE control: it steps 30/70 → 50/50 →
* full → closed, and the rail reopens it at 30/70. The width survives a reload.
* All of it lives in localStorage and reaches no server — the same rule as the
* lane strip's order.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -142,31 +142,28 @@ describe("Workspace — the Changes panel's width", () => {
}
}
it("steps narrow → 50/50 → 70/30 → full → closed on one control", async () => {
it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => {
await openPanel();
const card = () => screen.getByTestId("lane-changes-card");
expect(card().className).toContain("w-[28rem]");
expect(card().className).toContain("w-[30%]");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-1/2");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-[70%]");
// Full width HIDES the console beside it — hidden, still mounted.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-full");
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
// Past the widest step the panel closes — and reopens narrow, not back at
// 70/30, which is why closing resets the width.
// Past the widest step the panel closes — and reopens at 30/70, not back
// at full width, which is why closing resets the width.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle();
expect(card().className).toContain("w-[28rem]");
expect(card().className).toContain("w-[30%]");
});
it("remembers the width across a reload, and never asks the server", async () => {
+9 -17
View File
@@ -652,46 +652,38 @@ describe("split terminal view", () => {
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
});
it("switching to 2-pane layout renders two independent panes with lane pickers", async () => {
it("switching to 2-pane layout renders two panes bound to the selected lane", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(2);
expect(screen.getAllByTestId("pane-lane-select")).toHaveLength(2);
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
});
it("switching to 4-pane layout renders four panes", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
await settle();
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(4);
expect(screen.getAllByTestId("console-body")).toHaveLength(4);
});
it("persists the layout and pane selections to localStorage across remounts", async () => {
it("persists the layout to localStorage across remounts", async () => {
const { unmount } = await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
const selects = screen.getAllByTestId("pane-lane-select");
const select = selects[1];
expect(select).toBeDefined();
fireEvent.change(select!, { target: { value: String(lanesToReturn[1]!.id) } });
await settle();
unmount();
await renderWorkspace();
const persistedSelects = screen.getAllByTestId("pane-lane-select");
expect(persistedSelects).toHaveLength(2);
expect((persistedSelects[1] as HTMLSelectElement).value).toBe(String(lanesToReturn[1]!.id));
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
});
it("falls back to unselected when a persisted lane id no longer exists", async () => {
it("ignores a persisted per-pane lane id: every pane follows the selected lane", async () => {
localStorage.setItem(
"agenthub.workspace.splitView",
JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
);
await renderWorkspace();
// Lane 9999 doesn't exist, so it falls back to null (unselected).
// The second pane is already null. Both render as pane-empty.
expect(screen.getAllByTestId("pane-empty")).toHaveLength(2);
expect(screen.queryAllByTestId("pane-empty")).toHaveLength(0);
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
});
});