From 6f5dc40f515b33d523682e1d8fc484233f63c641 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:12:28 +0000 Subject: [PATCH 1/7] feat(service,features,desktop): comment on an agent's pull request from the foot of its Conversation The Conversation ends in a comment box, as GitHub's own does: Markdown with Write and Preview, sent as a new `comment` action on the member-writes seam (`addComment` on the pull request), so GitHub hears it as the member on their own sign-in, never the Luke GitHub App. Once GitHub takes it the box lets its words go and the conversation is read again; a refusal stands under the box in GitHub's words, the words kept. The review panel's Write and Preview move into `markdown-field.tsx`, which both now draw. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- apps/desktop/src/renderer/styles/tasks.css | 33 ++++++- .../tasks/agent-pull-request-tab.test.ts | 95 +++++++++++++++++++ .../renderer/tasks/agent-pull-request-tab.tsx | 15 ++- .../src/renderer/tasks/markdown-field.tsx | 89 +++++++++++++++++ .../tasks/pull-request-comment-box.tsx | 69 ++++++++++++++ .../renderer/tasks/pull-request-review.tsx | 59 ++---------- .../renderer/tasks/pull-request-writes.tsx | 1 + ...ingAgentPullRequestActionParamsSchema.json | 20 ++++ ...e-wire-pullRequestActionRequestSchema.json | 20 ++++ .../features/src/pull-request-write-wire.ts | 8 +- .../src/agents/coding-agents-app.test.ts | 28 ++++++ .../service/src/agents/coding-agents-app.ts | 7 +- .../service/src/github/pull-request-writes.ts | 15 +++ 13 files changed, 402 insertions(+), 57 deletions(-) create mode 100644 apps/desktop/src/renderer/tasks/markdown-field.tsx create mode 100644 apps/desktop/src/renderer/tasks/pull-request-comment-box.tsx diff --git a/apps/desktop/src/renderer/styles/tasks.css b/apps/desktop/src/renderer/styles/tasks.css index 3a64397f08..37d45383cd 100644 --- a/apps/desktop/src/renderer/styles/tasks.css +++ b/apps/desktop/src/renderer/styles/tasks.css @@ -1847,14 +1847,16 @@ color: var(--luke-color-text-muted); } -.pull-request-review-body { +/* Words sent to GitHub as Markdown (`markdown-field.tsx`): Write and + Preview over the field, the preview drawn in the field's own box. */ +.markdown-field { display: flex; flex-direction: column; align-items: flex-start; gap: var(--luke-space-8); } -.pull-request-review-preview { +.markdown-field-preview { box-sizing: border-box; width: 100%; min-height: 96px; @@ -1865,7 +1867,7 @@ border-radius: var(--luke-radius-10); } -.pull-request-review-preview > p:only-child { +.markdown-field-preview > p:only-child { margin: 0; } @@ -2547,6 +2549,31 @@ button.pull-request-check:hover { color: var(--luke-color-text); } +/* The comment box at the foot of the Conversation + (`pull-request-comment-box.tsx`): the words with their preview, GitHub's + refusal under them, and Comment at the row's end. */ +.pull-request-comment-box { + display: flex; + flex-direction: column; + gap: var(--space-8); +} + +.pull-request-comment-note { + margin: 0; + color: var(--text-secondary); + font-size: 12px; +} + +.pull-request-comment-note .failure-note-words { + flex: 1 1 auto; + min-width: 0; +} + +.pull-request-comment-foot { + display: flex; + justify-content: flex-end; +} + /* The earlier entries a read left on GitHub, counted where they would stand. */ .pull-request-earlier { margin: 0 0 var(--luke-space-8); diff --git a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts index f72daab913..d099f51efe 100644 --- a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts +++ b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts @@ -2534,3 +2534,98 @@ test("a review thread on its line in Files changed is replied on and resolved fr [AGENT.id, { action: PULL_REQUEST_ACTION.RESOLVE_THREAD, threadId: "PRRT_flag" }], ]); }); + +/** Writes in the comment box at the Conversation's foot as the member would. */ +function writeComment(tab: HTMLElement, words: string): HTMLTextAreaElement { + const field = + tab.querySelector( + '.pull-request-comment-box textarea[aria-label="Comment"]', + ) ?? assert.fail("the Conversation ends in a comment box"); + act(() => { + const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + set?.call(field, words); + field.dispatchEvent(new Event("input", { bubbles: true })); + }); + return field; +} + +/** The comment box's Comment press. */ +function commentPress(tab: HTMLElement): HTMLButtonElement { + return ( + tab.querySelector(".pull-request-comment-foot button") ?? + assert.fail("the box offers Comment") + ); +} + +test("a comment written at the Conversation's foot is asked of the service for the agent, after which the box lets its words go and the comment stands in the conversation", async () => { + const tab = await draw( + controlAnswering({ + conversation: () => + Promise.resolve({ + pullRequest: { + ...CONVERSATION, + conversation: [ + ...CONVERSATION.conversation, + ...changes.flatMap(([, request]) => + request.action === PULL_REQUEST_ACTION.COMMENT + ? [ + { + kind: CONVERSATION_ENTRY.COMMENT, + by: CHARLES_ON_GITHUB, + html: null, + body: request.body, + at: 4_000, + url: `${PAGE}#issuecomment-9`, + }, + ] + : [], + ), + ], + }, + }), + }), + ); + assert.equal(commentPress(tab).disabled, true, "a blank comment is not sent"); + + const field = writeComment(tab, "Shipping after **lunch**."); + act(() => { + field.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }), + ); + }); + await settle(); + + assert.deepEqual(changes, [ + [AGENT.id, { action: PULL_REQUEST_ACTION.COMMENT, body: "Shipping after **lunch**." }], + ]); + assert.equal( + tab.querySelector(".pull-request-comment-box textarea")?.value, + "", + "the box lets its words go", + ); + assert.match( + tab.querySelector(".pull-request-conversation")?.textContent ?? "", + /Shipping after lunch\./u, + ); +}); + +test("a comment GitHub refuses says so under the box in GitHub's own words, its words kept for another try", async () => { + changeAnswer = { + outcome: PULL_REQUEST_WRITE_OUTCOME.REFUSED, + words: "Conversation is locked to collaborators", + }; + const tab = await draw(controlAnswering()); + + writeComment(tab, "Ping?"); + act(() => commentPress(tab).click()); + await settle(); + + assert.equal( + tab.querySelector(".pull-request-comment-note .failure-note-words")?.textContent, + "GitHub said: Conversation is locked to collaborators", + ); + assert.equal( + tab.querySelector(".pull-request-comment-box textarea")?.value, + "Ping?", + ); +}); diff --git a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx index c6231f978e..c963560e08 100644 --- a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx +++ b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx @@ -91,6 +91,7 @@ import { import { PierreDiff, useDiffLook } from "./file-diff"; import { useFileViewSettings } from "./file-view-settings"; import { blockedWords, MergeActions } from "./merge-box"; +import { PullRequestCommentBox } from "./pull-request-comment-box"; import { PullRequestLabels } from "./pull-request-labels"; import { HeaderReviewers, OpenedBy, PersonRef, ReviewStanding } from "./pull-request-people"; import { Reactions } from "./pull-request-reactions"; @@ -1490,15 +1491,18 @@ function ConversationFilterRow({ ); } -/** The Conversation tab: its filters, then the description, then the comments, reviews, and threads, in one scroll under them. */ +/** The Conversation tab: its filters, then the description, then the comments, reviews, and threads, in one scroll under them, and, once the conversation is read, the box a new comment is written in at its foot. */ function ConversationView({ agentId, parts, url, + comment, }: { agentId: string; parts: OverviewParts; url: string | undefined; + /** The comment box's own change, apart from the others, so it says its own answer under itself. */ + comment: PullRequestWrites; }): React.JSX.Element { const now = useMinute(); const picture = usePictureDrawer(agentId); @@ -1523,6 +1527,7 @@ function ConversationView({ now={now} picture={picture} /> + {conversation === undefined ? null : } ); @@ -1637,6 +1642,7 @@ export function AgentPullRequestTab({ const writes = usePullRequestWrites(agentId, written); const review = usePullRequestWrites(agentId, written); const picture = usePictureDrawer(agentId); + const comment = usePullRequestWrites(agentId, written); // Every review thread the tab draws, in the Conversation or on its line, replies and resolves on this agent's pull request. return ( @@ -1674,7 +1680,12 @@ export function AgentPullRequestTab({ aria-labelledby={ids(PULL_REQUEST_VIEW.CONVERSATION).tab} hidden={view !== PULL_REQUEST_VIEW.CONVERSATION} > - +
void; + /** Whether the words are on their way to GitHub, so the field takes no typing. */ + busy: boolean; + /** The field itself, for an owner that focuses it as it opens. */ + fieldRef?: Ref; + /** What ⌘↩ in the field does, where the owner does not answer it itself. */ + onSubmit?: () => void; +}): React.JSX.Element { + const [mode, setMode] = useState(BODY_MODE.WRITE); + return ( +
+ + {mode === BODY_MODE.WRITE ? ( +