feat(cockpit): write the review's own comment yourself, in the send panel - #50
Conversation
…anel The body GitHub gets was composed and only composed — summary, walkthrough, folded notes, footer — so the one comment on a review that is not attached to a line was the one thing the user could not write. Now the send preview has an editor: it opens seeded with the composed body, and what you save is exactly what posts, footer and all. Half-honouring a hand-written body — keeping a footer you deleted, re-appending notes you cut — would post something nobody wrote. It changes the body alone. Inline comments still post, the event still applies, and the draft underneath is untouched: `build it from the review again` is one click, and a re-review clears the override the same way it drops the pre-chat snapshot. A chat turn never writes one, so writing or clearing one while a turn runs is the user's and stands. Because this is the one place what GitHub gets stops being derived from what the cockpit shows, the panel says so wherever it describes the payload — the strip reads "body you wrote" instead of claiming folded comments the body may no longer carry, the note under it says it no longer follows the summary, and a body you wrote opens shown rather than behind "see what gets posted". Two things go from the panel while it is being honest about what posts, both of them saying twice what is already said once: the "send as…" switch and its "follows the verdict" line, since the verdict buttons sit directly above the button and the way to post an approve is to say the review approves; and the verdict bar's "what you send follows this unless you say otherwise", which explained the switch. `cerber send -e` keeps its override — a terminal has no verdict buttons above it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1254000 to
c969a92
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The server PATCH currently coerces non-string bodyOverride inputs and the send payload trims user-authored override text despite UI/PR claims of posting it “exactly,” so API/input handling and payload fidelity should be tightened before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds support for a user-authored “review body to post” in the send panel, allowing the top-level GitHub review comment (not tied to any line) to be edited and saved independently of the drafted summary/walkthrough/comments.
Changes:
- Adds
bodyOverride: string | nullto the artifact schema and wires it through API PATCH + payload building so it replaces the composed review body at send time. - Updates the send panel UX to preview, edit (with a grow-to-fit textarea), and toggle between composed vs user-written body text.
- Extends tests and docs/specs to cover the new override semantics (including merge/re-review behavior and history logging).
File summaries
| File | Description |
|---|---|
| web/src/types.ts | Adds bodyOverride to the web Artifact type. |
| web/src/styles.css | Adds styling for body editor/actions and removes “send as…” menu styles. |
| web/src/review.ts | Adjusts payload summary string when a user-written body is present. |
| web/src/review.test.ts | Adds coverage for payload summary behavior with bodyOverride. |
| web/src/Detail.tsx | Implements the body editor UI and removes the “send as…” override UI/logic. |
| web/src/api.ts | Extends PATCH payload typing to include bodyOverride. |
| src/server/index.ts | Initializes bodyOverride and supports PATCH updates for it. |
| src/server/guards.test.ts | Adds PATCH route tests covering bodyOverride string/null behavior. |
| src/server/daemon.ts | Ensures stub artifacts default bodyOverride to null. |
| src/server/chat.test.ts | Updates chat test fixtures for the new artifact field. |
| src/runner/review.ts | Ensures re-review resets bodyOverride to null. |
| src/runner/review.test.ts | Updates review runner fixtures for the new artifact field. |
| src/runner/review-merge.test.ts | Updates merge fixtures for the new artifact field. |
| src/runner/chat.test.ts | Updates runner chat fixtures for the new artifact field. |
| src/core/state.test.ts | Updates state fixtures for the new artifact field. |
| src/core/send.ts | Makes send payload body come from bodyOverride when set. |
| src/core/send.test.ts | Adds tests asserting override body replaces composed body while inline/folded still compute. |
| src/core/revise.ts | Ensures chat merge keeps the current artifact’s bodyOverride. |
| src/core/revise.test.ts | Adds coverage for bodyOverride surviving concurrent chat turns. |
| src/core/refresh.test.ts | Updates refresh fixtures for the new artifact field. |
| src/core/history.ts | Records history lines when bodyOverride changes. |
| src/core/history.test.ts | Adds coverage for history lines on override set/cleared. |
| src/core/autosend.test.ts | Updates autosend fixtures for the new artifact field. |
| src/core/artifact.ts | Adds bodyOverride to the Zod schema with default null and extended docs. |
| SPEC.md | Documents schema + lifecycle semantics for bodyOverride and send behavior. |
| docs/lifecycle.md | Documents persistence/reset rules for a user-written send body. |
| CLAUDE.md | Updates contributor rules around send behavior and body override semantics. |
Review details
- Files reviewed: 27/27 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…'t a string
Three from review:
The composed body is trimmed; a hand-written one is not. "Exactly what posts"
has to mean it — and a body opening on an indented line is a markdown code
block, so trimming would silently repaint it as a paragraph.
`PATCH /api/reviews/:key` answers 400 on a `bodyOverride` that is neither a
string nor null, rather than coercing it. `String({})` is "[object Object]",
and this field reaches GitHub verbatim.
The cockpit's Artifact type carries `bodyOverride` as required: the server
defaults it through zod and stubs it as null, so it is always in the response,
and optional there only hid a field someone forgot to plumb through.
There was a problem hiding this comment.
🔵 Needs a closer look
The Send panel preview can temporarily display a stale body while refetching, creating brief but user-visible inconsistencies between the preview text and the “body source” explanation.
Review details
Suppressed comments (1)
web/src/Detail.tsx:1455
- In SendPanel, the send-preview body can temporarily go stale when
event/artifactchanges because the effect refetches without clearingpreviewfirst. That can briefly show the previous payload body while the “built from…” / “you wrote this body” source line is already reflecting the newartifact.bodyOverridestate, which is misleading.
Clear preview/previewError before refetching (and guard against out-of-order responses) so the UI shows the loading state instead of stale content.
useEffect(() => {
if (!showBody) return;
fetchSendPreview(reviewKey, event)
.then((p) => {
setPreview(p);
- Files reviewed: 27/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
The line under the box reads from the artifact, which changes the moment a body is saved or cleared; the box itself waited for a refetch. For that window the panel paired "you wrote this body" with the composed body it had just replaced — the exact confusion this panel exists to prevent. It now clears to "building the body…" first, and a slower earlier answer no longer lands on top of a newer one. Keyed on what the body is actually made of rather than on the artifact object, which a poll replaces wholesale every three seconds while a run or chat turn is in flight — on identity, the preview would clear and refetch on every tick of a body nothing had changed. The open editor is exempt: it owns the box, and the draft in it has to outlive an artifact that moves underneath.
|
Fixed in cd5c354 — the suppressed comment on The line under the box reads from the artifact, which changes the instant a body is saved or cleared, while the box waited on the refetch. For that window the panel paired "you wrote this body" with the composed body it had just replaced — precisely the confusion this panel exists to prevent. It now clears to One thing I did differently from the suggestion: the effect is keyed on a signature of what the body is made of ( Verified in a browser against a throwaway state dir: saved a body, watched the box clear and come back with the new text and the note in agreement. |
There was a problem hiding this comment.
🔵 Needs a closer look
The new BodyEditor ⌘↵/Ctrl↵ save shortcut doesn’t preventDefault(), so it can still insert a newline and behave inconsistently compared to the existing textarea shortcut handling.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
web/src/Detail.tsx:1386
- In BodyEditor, ⌘↵/Ctrl↵ triggers onSave but doesn’t call preventDefault(), so the browser can still insert a newline into the textarea (the same issue you explicitly guard against in LineComposer). That can lead to saving text that differs from what the user briefly saw/typed and makes the shortcut feel flaky.
- Files reviewed: 27/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
… out Same guard the line composer already has. The save closes the editor, so the stray newline was discarded rather than posted — but a shortcut whose default action still fires is one keystroke away from mattering, and the box beside it gets this right.
|
Fixed in the push above — For the record on severity: the save closes the editor, so the stray newline was discarded rather than posted. But a shortcut whose default action still fires is one refactor away from mattering, and the box next to it gets this right — so it's worth the line. |
There was a problem hiding this comment.
🔵 Needs a closer look
The send-panel body editor currently closes even if saving the overridden body fails, which can discard the user’s draft text and misrepresent persistence.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
web/src/Detail.tsx:1443
onBodyis typed as returningvoid, so the body editor can’t await persistence. That means the editor can only optimistically close, and on a failed PATCH the user’s typed text can be lost when the component unmounts. Consider makingonBodyreturn a Promise so the editor can keep the draft open (and/or show an error) until the save actually succeeds.
This issue also appears in the following locations of the same file:
- line 1550
- line 2546
web/src/Detail.tsx:1553
- The body editor closes immediately after calling
onBody(text). If the PATCH fails (network/server error), the editor unmounts and the user can lose the text they just wrote. Close the editor only after the save promise resolves successfully; on failure, keep it open so the draft is preserved.
onSave={(text) => {
onBody(text);
setEditingBody(false);
}}
web/src/Detail.tsx:2546
onBodyis currently wired throughapply(...), which catches errors and converts them intosetError(...)side-effects. IfonBodyis changed to return a Promise for the editor to await, this implementation should reject on failure (after setting the error) so the editor can keep the draft open and not treat a failed save as success.
onBody={(text) => apply(patchReview(reviewKey, { bodyOverride: text }))}
- Files reviewed: 27/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
…ide it The editor closed the moment save was clicked, so a PATCH that failed took the text with it — and this box is the only copy of what was typed. The summary and the comments it replaces are still the review's; these words are nowhere else. It now stays open until the write lands: `onBody` hands back the promise instead of a caught one, the save button says "saving…" while it is in flight, and a failure leaves the draft alone with the error under the box. The reset link reports the same way, where the body is read rather than at the top of the page. Verified by killing the server mid-edit: the text stayed, "Failed to fetch" appeared under it, and clicking save again once the server was back wrote it.
|
Fixed in 458377d — this one mattered more than the last two. The editor closed the moment save was clicked, so a failed PATCH took the text with it, and that box is the only copy of what was typed: the summary and the comments it replaces are still the review's, but these words are nowhere else.
Verified by killing the server mid-edit: the text stayed put, |
There was a problem hiding this comment.
🟡 Changes recommended
The new send-panel body editor has a couple of concrete error-handling/duplicate-submit bugs (in web/src/Detail.tsx) that can cause failed saves/resets to be mishandled.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
web/src/Detail.tsx:1592
- This
.catchhandler can itself throw if the rejection value isnull/a string/etc, because it readse.messagewithout checking the type. That can leave the panel with an unreported failure when clearing the body override.
onBody(null).catch((e) => setPreviewError(String(e.message ?? e)))
- Files reviewed: 27/27 changed files
- Comments generated: 1
- Review effort level: Lite
…hat can't throw ⌘↵ can be held down and the disabled button is not the only way in, so `save()` returns early while a write is in flight rather than sending a second one. And the failure handlers read `e?.message`: a rejection is not guaranteed to be an Error, and a catch that throws on `null.message` leaves the failure unreported — the one outcome this path exists to prevent.
There was a problem hiding this comment.
🔵 Needs a closer look
The new body editor can be closed via the Escape key while a save is in flight, which can discard the only copy of the user-authored text despite the intended “stay open until written” behavior.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
web/src/Detail.tsx:1412
Escapecurrently callsonCancel()even while a save is in flight, which can close the editor and discard the only copy of the draft despite the comment that it “stays open until the write actually lands”. Since the cancel button is disabled while saving, the keyboard path should follow the same rule (ignore Escape whilesavingis true).
web/src/Detail.tsx:1490bodySourceis JSON.stringified on every render, even when the preview is hidden (showBodyis false) or the editor is open (editingBodyis true). With many comments this can become avoidable work during the 3s polling refreshes; compute it lazily withuseMemoand gate it behindshowBody && !editingBody.
- Files reviewed: 27/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
The cancel button is disabled while a write is in flight, for the reason the editor exists to serve — the box holds the only copy of what was typed. Escape was the way around it. And the body's change signature is only built while the preview is on screen: with it closed there is nothing to keep in step, and walking every comment on each three-second poll tick was work for a string nobody reads.
|
Both fixed in the push above. Escape while saving — you're right, and it contradicted the rule the editor exists for: the cancel button is disabled for exactly that reason and the keyboard path must not be the way around it. Now ignored while a write is in flight.
|
There was a problem hiding this comment.
🔵 Needs a closer look
The new send-panel UI has a couple of concrete state/render edge cases (around showBody syncing and previewError hiding the preview/controls) that can block users from seeing or recovering the posting body without extra steps.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
web/src/Detail.tsx:1481
showBodyis initialized fromartifact.bodyOverrideonly on first render; ifbodyOverridebecomes non-null later (e.g., another tab writes it, or a poll tick brings in a change), the panel can keep the posting body hidden even though the UI copy/spec says a user-written body “opens shown”. SyncshowBodywhenownBodyflips true, without forcing it closed when cleared (so the user’s “hide what gets posted” choice still stands).
web/src/Detail.tsx:1581- When
previewErroris set (e.g., if “build it from the review again” fails), the current render path shows only the error<p>and hides the existing preview + controls. That prevents immediate retry/edit/copy without first toggling the whole preview closed/open. Prefer showing the error alongside the existing preview when one is available, and only replacing the preview entirely when there is nopreviewyet.
- Files reviewed: 27/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
…ailed reset keeps the body `showBody` was seeded from the artifact once, at mount, so a body written in another tab (or arriving on a poll tick) stayed hidden behind "see what gets posted" — while the panel's whole claim is that a body you wrote is the one fact about the payload nothing else on the page carries. It now opens whenever that flips true, and never closes on its own: clearing a body must not reopen a preview you closed. And a preview error renders above the body rather than instead of it. A reset that failed leaves the body it could not replace still standing, and taking that away with the buttons made retrying a matter of closing the panel and opening it again.
|
Both fixed in c45310d, and both verified in a browser.
|
There was a problem hiding this comment.
🟢 Approval recommended
The feature is coherently integrated end-to-end with solid tests/docs updates, with only a minor React state-update-after-unmount cleanup recommended.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
web/src/Detail.tsx:1393
- BodyEditor.save() calls setSaving(false) in a .finally() after onSave resolves, but on successful save the parent closes the editor (unmounting this component). That makes the .finally() state update run after unmount, which can trigger React warnings and is easy to trip in dev builds.
Since the editor unmounts on success, only reset saving on the error path (or guard with an isMounted ref).
- Files reviewed: 27/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
A save that lands closes the editor, so resetting `saving` in a `finally` was writing into an unmounted box. The failure path is the only one that returns to it — and it is the only one that needs to.
|
Fixed in the push above — Thanks for the run — seven rounds, and the two that mattered (a failed save discarding the only copy of the text, and the preview going stale under a line that had already changed) were both worth the trip. |
There was a problem hiding this comment.
🔵 Needs a closer look
The new body editor UX has a couple of concrete edge cases (draft loss / sizing behavior) that should be addressed before merging.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
web/src/Detail.tsx:1351
useGrowToFitonly recalculates height whenvaluechanges; resizing the window after the textarea has grown can leave it larger than 70% of the new viewport (or unnecessarily small), which contradicts the "up to most of the window" intent. Consider recalculating onresizeas well, without re-attaching listeners on every keystroke.
web/src/Detail.tsx:1593- While the body editor is open, the "see/hide what gets posted" toggle can still be clicked to hide the section, which unmounts
BodyEditorand discards the unsaved draft (and also allows closing mid-save). Disabling the toggle whileeditingBodyis true prevents accidental data loss and matches the intent that the editor owns the box until save/cancel.
- Files reviewed: 27/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
… re-fits on resize "Hide what gets posted" unmounted the open editor, taking the draft with it — the same loss the awaited save exists to prevent, by a different door. The toggle is disabled while the editor is open, and says why. And the box's cap is a share of the window, so it is re-taken when the window changes: one grown in a tall window stayed taller than the short one it then sat in. Its own effect, so the listener is not torn down and rebuilt on every keystroke.
|
Both fixed in the push above, both verified in a browser. The toggle unmounting the editor — this was the same loss the awaited save exists to prevent, by a different door, so thank you for it. Hide what gets posted is now disabled while the editor is open and carries the reason on hover:
|
There was a problem hiding this comment.
🟢 Approval recommended
Changes are well-covered by tests and documentation, with one minor performance tweak suggested but no functional blockers found.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
web/src/Detail.tsx:1518
bodySourceis computed (including mapping all comments +JSON.stringify) even while the body editor is open (editingBody === true). The effect already bails out during editing, so this extra work runs on every poll-driven re-render without any benefit and could get expensive on large reviews.
- Files reviewed: 27/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
The effect bails while the editor is open, so the string it would key on is never read there — the guard now says the same two conditions the effect acts on rather than one of them.
|
Applied in 849fb7b — the guard now names the same two conditions the effect acts on ( That is the last outstanding item; merging on the 🟢 at 78782f8 once CI is green here. For the record: 849fb7b is your own suggestion applied verbatim, one guard token plus its comment, and it is not itself re-reviewed. |
|
🎉 This PR is included in version 0.29.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
The problem
Cerber drafts a review, you read it, you send it. Everything on the page is yours to change on the way — every comment can be edited, re-graded or dropped — with one exception: the review's own comment. The paragraph GitHub shows at the top of a review, the one attached to no line, was composed and only composed (summary + walkthrough + the comments that can't post inline + footer). If it said the wrong thing, the only way to change a word of it was to ask the reviewer to rewrite the summary and wait several minutes for the answer.
That is the one part of the payload a human most often wants to write themselves. "Talked this through with the author, the quotes are approved now" is not something an AI can draft, and it is exactly what belongs in that box.
The fix
see what gets posted → write it yourself. The editor opens seeded with the composed body, grows to the text, previews the markdown as it will read, saves with ⌘↵. What you save is exactly what posts — footer and all, and verbatim: only the composed body is trimmed, because a hand-written one that opens on an indented line is a markdown code block and trimming would silently repaint it as a paragraph.
No half-honouring: a hand-written body is not re-decorated with a footer you deleted or notes you cut. That would post something nobody wrote.
It changes the body alone:
Because this is the one place what GitHub gets stops being derived from what the cockpit shows, the panel says so wherever it describes the payload: the strip reads
body you wroterather than claiming1 folded into the bodyover a body those notes may have been cut from, the note under the box says it no longer follows the summary or the comments, and a body you wrote opens shown rather than behind the see what gets posted click — including one that arrives from another tab or a poll tick, since it is the one fact about the payload nothing else on the page carries.The box holds the only copy of what you typed. The summary and comments it replaces are still the review's; these words are nowhere else. So the editor stays open until the write actually lands: the button reads
saving…while it is in flight, a failure leaves the draft alone with the reason under the box, and neither Escape nor a second ⌘↵ can take it away mid-save.Two things leave the panel in the same pass, both saying twice what is already said once:
cerber send -ekeeps its override; a terminal has no verdict buttons above it.)Technical details
bodyOverride: string | nullon the artifact (src/core/artifact.ts), defaultednullso every artifact already on disk still parses — schema evolution stays additive, no version bump.buildReviewPayloadreturns it in place of the composed body (src/core/send.ts), untrimmed. The composition still runs:foldedis what the cockpit uses to say which comments have no line to land on.PATCH /api/reviews/:key.nullhands the body back to the composition; anything that is neither string nor null gets a 400 rather than being coerced —String({})is"[object Object]", and this field reaches GitHub verbatim.mergeConcurrentEditstakesbodyOverridefrom the current artifact — a turn never writes one.mergeRunResultdoesn't carry it, so a re-review drops it along with the draft it described.the body to post was written by hand/…follows the review again.docs/lifecycle.mdand CLAUDE.md updated in the same change.Verification
pnpm typecheck,pnpm test(566),pnpm buildall green. New tests: the override replacing the composed body while inline/folded still compute, verbatim preservation including a body opening on an indented code block, composition returning when it's cleared, the PATCH round-trip withnull, the 400 on object/array/boolean/number, the chat-merge rule, the payload strip's wording, and both history lines.CERBER_HOMEcopies of a live draft: wrote a body and confirmed the JSON on disk and the/send-previewpayload both returned exactly that text with the inline comments intact; cleared it and watched the composed body come back; killed the server mid-edit and confirmed the draft stayed put withFailed to fetchunder the box and a retry writing it through once the server was back; started the cockpit against a state file already carrying a body and confirmed the panel draws with it open.