feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent

- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of
  consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js)
  cuts git's fresh hunk down to it, folding the other blocks into the side
  being checked; LaneDiffView hangs one review bar per block (unified + split)
  and its arrows walk blocks.
- git exclude: entries AgentHub writes for a lane go to the COMMON git dir with
  the lane's path prefix (server/lib/git-exclude.js, used by router-apply and
  lane-mcp) — a linked worktree's own info/exclude is never read by git, and a
  subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree
  lane or creates a stray .git in a non-repo lane.
- codex lanes carry no pipeline: codex-ingest mirrors only idle/running,
  codex-nudge is removed, agents install writes nothing for codex, the
  Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and
  every server start clears leftover codex stage data and the old AGENTS.md
  stage block.
- Changes panel: a whole file opens for reading and enters the editor on a
  click on its text (caret at the clicked line), not automatically.
- macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by
  scripts/service-manager.js).
- Adds the AgentHub introduction slides and their image-embedding script.
This commit is contained in:
2026-09-25 17:30:28 +07:00
parent 3f6e903c22
commit 952e8584f0
48 changed files with 3336 additions and 1579 deletions
+16 -13
View File
@@ -363,19 +363,22 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
role="menu"
className="absolute right-0 z-10 mt-1 min-w-44 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
>
<button
type="button"
role="menuitem"
data-testid="lane-agents-install"
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen(false);
void handleAgentsInstall();
}}
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
>
{t("actions.agentsInstall")}
</button>
{/* Claude-only: a codex lane has no agents to install. */}
{lane.tool !== "codex" && (
<button
type="button"
role="menuitem"
data-testid="lane-agents-install"
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen(false);
void handleAgentsInstall();
}}
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
>
{t("actions.agentsInstall")}
</button>
)}
{lane.tool !== "codex" && (
<button
type="button"
@@ -51,7 +51,7 @@ import {
UnfoldVertical,
} from "lucide-react";
import { api } from "../../lib/api";
import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import type {
@@ -59,7 +59,6 @@ import type {
LaneChangeRange,
LaneChanges,
LaneCommit,
LaneDiffHunk,
LaneTreeEntry,
} from "../../lib/types";
@@ -454,10 +453,33 @@ export function LaneChangesPanel({
// The whole-file editor's text while it is open; null = viewing.
const [draft, setDraft] = useState<string | null>(null);
const editWanted = useRef(false);
// Where the editor opens: the line clicked in the read view, and that view's
// scroll offset, so the text does not jump when the textarea replaces it.
const editAt = useRef<{ line: number; scrollTop: number } | null>(null);
const editorRef = useRef<HTMLTextAreaElement>(null);
const editing = draft !== null;
useEffect(() => {
const at = editAt.current;
const el = editorRef.current;
if (!editing || !at || !el) return;
editAt.current = null;
const offset = el.value
.split("\n")
.slice(0, at.line)
.reduce((n, l) => n + l.length + 1, 0);
el.setSelectionRange(offset, offset);
el.focus({ preventScroll: true });
el.scrollTop = at.scrollTop;
}, [editing]);
const startEdit = (line: number, scrollTop: number) => {
if (!text?.sha) return;
editAt.current = { line, scrollTop };
setDraft(text.lines.join("\n"));
};
useEffect(() => {
if (!editWanted.current || !text) return;
editWanted.current = false;
if (text.sha) setDraft(text.lines.join("\n"));
if (text.sha) startEdit(0, 0);
// 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)));
@@ -786,16 +808,22 @@ export function LaneChangesPanel({
};
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
// One hunk, matched by its `@@` line against a fresh diff at the context
// this diff was read with — so the server never applies a stale patch.
const actHunk = async (action: "stage-hunk" | "discard-hunk", path: string, hunk: string) => {
// One change block, named by its hunk's `@@` line and its index inside that
// hunk, matched against a fresh diff at the context this diff was read
// with — so the server never applies a stale patch.
const actHunk = async (
action: "stage-hunk" | "discard-hunk",
path: string,
change: LaneDiffChange
) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, {
path,
hunk,
hunk: change.hunk.header,
block: change.block,
context: data?.available ? data.context : undefined,
...(action === "discard-hunk" ? { confirm: true } : {}),
});
@@ -810,13 +838,13 @@ export function LaneChangesPanel({
}
};
// Stage / Undo on each hunk of a file with unstaged changes. The diff is
// Stage / Undo on each change block of a file with unstaged changes. The diff is
// HEAD → working tree, so a staged-only file gets none: undoing its tree
// would leave the index holding the change.
const hunkActions = (f: LaneChangeFile) =>
range === "work" && f.unstaged && !f.untracked && !f.binary
? (hunk: LaneDiffHunk) => {
const key = `${f.path}\n${hunk.header}`;
? (change: LaneDiffChange) => {
const key = `${f.path}\n${change.hunk.header}\n${change.block}`;
const armed = armedHunk === key;
return (
<>
@@ -826,7 +854,7 @@ export function LaneChangesPanel({
title={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
disabled={busy}
onClick={() =>
armed ? void actHunk("discard-hunk", f.path, hunk.header) : setArmedHunk(key)
armed ? void actHunk("discard-hunk", f.path, change) : setArmedHunk(key)
}
className={`${HUNK_CHIP} ${
armed
@@ -841,7 +869,7 @@ export function LaneChangesPanel({
aria-label={t("changes.stageHunk")}
title={t("changes.stageHunk")}
disabled={busy}
onClick={() => void actHunk("stage-hunk", f.path, hunk.header)}
onClick={() => void actHunk("stage-hunk", f.path, change)}
className={`${HUNK_CHIP} border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35`}
>
{t("changes.stageHunkShort")}
@@ -887,12 +915,6 @@ export function LaneChangesPanel({
return next;
});
// Full width edits in place: an editable whole file IS the editor, and
// Cancel just puts the saved text back (this effect re-seeds the draft).
const editableText = full && mode === "file" && whole && range !== "commit" && text?.sha;
useEffect(() => {
if (editableText && draft === null && text) setDraft(text.lines.join("\n"));
}, [editableText, draft, text]);
const dirty = draft !== null && draft !== text?.lines.join("\n");
const files = data?.available ? data.files : [];
@@ -912,8 +934,8 @@ export function LaneChangesPanel({
}
}, [data, files, confirmDiscard]);
// A tree or quick-open pick opens the whole file. Always-on editing (full
// width) makes a stray click here the easy way to lose typed work.
// A tree or quick-open pick opens the whole file — and, with an edit open,
// would drop it, so a dirty draft asks first.
const openWhole = (p: string) => {
if (dirty && !window.confirm(t("changes.dropEdits"))) return;
show(p);
@@ -1408,6 +1430,9 @@ export function LaneChangesPanel({
/>
)}
{/* Only the working file is editable: a commit is history. */}
{/* The whole file enters the editor by a click on its text;
only the diff, which has no whole text to click, keeps
an Edit button. */}
{range !== "commit" &&
(draft === null && whole && text && !text.sha ? (
<span
@@ -1416,16 +1441,21 @@ export function LaneChangesPanel({
>
{t(noEditReason(text))}
</span>
) : draft === null && whole ? (
text && (
<span className="shrink-0 self-center text-[10px] text-fg-muted">
{t("changes.clickToEdit")}
</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;
// The file is not loaded yet: the editor opens as
// soon as the whole-file read lands.
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"
@@ -1446,7 +1476,7 @@ export function LaneChangesPanel({
</button>
<button
type="button"
disabled={busy || (full && !dirty)}
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"
>
@@ -1457,6 +1487,7 @@ export function LaneChangesPanel({
</div>
{whole && draft !== null ? (
<textarea
ref={editorRef}
data-testid="lane-file-editor"
aria-label={t("changes.editorLabel", { path: openFile })}
value={draft}
@@ -1478,6 +1509,7 @@ export function LaneChangesPanel({
lines={text.lines}
truncated={text.truncated}
fill
onEditAt={range !== "commit" && text.sha && !busy ? startEdit : undefined}
/>
) : (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
+135 -51
View File
@@ -273,10 +273,13 @@ function Gutter({ n }: { n: number | null }) {
function Row({
tint,
style,
anchor,
children,
}: {
tint: string;
style?: CSSProperties;
/** This row starts change block `anchor` (file-wide): the bar's arrows land here. */
anchor?: number;
children: React.ReactNode;
}) {
return (
@@ -284,6 +287,7 @@ function Row({
className={`flex w-max min-w-full ${tint}`}
style={style}
data-diff-item={style ? "" : undefined}
data-change-index={anchor}
>
{children}
</div>
@@ -305,40 +309,82 @@ function markerClass(type: LaneDiffLine["type"]) {
return "text-fg-muted";
}
/** 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. */
/** One change block: a run of consecutive +/- lines inside a hunk. git merges
* edits a few lines apart into one `@@` hunk, so the review bar acts on these
* instead (#50) — `block` is the index the server's `stage-hunk` /
* `discard-hunk` take beside the hunk's header. */
export type LaneDiffChange = {
hunk: LaneDiffHunk;
block: number;
/** First/last index in `hunk.lines` (unified). */
firstLine: number;
lastLine: number;
/** First/last split-view row. */
firstRow: number;
lastRow: number;
};
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
/** A hunk's change blocks, cached per hunk object like `analyseHunk`. */
export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] {
const hit = blocksOf.get(hunk);
if (hit) return hit;
const out: LaneDiffChange[] = [];
const owner = new Map<LaneDiffLine, LaneDiffChange>();
hunk.lines.forEach((line, i) => {
if (line.type === "ctx") return;
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
if (!c) {
c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1 };
out.push(c);
}
c.lastLine = i;
owner.set(line, c);
});
analyseHunk(hunk).rows.forEach((r, i) => {
const c = (r.left && owner.get(r.left)) || (r.right && owner.get(r.right));
if (!c) return;
if (c.firstRow < 0) c.firstRow = i;
c.lastRow = i;
});
blocksOf.set(hunk, out);
return out;
}
/** The block whose last line (unified) / last row (split) is `i`, if any. */
const barAtLine = (hunk: LaneDiffHunk, i: number) =>
changeBlocks(hunk).find((c) => c.lastLine === i);
const barAtRow = (hunk: LaneDiffHunk, i: number) => changeBlocks(hunk).find((c) => c.lastRow === i);
const startAtLine = (hunk: LaneDiffHunk, i: number) =>
changeBlocks(hunk).find((c) => c.firstLine === i);
const startAtRow = (hunk: LaneDiffHunk, i: number) =>
changeBlocks(hunk).find((c) => c.firstRow === i);
/** Per-block review controls (Undo / Keep), drawn in a bar under each change
* block's last line. A context rather than a prop because lines render from
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
const HunkActions = createContext<{
actions: (hunk: LaneDiffHunk) => ReactNode;
hunks: LaneDiffHunk[];
actions: (change: LaneDiffChange) => ReactNode;
changes: LaneDiffChange[];
go: (index: number) => void;
} | null>(null);
/** 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;
/** File-wide index of `change`, for a row's `anchor`. */
function useAnchor(change?: LaneDiffChange) {
const ctx = useContext(HunkActions);
return change && ctx ? ctx.changes.indexOf(change) : undefined;
}
/** The review bar: hangs just BELOW the last changed line, pinned to the
/** The review bar: hangs just BELOW a change block's last 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 }) {
function HunkBar({ change }: { change: LaneDiffChange }) {
const { t } = useTranslation(["lanes"]);
const ctx = useContext(HunkActions);
if (!ctx) return null;
const index = ctx.hunks.indexOf(hunk);
const total = ctx.hunks.length;
const index = ctx.changes.indexOf(change);
const total = ctx.changes.length;
const nav =
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
return (
@@ -370,14 +416,13 @@ function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
<span className="px-0.5 tabular-nums text-fg-muted">
{t("changes.hunkOf", { n: index + 1, total })}
</span>
{ctx.actions(hunk)}
{ctx.actions(change)}
</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 (
@@ -387,7 +432,6 @@ function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
}`}
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}
@@ -403,16 +447,20 @@ function UnifiedLine({
emph,
style,
bar,
start,
}: {
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;
/** This is a change block's last line: hang its review bar under it. */
bar?: LaneDiffChange;
/** This is a change block's first line: the bar's arrows scroll here. */
start?: LaneDiffChange;
}) {
const anchor = useAnchor(start);
return (
<Row tint={rowTint(line.type)} style={style}>
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<span className="whitespace-pre px-2">
@@ -421,14 +469,13 @@ function UnifiedLine({
</span>
<Code text={line.text} lang={lang} emph={emph} />
</span>
{bar && <HunkBar hunk={bar} />}
{bar && <HunkBar change={bar} />}
</Row>
);
}
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
const { emph } = analyseHunk(hunk);
const last = lastChangedLine(hunk);
return (
<div>
<HunkHeader hunk={hunk} />
@@ -438,7 +485,8 @@ function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
line={line}
lang={lang}
emph={emph.get(i)}
bar={i === last ? hunk : undefined}
bar={barAtLine(hunk, i)}
start={startAtLine(hunk, i)}
/>
))}
</div>
@@ -473,16 +521,19 @@ function SplitLine({
side,
style,
bar,
start,
}: {
row: PairRow;
lang: string;
side: "left" | "right";
style?: CSSProperties;
bar?: LaneDiffHunk;
bar?: LaneDiffChange;
start?: LaneDiffChange;
}) {
const line = side === "left" ? row.left : row.right;
const anchor = useAnchor(start);
return (
<Row tint={rowTint(line?.type ?? null)} style={style}>
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
<span className="whitespace-pre px-1">
{line ? (
@@ -493,7 +544,7 @@ function SplitLine({
/>
) : null}
</span>
{bar && <HunkBar hunk={bar} />}
{bar && <HunkBar change={bar} />}
</Row>
);
}
@@ -519,8 +570,9 @@ function SplitColumn({
row={row}
lang={lang}
side={side}
// One bar per hunk: on the right (new) column only.
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
// One bar per change block: on the right (new) column only.
bar={side === "right" ? barAtRow(hunk, i) : undefined}
start={startAtRow(hunk, i)}
/>
))}
</div>
@@ -555,7 +607,8 @@ function SplitSlice({
lang={lang}
side={side}
style={FIXED}
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
bar={side === "right" ? barAtRow(hunk, i) : undefined}
start={startAtRow(hunk, i)}
/>
)
)}
@@ -652,8 +705,8 @@ export function LaneDiffView({
layout: DiffLayout;
/** Take the parent's height (single-file view) instead of the 60vh cap. */
fill?: boolean;
/** Controls drawn in each hunk's review bar. */
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
/** Controls drawn in each change block's review bar. */
hunkActions?: (change: LaneDiffChange) => ReactNode;
}) {
const root = useRef<HTMLDivElement>(null);
const { t } = useTranslation(["lanes"]);
@@ -672,12 +725,15 @@ export function LaneDiffView({
[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 changes = useMemo(() => (hunks ? hunks.flatMap(changeBlocks) : []), [hunks]);
// The bar's arrows: bring change block `index`'s first line 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}"]`);
const target = changes[index];
if (!el || !target) return;
const header = el.querySelector<HTMLElement>(`[data-change-index="${index}"]`);
// The review list pins each file's header over its own diff.
const sticky = el
.closest("[data-review-file]")
@@ -688,10 +744,10 @@ export function LaneDiffView({
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]);
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
};
const review = hunkActions && hunks ? { actions: hunkActions, hunks, go } : null;
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
@@ -727,7 +783,8 @@ export function LaneDiffView({
lang={lang}
emph={analyseHunk(hunk).emph.get(i)}
style={FIXED}
bar={i === lastChangedLine(hunk) ? hunk : undefined}
bar={barAtLine(hunk, i)}
start={startAtLine(hunk, i)}
/>
)
)}
@@ -751,12 +808,16 @@ export function LaneFileView({
lines,
truncated,
fill = false,
onEditAt,
}: {
path: string;
lines: string[];
truncated?: boolean;
/** Take the parent's height instead of the 60vh cap. */
fill?: boolean;
/** Set when the file is editable: a click on the text (0-based line, and
* where the view was scrolled) asks to open the editor there. */
onEditAt?: (line: number, scrollTop: number) => void;
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(path);
@@ -765,9 +826,12 @@ export function LaneFileView({
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
const row = (text: string, i: number, style?: CSSProperties) => (
<Row key={i} tint="" style={style}>
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
{/* `contents`: a line marker for the click handler, not a layout box. */}
<span data-ln={i} className="contents">
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</span>
</Row>
);
@@ -775,7 +839,27 @@ 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, true)}`}
tabIndex={onEditAt ? 0 : undefined}
onClick={
onEditAt &&
((e) => {
// A click that ends a drag-selection is copying, not asking to edit.
if (window.getSelection()?.toString()) return;
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
})
}
onKeyDown={
onEditAt &&
((e) => {
if (e.key !== "Enter") return;
e.preventDefault();
onEditAt(0, e.currentTarget.scrollTop);
})
}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
onEditAt ? " cursor-text" : ""
}`}
>
{win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
@@ -115,15 +115,18 @@ export default function LaneStripCard({
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
#{lane.id}
</span>
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
{lane.stage}
</span>
{lane.tool === "codex" && (
{/* Codex lanes carry no pipeline, so no stage chip — the Codex tag
takes its place. */}
{lane.tool === "codex" ? (
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
Codex
</span>
) : (
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
{lane.stage}
</span>
)}
{detectionLeads(lane) && (
{lane.tool !== "codex" && detectionLeads(lane) && (
<span
data-testid={`lane-tile-auto-${lane.id}`}
title={lane.detected_signal || undefined}
@@ -321,7 +321,7 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
});
it("at full width shows the tree as a sidebar and opens a file straight into the editor", async () => {
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
@@ -346,19 +346,24 @@ describe("LaneChangesPanel", () => {
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
await user.click(await within(sidebar).findByText("main.ts"));
// No Edit click: the editor is simply there, Save/Cancel idle until typed.
// Opening a file only shows it: no editor until the text itself is clicked.
const view = await screen.findByTestId("lane-file-view");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
await user.click(view);
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
expect(editor).toHaveFocus();
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
// Cancel reverts to the saved text and stays in the editor.
// Cancel drops the edit and goes back to reading.
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("at full width opens a changed file from the overview straight into the editor", async () => {
it("at full width opens a changed file from the overview for reading, not editing", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
@@ -372,10 +377,31 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("a");
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("does not enter the editor when a click only ends a text selection", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
rev: "work",
binary: false,
truncated: false,
lines: ["copy me"],
sha: "b".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
const view = await screen.findByTestId("lane-file-view");
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
toString: () => "copy",
} as unknown as Selection);
await user.click(view);
spy.mockRestore();
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("needs two clicks to discard, and only the second one reaches the server", async () => {
@@ -615,9 +641,7 @@ describe("LaneChangesPanel", () => {
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.click(await screen.findByTestId("lane-file-view"));
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
await user.clear(editor);
@@ -676,8 +700,7 @@ describe("LaneChangesPanel", () => {
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.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "y");
await user.click(screen.getByTestId("lane-file-save"));
@@ -922,6 +945,59 @@ describe("LaneChangesPanel", () => {
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -9,2 +10,3 @@",
block: 0,
context: 3,
});
});
it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 2,
deleted: 1,
binary: false,
hunks: [
{
header: "@@ -1,4 +1,5 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
{ type: "ctx" as const, oldLine: 3, newLine: 3, text: "c" },
{ type: "add" as const, oldLine: null, newLine: 4, text: "new" },
{ type: "ctx" as const, oldLine: 4, newLine: 5, text: "d" },
],
},
],
},
],
})
);
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
const bars = screen.getAllByTestId("lane-hunk-bar");
expect(bars.map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 2"),
expect.stringContaining("2 of 2"),
]);
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -1,4 +1,5 @@",
block: 1,
context: 3,
});
});
@@ -959,6 +1035,7 @@ describe("LaneChangesPanel", () => {
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -1,2 +1,3 @@",
block: 0,
context: 3,
confirm: true,
});
@@ -422,8 +422,8 @@ describe("LaneDiffView — hunk review bar navigation", () => {
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);
for (const el of view.container.querySelectorAll<HTMLElement>("[data-change-index]")) {
const k = Number(el.dataset.changeIndex);
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
}
return scroller;
@@ -454,6 +454,49 @@ describe("LaneDiffView — hunk review bar navigation", () => {
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
});
it("hangs one bar under EACH +/- block of a single hunk, in both layouts (#50)", () => {
const merged: LaneChangeFile = {
...FILE,
hunks: [
{
header: "@@ -1,6 +1,6 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
{ type: "del" as const, oldLine: 3, newLine: null, text: "c" },
{ type: "ctx" as const, oldLine: 4, newLine: 3, text: "d" },
{ type: "add" as const, oldLine: null, newLine: 4, text: "E" },
{ type: "ctx" as const, oldLine: 5, newLine: 5, text: "f" },
],
},
],
};
const seen: number[] = [];
for (const layout of ["unified", "split"] as const) {
const { unmount } = render(
<LaneDiffView
file={merged}
layout={layout}
hunkActions={(c) => {
seen.push(c.block);
return <span />;
}}
/>
);
expect(screen.getAllByTestId("lane-hunk-bar").map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 3"),
expect.stringContaining("2 of 3"),
expect.stringContaining("3 of 3"),
]);
unmount();
}
expect(seen).toEqual([0, 1, 2, 0, 1, 2]);
});
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]");