Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ installation below.
- Shows saved comment text inline beneath its anchored diff range.
- Re-anchors comments deterministically after refresh or marks them stale
without guessing.
- Hides and restores the same live pane with `F6`.
- Opens the live review in its own tab and toggles between review and agent
tabs with `F6`.
- Inserts saved comments into the associated agent with `F7`, preserving
existing input and never pressing Enter.
- Migrates legacy human Hunk notes once, preserving an untouched `.v1.bak`.
Expand Down Expand Up @@ -139,9 +140,12 @@ herdr server reload-config
## Usage

Focus a detected coding agent inside a Git repository and press `F6`. The
native review opens beside that exact agent. Pressing `F6` again from the agent
or review moves the live pane to a background tab; another press restores the
same process and state.
native review opens in a dedicated tab and Herdr switches to it immediately.
Press `F6` from that review tab to return to the exact source agent tab. Press
`F6` from the source agent again to switch back to the same live review
process and state. The pane is never moved into a split or restarted while
toggling. An already-running split review from an older release is moved once
into its dedicated tab without restarting the pane.

The review pane uses this stable key contract:

Expand Down
13 changes: 8 additions & 5 deletions agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Fix only problems introduced by this setup. An attached client may need
## 5. Explain the workflow

1. Focus a detected coding agent in a Git repository.
2. Press `F6` to open the native review.
2. Press `F6` to open the native review in a dedicated tab and switch to it.
3. Choose `1` for current uncommitted work, `2` for everything since the
branch diverged from its local main/master base, or `3` for the latest
file-changing turn observed from this exact agent. Navigate with `j`/`k`,
Expand All @@ -137,7 +137,8 @@ Fix only problems introduced by this setup. An attached client may need
divider to resize it, and toggle long-row wrapping with `w`. Letter and
bracket shortcuts also work from a Russian keyboard layout.
4. Select a line/range with `v`, press `c`, and save with `Ctrl+S`.
5. Press `F6` to hide/restore the same live pane.
5. Press `F6` from the review tab to return to the source agent tab; press it
from the source agent to switch back to the same live review tab.
6. Press `F7` to insert open validated saved comments into the exact source
agent.
7. Review or edit the draft, then press Enter manually.
Expand All @@ -158,9 +159,11 @@ idle-to-working transition and freezes when that turn finishes. Press `?` or
`F1` inside the pane for the full keyboard/mouse reference. `s` chooses the
old/new target only for unchanged context lines; additions are always new and
deletions are always old.
`Ctrl+C` closes the review pane cleanly; `F6` can reopen it with saved
comments, resolution state, active scope, sidebar visibility, sidebar width,
and row-wrap preference intact.
`Ctrl+C` closes the review pane cleanly. Normal `F6` tab switching does not
move, close, or restart the pane, so saved comments, resolution state, active
scope, sidebar visibility, sidebar width, and row-wrap preference stay intact.
An existing split review from an older release is moved into a dedicated tab
once, without restarting it.

## 6. Report

Expand Down
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ command = ["npm", "ci", "--omit=dev"]
[[actions]]
id = "open-review"
title = "Review changes"
description = "Open a live native review beside the focused agent."
description = "Open a live native review in a dedicated tab and toggle back to the focused agent."
contexts = ["pane"]
command = ["node", "src/open-review.mjs"]

Expand Down
115 changes: 84 additions & 31 deletions src/open-review.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,49 +28,86 @@ function getPane(herdr, paneId) {
return parseCommandJson(result.stdout, "herdr pane get")?.result?.pane;
}

