Skip to content

Commit b481c0e

Browse files
authored
Keep the review after a send and never hand a prompt to a shell pane (#1321)
Sending a review erased it while the agent received nothing. Two causes. A resolved sendAgentMessageNow was treated as proof of delivery: it drops the AgentPromptDelivery the backend returns, so the renderer wiped inlineComments on every resolve. Even a tmux 'delivered' verdict only means the keystrokes were accepted, not that the agent read them. Every send path now stamps sentAt and keeps the text; clearing stays the confirmed Reset review. resolveAgentPromptTargetPane fell through to the session's active pane whenever a task had two or more live agent panes and no recorded focus, so a hand-off could be typed into a focused shell split and still report success. The active pane now wins only when it is itself an agent pane, otherwise the first live agent pane does, and both fallbacks log the pane they picked.
1 parent b6d168c commit b481c0e

15 files changed

Lines changed: 161 additions & 47 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Short: Sending a review never loses it
2+
3+
Pressing "Send to agent" on a diff review could erase the whole review while the agent received nothing — the send reported success as soon as the keystrokes left dev3, which is not the same as the agent reading them. Sent comments are now marked as sent and kept, exactly like a single comment sent on its own, and clearing them stays the explicit Reset button. A second cause is fixed underneath: on a task with several agent panes and no recorded focus, the hand-off could be typed into whatever pane you were looking at, including a plain shell — it now always goes to an agent pane, and the chosen pane is written to the log.
4+
5+
Suggested by @diverru (Alexander Kiselyov)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Never destroy an inline review on send, and never hand a prompt to a shell pane
2+
3+
## Context
4+
5+
A reviewer pressed "Send to agent" on a diff review: the agent received nothing and
6+
the whole review was erased. Intermittent — the next attempt worked. Reported through
7+
Alexander Kiselyov (`@diverru`) on 2026-08-10, against `v1.42.x`.
8+
9+
## Investigation
10+
11+
Two independent defects sat on the same path.
12+
13+
1. **A resolved RPC was treated as proof of delivery.** `sendAgentMessageNow`
14+
(`src/bun/rpc-handlers/pr-comments.ts`) discards the `AgentPromptDelivery` that
15+
`sendMessageImmediately` returns and answers only `{ spilledPath }`. The renderer
16+
therefore could not tell `delivered` from `unconfirmed`, and
17+
`handleSendReviewToAgent` wiped `inlineComments` on every resolve — the auto-clear
18+
added in #1236. Even a `delivered` verdict only means tmux accepted the keystrokes;
19+
the agent's TUI can still drop a paste, which is exactly the intermittent shape of
20+
the report.
21+
2. **A hand-off could be typed into a plain shell.** `resolveAgentPromptTargetPane`
22+
(`src/bun/agent-prompt.ts`) fell through to the session's *active* pane whenever a
23+
task had ≥2 live agent panes and no recorded last-focused agent. If the user was
24+
looking at a shell or dev-server split, the review went there. tmux reports
25+
`delivered` for any pane, so the send looked clean and the review then deleted
26+
itself. The pre-existing test `ignores a recorded pane that is live but not a
27+
registered agent pane` asserted this exact routing.
28+
29+
## Decision
30+
31+
- **The review is never destroyed by a send.** `handleSendReviewToAgent` and the
32+
composer's `Send now` now stamp `sentAt` on the comments they handed over
33+
(`markInlineCommentsSent`), the same sticky marking per-comment sends already used.
34+
Sent comments grey out, leave the export payload, and disable `Copy`/`Send`; the
35+
user clears them with the explicit, confirmed `Reset review`. This reverses the
36+
auto-clear of #1236 — deliberately, with the user's word: an unprovable delivery may
37+
not be allowed to delete the user's writing.
38+
- **A hand-off goes to an agent pane or to nothing.** With ≥2 live agent panes and no
39+
recorded focus, the active pane is used only when it *is* one of them; otherwise the
40+
first live agent pane wins. The legacy "no agent registry at all" fallback to the
41+
active pane stays, and both fallbacks now log the pane they chose.
42+
43+
## Risks
44+
45+
- Reviewers who liked the self-clearing card must now press `Reset` once per round. It
46+
is one click (the icon-only button in the card's title line) and it asks first.
47+
- Deterministically picking the first live agent pane can differ from the user's focus
48+
in a multi-agent task where the focus hook never fired. Landing in the wrong *agent*
49+
is recoverable; landing in a shell is not.
50+
51+
## Alternatives considered
52+
53+
- **Clear only on a proven `delivered`.** Would have required plumbing
54+
`AgentPromptDelivery` to the renderer, and still deletes reviews in the common case,
55+
because tmux says `delivered` even when the agent's input layer swallows the paste.
56+
- **Leave the routing alone and only fix the clearing.** The review would survive, but
57+
the prompt would still silently land in a shell, so "I pressed send and the agent did
58+
nothing" would keep happening with no signal.

docs/ux/PRODUCT_UX_BIBLE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,8 @@ Layout = left **Files aside** (collapsible, `22rem`) + right **diff stream**.
200200
- **Files aside** contains two cards: the **Review export card** (top) and the **Files card** (read-progress + expand/collapse-all + the file tree).
201201
- **Per-file header (diff stream):** status chip (A/M/D/R/C/T/?), path (click = expand/collapse), **copy-file-path** icon button (role `neutral`/icon), `+N/−N` stat pill, **mark-read** checkbox (success-tinted when read), expand/collapse caret. The path renders as **one truncating line at every width** (directory truncates, basename always survives) and the identity column carries a `basis-[15rem]` floor, so the trailing controls wrap onto a second row instead of squeezing the path into a one-character-per-line column.
202202
- **Inline comments:** drag across the gutter to select a line range (or use the hover `+` widget for a single line) → composer opens → comment is added to a per-file/per-side/per-line thread. Threads render inline and are editable/deletable in place.
203-
- **Composer actions (budget: 3):** `Cancel` (`neutral`), **`Send now`** (`secondary`), `Add comment` (`primary`). `Send now` is the one-shot lane: the comment is parked in the review, shipped to the agent, then dropped on success — a single remark costs one click instead of add → send → delete. A failed send leaves it in the review, so nothing typed is lost.
204-
- **Per-comment actions (budget: 3, no growth):** `Edit` (`neutral`), `Delete` (`destructive`), `Send to agent` (`secondary`) — the same pattern as the GitHub thread action, pushing that single comment into the task terminal. Sending is per comment and **sticky**: the comment is marked `Sent`, persisted with the review, and leaves the export payload, so `Copy review` and the batch `Send to Agent` cover **only unsent** comments. Sent comments stay visible and re-readable in the export card (greyed, `Sent` badge); editing one clears the mark so the edited text can be delivered again.
203+
- **Composer actions (budget: 3):** `Cancel` (`neutral`), **`Send now`** (`secondary`), `Add comment` (`primary`). `Send now` is the one-shot lane: the comment is parked in the review and shipped to the agent in one click instead of add → send. **No send ever deletes a comment** — a resolved send only proves the keys left dev3, so the text is marked `Sent` and kept; clearing stays the explicit, confirmed `Reset review` (decision `never-destroy-a-review-on-send`).
204+
- **Per-comment actions (budget: 3, no growth):** `Edit` (`neutral`), `Delete` (`destructive`), `Send to agent` (`secondary`) — the same pattern as the GitHub thread action, pushing that single comment into the task terminal. Sending is per comment and **sticky**: the comment is marked `Sent`, persisted with the review, and leaves the export payload, so `Copy review` and the batch `Send to Agent` cover **only unsent** comments. Sent comments stay visible and re-readable in the export card (greyed, `Sent` badge); editing one clears the mark so the edited text can be delivered again. The **batch** send marks its comments the same way rather than wiping the review — the pre-2026-08-10 auto-clear destroyed reviews whose delivery was never actually proven.
205205

206206
**Review export card — action hierarchy (the one budgeted cluster):**
207207

docs/ux/UX_DECISIONS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ Compact index of UX architecture decisions — the *why* behind rules that live
44
`PRODUCT_UX_BIBLE.md` / `ux-architecture.yaml`. Max ~5 lines per entry; details live in
55
git history, PRs, and the records in `decisions/`. Newest first.
66

7+
## 2026-08-10 — A send marks the review, it never deletes it
8+
9+
- **Rule:** No send path (per-comment, composer `Send now`, or batch) may clear inline review comments; they are stamped `Sent`, and only the confirmed `Reset review` destroys anything.
10+
- **Why:** A resolved send proves the keystrokes left dev3, not that the agent read them — a reviewer lost a whole review to a send that never landed; the rejected alternative, clearing only on a proven `delivered`, still deletes on the common tmux case where the agent's TUI swallows the paste.
11+
- **Status:** Implemented. Evidence: `src/mainview/components/TaskDiffViewer.tsx` (`markInlineCommentsSent`), `decisions/2026/08/10/never-destroy-a-review-on-send.md`.
12+
713
## 2026-08-10 — Review export card is a title line; the composer owns the one-shot send
814

915
- **Rule:** The export cluster (count + Copy + Send + icon-only Reset) sits on the card's title line, renders nothing when the review is empty, and the count is plain text — never a bordered box; the composer gains `Send now`, which delivers one remark and drops it.

src/bun/__tests__/agent-prompt.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,14 +166,22 @@ describe("sendPromptToAgentPane — target resolution", () => {
166166
expect(sentPane(0)).toBe("%1");
167167
});
168168

169-
it("ignores a recorded pane that is live but not a registered agent pane", async () => {
170-
// %3 is a live shell split, not in the agent registry.
169+
it("never types into a focused shell split: falls back to a live agent pane", async () => {
170+
// %3 is a live shell split, not in the agent registry. tmux would report the
171+
// send as delivered either way, so aiming there loses the prompt silently.
171172
vi.mocked(tmux.listPanes).mockResolvedValue([{ paneId: "%1" }, { paneId: "%2" }, { paneId: "%3" }] as never);
172173
vi.mocked(tmux.showOption).mockResolvedValue("%3");
173174
vi.mocked(tmux.activePaneId).mockResolvedValue("%3");
174175
await runPrompt(sendPromptToAgentPane(TASK, "ping", TWO_AGENTS));
175-
// No last-focused agent → ≥2 agents → active pane (%3, the focused shell).
176-
expect(sentPane(0)).toBe("%3");
176+
expect(sentPane(0)).toBe("%1");
177+
});
178+
179+
it("keeps honouring the focus when the focused pane is itself an agent pane", async () => {
180+
vi.mocked(tmux.listPanes).mockResolvedValue([{ paneId: "%1" }, { paneId: "%2" }, { paneId: "%3" }] as never);
181+
vi.mocked(tmux.showOption).mockResolvedValue("");
182+
vi.mocked(tmux.activePaneId).mockResolvedValue("%2");
183+
await runPrompt(sendPromptToAgentPane(TASK, "ping", TWO_AGENTS));
184+
expect(sentPane(0)).toBe("%2");
177185
});
178186

179187
it("marks live agent panes with the focus-hook option (self-heal)", async () => {

src/bun/__tests__/rpc-handlers.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12885,8 +12885,9 @@ describe("handlers.createPullRequest", () => {
1288512885
expect(sends[1]?.join(" ")).toContain("send-keys -t %5 Enter");
1288612886
});
1288712887

12888-
// With two or more agent panes the target is ambiguous, so respect focus.
12889-
it("routes to the active pane when there are two agent panes", async () => {
12888+
// With two or more agent panes the target is ambiguous — but a focused SHELL
12889+
// never wins it: tmux reports the send as delivered and the agent sees nothing.
12890+
it("routes to a live agent pane, not the focused shell, when there are two agent panes", async () => {
1289012891
const project = makeProject();
1289112892
const task = makeTask({
1289212893
id: "task-1",
@@ -12904,10 +12905,10 @@ describe("handlers.createPullRequest", () => {
1290412905

1290512906
await handlers.createPullRequest({ taskId: "task-1", projectId: project.id });
1290612907

12907-
expect(guardedSends()[0]?.join(" ")).toContain("send-keys -t %3 -H");
12908+
expect(guardedSends()[0]?.join(" ")).toContain("send-keys -t %5 -H");
1290812909
});
1290912910

12910-
it("routes to the active Codex main pane when its pane id is not persisted", async () => {
12911+
it("routes to the live agent pane when the Codex main pane id is not persisted", async () => {
1291112912
const project = makeProject();
1291212913
const task = makeTask({
1291312914
id: "task-1",
@@ -12925,7 +12926,7 @@ describe("handlers.createPullRequest", () => {
1292512926

1292612927
await handlers.createPullRequest({ taskId: "task-1", projectId: project.id });
1292712928

12928-
expect(guardedSends()[0]?.join(" ")).toContain("send-keys -t %3 -H");
12929+
expect(guardedSends()[0]?.join(" ")).toContain("send-keys -t %7 -H");
1292912930
});
1293012931

1293112932
it("routes a legacy Codex main pane before a focused shell split", async () => {

src/bun/agent-prompt.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ async function getLastFocusedAgentPane(tmuxSession: string, socket: string): Pro
7272
* covers legacy tasks and the brief Codex pre-hook interval, when pane[0]'s
7373
* ID has not been persisted yet but a shell split may be focused.
7474
* - TWO OR MORE live agent panes with nothing recorded yet → respect the user's
75-
* focus and use the session's active pane.
75+
* focus when the focused pane IS one of them, else the first live agent pane.
76+
* Never a shell split: tmux reports "delivered" for any pane, so a hand-off
77+
* typed into a shell reads as a clean send while the agent got nothing.
7678
* - ZERO known agent panes (legacy tasks with no sessionState) → fall back to
7779
* the active pane, preserving the historical behavior.
7880
*
@@ -112,9 +114,30 @@ export async function resolveAgentPromptTargetPane(
112114
// recorded pane ID. Their session-state entry is pane[0], and tmux lists
113115
// that initial pane first, so prefer it over an unrelated focused shell.
114116
if (agentPanes?.length === 1 && hasUnresolvedAgentPane) return orderedLivePaneIds[0] ?? null;
115-
// ≥2 or 0 live agent panes → fall through to the active pane below.
117+
// ≥2 live agents and nothing recorded: honour the user's focus only when it
118+
// IS one of them. Typing a hand-off into the focused shell used to look like
119+
// a clean delivery (tmux takes the keys either way) while the agent never
120+
// saw a word — and a review that clears itself on that "success" is gone.
121+
if (liveAgentPanes.length > 0) {
122+
if (activePane && liveAgentPanes.includes(activePane)) return activePane;
123+
log.info("agent prompt routed to the first live agent pane", {
124+
session: tmuxSession,
125+
activePane: activePane ?? "none",
126+
chosen: liveAgentPanes[0] ?? "none",
127+
liveAgents: liveAgentPanes.length,
128+
});
129+
return liveAgentPanes[0] ?? null;
130+
}
131+
// No live agent pane at all → fall through to the active pane below.
116132
}
117133

134+
// Legacy tasks with no agent registry: the active pane is the only guess there
135+
// is. Logged, because it is also the one route that can land in a plain shell.
136+
log.info("agent prompt falling back to the active pane (no live agent pane known)", {
137+
session: tmuxSession,
138+
activePane: activePane ?? "none",
139+
registered: registeredIds.length,
140+
});
118141
return activePane;
119142
}
120143

src/mainview/components/TaskDiffViewer.tsx

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -484,9 +484,9 @@ function hasAnyInlineComments(state: InlineDiffCommentsState): boolean {
484484
}
485485

486486
/** Rewrites one comment anywhere in the review state, leaving every other entry untouched. */
487-
function mapInlineComment(
487+
function mapInlineComments(
488488
state: InlineDiffCommentsState,
489-
commentId: string,
489+
matches: (comment: InlineDiffComment) => boolean,
490490
transform: (comment: InlineDiffComment) => InlineDiffComment,
491491
): InlineDiffCommentsState {
492492
const nextState: InlineDiffCommentsState = {};
@@ -497,7 +497,7 @@ function mapInlineComment(
497497
nextFileComments[side][lineNumber] = {
498498
data: {
499499
comments: thread.data.comments.map((comment) => (
500-
comment.id === commentId ? transform(comment) : comment
500+
matches(comment) ? transform(comment) : comment
501501
)),
502502
},
503503
};
@@ -508,6 +508,12 @@ function mapInlineComment(
508508
return nextState;
509509
}
510510

511+
/** Stamp `sentAt` on every comment the caller just handed to the agent. */
512+
function markInlineCommentsSent(state: InlineDiffCommentsState, ids: ReadonlySet<string>): InlineDiffCommentsState {
513+
const sentAt = new Date().toISOString();
514+
return mapInlineComments(state, (comment) => ids.has(comment.id), (comment) => ({ ...comment, sentAt }));
515+
}
516+
511517
function buildInlineReviewExportEntries(
512518
files: TaskDiffFile[],
513519
inlineComments: InlineDiffCommentsState,
@@ -2449,7 +2455,9 @@ function TaskDiffViewer({ task, project, request, onBack, navigationGuardRef }:
24492455
setSendingCommentIds((current) => ({ ...current, [commentId]: true }));
24502456
api.request.sendAgentMessageNow({ taskId: task.id, projectId: project.id, text: buildInlineReviewXml([entry]) })
24512457
.then((result) => {
2452-
deleteInlineComment(commentId);
2458+
// Marked, never deleted: a resolved RPC proves the keys were handed
2459+
// over, not that the agent ingested them, so the text must survive.
2460+
setInlineComments((current) => markInlineCommentsSent(current, new Set([commentId])));
24532461
toast.success(result?.spilledPath
24542462
? t("infoPanel.diffReviewSendCommentSuccessFile", { path: result.spilledPath })
24552463
: t("infoPanel.diffReviewSendCommentSuccess"), { taskId: task.id });
@@ -2495,10 +2503,7 @@ function TaskDiffViewer({ task, project, request, onBack, navigationGuardRef }:
24952503
setSendingCommentIds((current) => ({ ...current, [commentId]: true }));
24962504
api.request.sendAgentMessageNow({ taskId: task.id, projectId: project.id, text: prompt })
24972505
.then((result) => {
2498-
setInlineComments((current) => mapInlineComment(current, commentId, (comment) => ({
2499-
...comment,
2500-
sentAt: new Date().toISOString(),
2501-
})));
2506+
setInlineComments((current) => markInlineCommentsSent(current, new Set([commentId])));
25022507
toast.success(result?.spilledPath
25032508
? t("infoPanel.diffReviewSendCommentSuccessFile", { path: result.spilledPath })
25042509
: t("infoPanel.diffReviewSendCommentSuccess"), { taskId: task.id });
@@ -2528,13 +2533,15 @@ function TaskDiffViewer({ task, project, request, onBack, navigationGuardRef }:
25282533
return;
25292534
}
25302535
const snapshot = reviewExportXml;
2536+
const sentIds = new Set(pendingReviewExportEntries.filter((entry) => entry.origin === "local").map((entry) => entry.id));
25312537
setReviewSendState("sending");
25322538
api.request.sendAgentMessageNow({ taskId: task.id, projectId: project.id, text: snapshot })
25332539
.then((result) => {
25342540
setReviewSendState("sent");
2535-
// Delivered comments are dead weight: clear them here so the reviewer
2536-
// never has to run the destructive "Reset review" as a routine step.
2537-
setInlineComments({});
2541+
// Sent comments are marked, never destroyed. A resolved RPC only means
2542+
// the keys reached the terminal — the agent's TUI can still drop them —
2543+
// so wiping here silently ate reviews that never arrived.
2544+
setInlineComments((current) => markInlineCommentsSent(current, sentIds));
25382545
setEditingCommentId(null);
25392546
toast.success(result?.spilledPath
25402547
? t("infoPanel.diffReviewExportSendSuccessFile", { path: result.spilledPath })
@@ -2869,7 +2876,7 @@ function TaskDiffViewer({ task, project, request, onBack, navigationGuardRef }:
28692876

28702877
// Editing revives the comment: the agent got the old text, so the new text
28712878
// has to be deliverable again — via its own send or the next batch.
2872-
setInlineComments((current) => mapInlineComment(current, commentId, (comment) => ({
2879+
setInlineComments((current) => mapInlineComments(current, (comment) => comment.id === commentId, (comment) => ({
28732880
...comment,
28742881
body: trimmedBody,
28752882
sentAt: undefined,

0 commit comments

Comments
 (0)