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
@@ -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>