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
+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}>