function runPaneMove(herdr, args) {
const moved = spawnSync(herdr, ["pane", "move", ...args], {
function focusTab(herdr, tabId) {
const focused = spawnSync(herdr, ["tab", "focus", tabId], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (focused.status !== 0) {
throw new Error(describeCommandFailure("herdr tab focus", focused));
}
}

function renameTab(herdr, tabId) {
const renamed = spawnSync(herdr, ["tab", "rename", tabId, "Review"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (renamed.status !== 0) {
throw new Error(describeCommandFailure("herdr tab rename", renamed));
}
}

function moveReviewToDedicatedTab(herdr, reviewPaneId, workspaceId) {
const moved = spawnSync(
herdr,
[
"pane",
"move",
reviewPaneId,
"--new-tab",
"--workspace",
workspaceId,
"--label",
"Review",
"--focus",
],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
},
);
if (moved.status !== 0) {
throw new Error(describeCommandFailure("herdr pane move", moved));
}
}

function toggleExistingReview(herdr, review) {
function toggleExistingReview(herdr, review, focusedPaneId) {
const reviewPane = getPane(herdr, review.reviewPaneId);
const agentPane = getPane(herdr, review.agentPaneId);
if (!reviewPane || !agentPane) {
return false;
}

if (reviewPane.tab_id === agentPane.tab_id) {
runPaneMove(herdr, [
const workspaceId = agentPane.workspace_id ?? reviewPane.workspace_id;
if (!workspaceId) {
throw new Error("Herdr did not provide the review workspace.");
}
moveReviewToDedicatedTab(
herdr,
review.reviewPaneId,
"--new-tab",
"--workspace",
agentPane.workspace_id,
"--label",
"Review",
"--no-focus",
]);
workspaceId,
);
process.stdout.write(
`Moved the existing review for ${review.repo} into its dedicated tab.\n`,
);
return true;
}

renameTab(herdr, reviewPane.tab_id);
const focusedPane = focusedPaneId
? getPane(herdr, focusedPaneId)
: undefined;
if (focusedPane?.tab_id === reviewPane.tab_id) {
focusTab(herdr, agentPane.tab_id);
process.stdout.write(
`Hid the review for ${review.repo} without closing its session.\n`,
`Returned to ${review.agentKind ?? "agent"} (${review.agentPaneId}) for ${review.repo}.\n`,
);
} else {
runPaneMove(herdr, [
review.reviewPaneId,
"--tab",
agentPane.tab_id,
"--split",
"right",
"--target-pane",
review.agentPaneId,
"--focus",
]);
focusTab(herdr, reviewPane.tab_id);
process.stdout.write(
`Restored the review for ${review.repo} beside ${review.agentKind ?? "agent"} (${review.agentPaneId}).\n`,
`Switched to the review tab for ${review.repo}.\n`,
);
}
return true;
Expand All @@ -85,7 +122,10 @@ function main() {
const focusedReview = state.reviews.find(
(review) => review.reviewPaneId === context.focused_pane_id,
);
if (focusedReview && toggleExistingReview(herdr, focusedReview)) {
if (
focusedReview &&
toggleExistingReview(herdr, focusedReview, context.focused_pane_id)
) {
return;
}

Expand Down Expand Up @@ -115,11 +155,19 @@ function main() {
}
if (
activeMatches.length === 1 &&
toggleExistingReview(herdr, activeMatches[0])
toggleExistingReview(herdr, activeMatches[0], context.focused_pane_id)
) {
return;
}

const agentPane = getPane(herdr, agentPaneId);
if (!agentPane) {
throw new Error("The focused agent pane is no longer active.");
}
const workspaceId = agentPane.workspace_id ?? context.workspace_id;
if (!workspaceId) {
throw new Error("Herdr did not provide the agent workspace.");
}
const reviewKey =
matchingReviews.length === 1
? matchingReviews[0].reviewKey
Expand All @@ -133,11 +181,9 @@ function main() {
"--entrypoint",
"review",
"--placement",
"split",
"--target-pane",
agentPaneId,
"--direction",
"right",
"tab",
"--workspace",
workspaceId,
"--env",
`HERDR_HUNK_REVIEW_KEY=${reviewKey}`,
"--env",
Expand Down Expand Up @@ -173,8 +219,15 @@ function main() {
}),
);

const reviewTabId =
pane.tab_id ?? getPane(herdr, pane.pane_id)?.tab_id;
if (!reviewTabId) {
throw new Error("Herdr opened the review but did not return its tab ID.");
}
renameTab(herdr, reviewTabId);

process.stdout.write(
`Opened a review for ${repo} beside ${context.focused_pane_agent} (${agentPaneId}).\n`,
`Opened a review tab for ${repo} and switched to it.\n`,
);
}

Expand Down
19 changes: 19 additions & 0 deletions src/review/ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ export class ReviewUI {
this.selectedDetachedNoteId = null;
this.diffNavigation = [];
this.pendingG = false;
this.renderQueued = false;
this.renderPromise = null;

this.root = new BoxRenderable(this.ctx, {
id: "review-root",
Expand Down Expand Up @@ -527,6 +529,23 @@ export class ReviewUI {
}

async render() {
this.renderQueued = true;
if (this.renderPromise) return this.renderPromise;
this.renderPromise = (async () => {
while (this.renderQueued) {
this.renderQueued = false;
await this.renderPass();
}
})();
try {
await this.renderPromise;
} finally {
this.renderPromise = null;
}
if (this.renderQueued) return this.render();
}

async renderPass() {
const version = ++this.renderVersion;
const narrow = this.renderer.terminalWidth < 72;
const sidebarVisible =
Expand Down
30 changes: 30 additions & 0 deletions test-bun/ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,36 @@ test("sidebar toggles without losing selection and file rows are not text-select
);
});

test("concurrent resize renders are serialized and coalesced", async () => {
let measuring = false;
let active = 0;
let maxActive = 0;
let calls = 0;
const highlighter = {
highlight: async (value) => {
if (!measuring) return value;
calls += 1;
active += 1;
maxActive = Math.max(maxActive, active);
await Bun.sleep(5);
active -= 1;
return value;
},
};
const app = await setup(100, 24, highlighter);
measuring = true;

await Promise.all([
app.ui.render(),
app.ui.render(),
app.ui.render(),
]);
await app.flush();

expect(maxActive).toBe(1);
expect(calls).toBe(app.controller.file.rows.length * 2);
});

test("scope switching isolates comments and persists the active scope", async () => {
const app = await setup();

Expand Down
Loading