diff --git a/packages/core/src/code-review/reviewPrompts.test.ts b/packages/core/src/code-review/reviewPrompts.test.ts index 376940691f..c25e0a9ede 100644 --- a/packages/core/src/code-review/reviewPrompts.test.ts +++ b/packages/core/src/code-review/reviewPrompts.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildAskAboutPrCommentPrompt, buildBatchedInlineCommentsPrompt, + buildChatAboutPrCommentPrompt, buildFixPrCommentPrompt, buildInlineCommentPrompt, } from "./reviewPrompts"; @@ -65,7 +66,7 @@ describe("buildBatchedInlineCommentsPrompt", () => { }); }); -describe("buildFixPrCommentPrompt / buildAskAboutPrCommentPrompt", () => { +describe("PR comment prompts", () => { it("includes the thread body and side", () => { const out = buildFixPrCommentPrompt("a.ts", 4, "new", [ makeComment("please rename"), @@ -81,4 +82,19 @@ describe("buildFixPrCommentPrompt / buildAskAboutPrCommentPrompt", () => { ]); expect(out).toContain("Do not make any changes"); }); + + it("chat prompt includes the thread and custom message", () => { + const out = buildChatAboutPrCommentPrompt( + 'src/a".ts', + 8, + "new", + [makeComment("consider extracting this", "reviewer")], + "Is there already a helper for this?", + ); + expect(out).toContain(''); + expect(out).toContain("line 8 (new)"); + expect(out).toContain("@reviewer"); + expect(out).toContain("consider extracting this"); + expect(out.endsWith("Is there already a helper for this?")).toBe(true); + }); }); diff --git a/packages/core/src/code-review/reviewPrompts.ts b/packages/core/src/code-review/reviewPrompts.ts index 688aa79d4a..a5c7570d1b 100644 --- a/packages/core/src/code-review/reviewPrompts.ts +++ b/packages/core/src/code-review/reviewPrompts.ts @@ -15,6 +15,17 @@ function formatThreadForPrompt(comments: PrReviewComment[]): string { return comments.map((c) => `@${c.user.login}:\n> ${c.body}`).join("\n\n"); } +function formatPrCommentPromptContext( + filePath: string, + line: number, + side: "old" | "new", + comments: PrReviewComment[], +): string { + const escapedPath = escapeXmlAttr(filePath); + const thread = formatThreadForPrompt(comments); + return `, line ${line} (${side}):\n\n${thread}`; +} + function formatLineRef(startLine: number, endLine: number): string { return startLine === endLine ? `line ${startLine}` @@ -85,9 +96,8 @@ export function buildFixPrCommentPrompt( side: "old" | "new", comments: PrReviewComment[], ): string { - const escapedPath = escapeXmlAttr(filePath); - const thread = formatThreadForPrompt(comments); - return `Fix this PR review comment on , line ${line} (${side}):\n\n${thread}`; + const context = formatPrCommentPromptContext(filePath, line, side, comments); + return `Fix this PR review comment on ${context}`; } export function buildAskAboutPrCommentPrompt( @@ -96,7 +106,17 @@ export function buildAskAboutPrCommentPrompt( side: "old" | "new", comments: PrReviewComment[], ): string { - const escapedPath = escapeXmlAttr(filePath); - const thread = formatThreadForPrompt(comments); - return `Help me understand this PR review comment on , line ${line} (${side}):\n\n${thread}\n\nWhat is this comment asking for and how should I address it? Do not make any changes, your job is simply to chat with me about this comment. If I need further changes, I'll ask.`; + const context = formatPrCommentPromptContext(filePath, line, side, comments); + return `Help me understand this PR review comment on ${context}\n\nWhat is this comment asking for and how should I address it? Do not make any changes, your job is simply to chat with me about this comment. If I need further changes, I'll ask.`; +} + +export function buildChatAboutPrCommentPrompt( + filePath: string, + line: number, + side: "old" | "new", + comments: PrReviewComment[], + message: string, +): string { + const context = formatPrCommentPromptContext(filePath, line, side, comments); + return `Regarding this PR review comment on ${context}\n\n${message}`; } diff --git a/packages/ui/src/features/code-review/components/PatchedFileDiff.test.tsx b/packages/ui/src/features/code-review/components/PatchedFileDiff.test.tsx new file mode 100644 index 0000000000..bc2fac4499 --- /dev/null +++ b/packages/ui/src/features/code-review/components/PatchedFileDiff.test.tsx @@ -0,0 +1,82 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import type { PrCommentThread } from "@posthog/core/code-review/types"; +import type { ChangedFile } from "@posthog/shared/domain-types"; +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../../primitives/FileIcon", () => ({ + FileIcon: () => , +})); + +vi.mock("./InteractiveFileDiff", () => ({ + InteractiveFileDiff: ({ + fileDiff, + renderCustomHeader, + }: { + fileDiff: FileDiffMetadata; + renderCustomHeader: (fileDiff: FileDiffMetadata) => ReactNode; + }) => renderCustomHeader(fileDiff), +})); + +import { PatchedFileDiff } from "./PatchedFileDiff"; + +const patch = `diff --git a/src/reviewed.ts b/src/reviewed.ts +index 1111111..2222222 100644 +--- a/src/reviewed.ts ++++ b/src/reviewed.ts +@@ -1 +1 @@ +-before ++after`; + +describe.each([ + [ + "regular", + { + path: "src/reviewed.ts", + originalPath: "src/original.ts", + patch, + }, + ], + ["binary", { path: "assets/reviewed.png", patch: null }], + ["unavailable", { path: "src/unavailable.ts", patch: null }], +] as const)("PatchedFileDiff %s header", (_kind, fileInput) => { + it("renders metadata before line change stats", () => { + const file = { + ...fileInput, + linesAdded: 2, + linesRemoved: 1, + } as ChangedFile; + const threadPath = file.originalPath ?? file.path; + const commentThreads = new Map([ + [ + 1, + { + rootId: 1, + nodeId: "thread-1", + isResolved: false, + filePath: threadPath, + comments: [{ id: 1 }, { id: 2 }] as PrCommentThread["comments"], + }, + ], + ]); + + render( + {}} + commentThreads={commentThreads} + />, + ); + + const header = screen.getByRole("button"); + const text = header.textContent ?? ""; + const additions = _kind === "regular" ? "+1" : "+2"; + + expect(screen.getByTitle("2 comments")).toBeInTheDocument(); + expect(text.indexOf("2 comments")).toBeLessThan(text.indexOf(additions)); + }); +}); diff --git a/packages/ui/src/features/code-review/components/PatchedFileDiff.tsx b/packages/ui/src/features/code-review/components/PatchedFileDiff.tsx index e648537e1d..93b987636a 100644 --- a/packages/ui/src/features/code-review/components/PatchedFileDiff.tsx +++ b/packages/ui/src/features/code-review/components/PatchedFileDiff.tsx @@ -49,6 +49,7 @@ export function PatchedFileDiff({ } return null; }, [fileDiff, fallback, file.path]); + const commentCount = countPrCommentsForFile(commentThreads, file); // Branch/PR diffs have no reliable local working-tree file to preview (the // checkout may be on a different ref, and GitHub omits binary patches), so @@ -63,6 +64,7 @@ export function PatchedFileDiff({ collapsed={collapsed} onToggle={onToggle} externalUrl={externalUrl} + commentCount={commentCount} headerTrailing={headerTrailing} /> ); @@ -78,6 +80,7 @@ export function PatchedFileDiff({ collapsed={collapsed} onToggle={onToggle} externalUrl={externalUrl} + commentCount={commentCount} headerTrailing={headerTrailing} /> ); @@ -95,9 +98,26 @@ export function PatchedFileDiff({ fileDiff={fd} collapsed={collapsed} onToggle={onToggle} + commentCount={commentCount} trailing={headerTrailing} /> )} /> ); } + +function countPrCommentsForFile( + threads: Map | undefined, + file: Pick, +): number { + let count = 0; + for (const thread of threads?.values() ?? []) { + if ( + thread.filePath === file.path || + (file.originalPath != null && thread.filePath === file.originalPath) + ) { + count += thread.comments.length; + } + } + return count; +} diff --git a/packages/ui/src/features/code-review/components/PrCommentThread.test.tsx b/packages/ui/src/features/code-review/components/PrCommentThread.test.tsx new file mode 100644 index 0000000000..7f14fe2ee9 --- /dev/null +++ b/packages/ui/src/features/code-review/components/PrCommentThread.test.tsx @@ -0,0 +1,154 @@ +import type { PrReviewComment } from "@posthog/shared"; +import { Theme } from "@radix-ui/themes"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PrCommentMetadata } from "../types"; +import { PrCommentThread } from "./PrCommentThread"; + +const { reply, resolve, sendPromptToAgent } = vi.hoisted(() => ({ + reply: vi.fn(), + resolve: vi.fn(), + sendPromptToAgent: vi.fn(), +})); + +vi.mock("../hooks/usePrCommentActions", () => ({ + usePrCommentActions: () => ({ reply, resolve }), +})); + +vi.mock("../../sessions/sendPromptToAgent", () => ({ sendPromptToAgent })); + +function makeComment(): PrReviewComment { + return { + id: 42, + body: "Could this use the shared helper?", + created_at: "2026-07-14T12:00:00Z", + user: { + login: "reviewer", + avatar_url: "", + }, + } as PrReviewComment; +} + +function makeMetadata(): PrCommentMetadata { + return { + kind: "pr-comment", + threadId: 42, + nodeId: "thread-node", + isResolved: false, + comments: [makeComment()], + isOutdated: false, + isFileLevel: false, + startLine: 8, + endLine: 8, + side: "additions", + }; +} + +function renderThread() { + return render( + + + , + ); +} + +describe("PrCommentThread", () => { + beforeEach(() => { + vi.clearAllMocks(); + reply.mockResolvedValue(true); + resolve.mockResolvedValue(true); + sendPromptToAgent.mockResolvedValue(true); + }); + + it("sends a custom chat message with the review context", async () => { + const user = userEvent.setup(); + renderThread(); + + await user.click(screen.getByRole("button", { name: "Chat" })); + await user.type( + screen.getByPlaceholderText("Ask the agent about this comment..."), + "Check whether this helper already exists", + ); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(sendPromptToAgent).toHaveBeenCalledWith( + "task-1", + expect.stringContaining("Could this use the shared helper?"), + ); + expect(sendPromptToAgent).toHaveBeenCalledWith( + "task-1", + expect.stringContaining("Check whether this helper already exists"), + ); + await waitFor(() => + expect( + screen.queryByPlaceholderText("Ask the agent about this comment..."), + ).not.toBeInTheDocument(), + ); + }); + + it("keeps the custom message available when sending fails", async () => { + sendPromptToAgent.mockResolvedValue(false); + const user = userEvent.setup(); + renderThread(); + + await user.click(screen.getByRole("button", { name: "Chat" })); + const textarea = screen.getByPlaceholderText( + "Ask the agent about this comment...", + ); + await user.type(textarea, "Keep this draft"); + await user.click(screen.getByRole("button", { name: "Send" })); + + await waitFor(() => + expect( + screen.getByPlaceholderText("Ask the agent about this comment..."), + ).toHaveValue("Keep this draft"), + ); + }); + + it("keeps a reply composer opened while a chat message is sending", async () => { + let resolveSend: ((success: boolean) => void) | undefined; + sendPromptToAgent.mockReturnValue( + new Promise((resolve) => { + resolveSend = resolve; + }), + ); + const user = userEvent.setup(); + renderThread(); + + await user.click(screen.getByRole("button", { name: "Chat" })); + await user.type( + screen.getByPlaceholderText("Ask the agent about this comment..."), + "Check this", + ); + await user.click(screen.getByRole("button", { name: "Send" })); + await user.click(screen.getByRole("button", { name: "Close composer" })); + await user.click(screen.getByRole("button", { name: "Reply" })); + await user.type(screen.getByPlaceholderText("Write a reply..."), "Keep me"); + + resolveSend?.(true); + + await waitFor(() => + expect(screen.getByPlaceholderText("Write a reply...")).toHaveValue( + "Keep me", + ), + ); + }); + + it("still posts replies without sending them to chat", async () => { + const user = userEvent.setup(); + renderThread(); + + await user.click(screen.getByRole("button", { name: "Reply" })); + await user.type(screen.getByPlaceholderText("Write a reply..."), "Done"); + await user.click(screen.getByRole("button", { name: "Reply" })); + + expect(reply).toHaveBeenCalledWith(42, "Done"); + expect(sendPromptToAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/code-review/components/PrCommentThread.tsx b/packages/ui/src/features/code-review/components/PrCommentThread.tsx index dfc10acec3..3addfc943c 100644 --- a/packages/ui/src/features/code-review/components/PrCommentThread.tsx +++ b/packages/ui/src/features/code-review/components/PrCommentThread.tsx @@ -12,6 +12,7 @@ import { } from "@phosphor-icons/react"; import { buildAskAboutPrCommentPrompt, + buildChatAboutPrCommentPrompt, buildFixPrCommentPrompt, } from "@posthog/core/code-review/reviewPrompts"; import { Button } from "@posthog/quill"; @@ -40,6 +41,7 @@ const ghRehypePlugins: PluggableList = [ ]; const MAX_COMMENT_HEIGHT = 120; +type ComposerMode = "reply" | "chat"; /** Strip markdown noise to a single-line preview for the collapsed header. */ function toPreview(body: string): string { @@ -61,11 +63,12 @@ interface ThreadActionBarProps { comments: PrReviewComment[]; isResolved: boolean; onResolveToggle: () => void; - showReplyBox: boolean; + composerMode: ComposerMode | null; pendingReply: string | null; - onShowReplyBox: () => void; - onHideReplyBox: () => void; - onSubmitReply: () => void; + isSendingChat: boolean; + onShowComposer: (mode: ComposerMode) => void; + onHideComposer: () => void; + onSubmitComposer: () => void; onKeyDown: (e: React.KeyboardEvent) => void; textareaRefCallback: (el: HTMLTextAreaElement | null) => void; } @@ -79,20 +82,26 @@ function ThreadActionBar({ comments, isResolved, onResolveToggle, - showReplyBox, + composerMode, pendingReply, - onShowReplyBox, - onHideReplyBox, - onSubmitReply, + isSendingChat, + onShowComposer, + onHideComposer, + onSubmitComposer, onKeyDown, textareaRefCallback, }: ThreadActionBarProps) { - if (showReplyBox) { + if (composerMode) { + const isReply = composerMode === "reply"; + const isSending = isSendingChat || (isReply && !!pendingReply); + const submitLabel = isSending ? "Sending..." : isReply ? "Reply" : "Send"; return ( @@ -100,13 +109,17 @@ function ThreadActionBar({ - - {pendingReply ? "Sending..." : "Reply"} + {isReply ? : } + {submitLabel} - + @@ -121,7 +134,7 @@ function ThreadActionBar({ className="mt-1 border-[var(--gray-4)] border-t pt-1.5" > {prUrl && ( - + onShowComposer("reply")}> Reply @@ -168,6 +181,11 @@ function ThreadActionBar({ Ask + + onShowComposer("chat")}> + + Chat + ); } @@ -296,8 +314,9 @@ export function PrCommentThread({ } = metadata; const side = annotationSide === "deletions" ? "old" : "new"; const { reply, resolve } = usePrCommentActions(prUrl); - const [showReplyBox, setShowReplyBox] = useState(false); + const [composerMode, setComposerMode] = useState(null); const [pendingReply, setPendingReply] = useState(null); + const [isSendingChat, setIsSendingChat] = useState(false); const [isResolved, setIsResolved] = useState(initialIsResolved); // Resolved/outdated threads add up — start them collapsed. const [isCollapsed, setIsCollapsed] = useState( @@ -319,30 +338,54 @@ export function PrCommentThread({ prevLastCommentIdRef.current = lastCommentId; }, [lastCommentId, pendingReply]); - const handleReplySubmit = useCallback(async () => { + const handleComposerSubmit = useCallback(async () => { const text = textareaRef.current?.value?.trim(); - if (text) { - setPendingReply(text); - setShowReplyBox(false); - const success = await reply(threadId, text); - if (!success) { - setPendingReply(null); + if (!text || !composerMode) return; + + if (composerMode === "chat") { + setIsSendingChat(true); + const success = await sendPromptToAgent( + taskId, + buildChatAboutPrCommentPrompt(filePath, endLine, side, comments, text), + ); + setIsSendingChat(false); + if (success) { + setComposerMode((currentMode) => + currentMode === "chat" ? null : currentMode, + ); } + return; } - }, [reply, threadId]); + + setPendingReply(text); + setComposerMode(null); + const success = await reply(threadId, text); + if (!success) { + setPendingReply(null); + } + }, [ + comments, + composerMode, + endLine, + filePath, + reply, + side, + taskId, + threadId, + ]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (isSendMessageSubmitKey(e)) { e.preventDefault(); - handleReplySubmit(); + handleComposerSubmit(); } if (e.key === "Escape") { e.preventDefault(); - setShowReplyBox(false); + setComposerMode(null); } }, - [handleReplySubmit], + [handleComposerSubmit], ); const setTextareaRefCallback = useCallback( @@ -497,11 +540,12 @@ export function PrCommentThread({ comments={comments} isResolved={isResolved} onResolveToggle={handleResolveToggle} - showReplyBox={showReplyBox} + composerMode={composerMode} pendingReply={pendingReply} - onShowReplyBox={() => setShowReplyBox(true)} - onHideReplyBox={() => setShowReplyBox(false)} - onSubmitReply={handleReplySubmit} + isSendingChat={isSendingChat} + onShowComposer={setComposerMode} + onHideComposer={() => setComposerMode(null)} + onSubmitComposer={handleComposerSubmit} onKeyDown={handleKeyDown} textareaRefCallback={setTextareaRefCallback} /> diff --git a/packages/ui/src/features/code-review/reviewShellParts.test.tsx b/packages/ui/src/features/code-review/reviewShellParts.test.tsx index 2361dae47a..a07d37b55b 100644 --- a/packages/ui/src/features/code-review/reviewShellParts.test.tsx +++ b/packages/ui/src/features/code-review/reviewShellParts.test.tsx @@ -36,12 +36,13 @@ function findSpan( return found; } -function renderHeader(path: string) { +function renderHeader(path: string, commentCount?: number) { const diff = render( {}} + commentCount={commentCount} />, ); const deferred = render( @@ -52,6 +53,7 @@ function renderHeader(path: string) { reason="line-limit" collapsed={false} onToggle={() => {}} + commentCount={commentCount} />, ); return { diff, deferred }; @@ -96,4 +98,12 @@ describe.each([ expect(dirSpan.parentElement).toBe(fileSpan.parentElement); expect(dirSpan.parentElement?.classList.contains("flex")).toBe(true); }); + + it("renders metadata before line changes", () => { + const rendered = renderHeader("src/ReviewShell.tsx", 2)[which]; + const text = rendered.container.querySelector("button")?.textContent ?? ""; + const additions = which === "diff" ? "+3" : "+10"; + + expect(text.indexOf("2 comments")).toBeLessThan(text.indexOf(additions)); + }); }); diff --git a/packages/ui/src/features/code-review/reviewShellParts.tsx b/packages/ui/src/features/code-review/reviewShellParts.tsx index cafe7f1ceb..ae283f7e92 100644 --- a/packages/ui/src/features/code-review/reviewShellParts.tsx +++ b/packages/ui/src/features/code-review/reviewShellParts.tsx @@ -2,6 +2,7 @@ import { ArrowCounterClockwise, ArrowSquareOut, CaretDown, + ChatCircle, Minus, Plus, } from "@phosphor-icons/react"; @@ -13,6 +14,7 @@ import { splitFilePath, sumHunkStats, } from "@posthog/core/code-review/reviewShellGeometry"; +import { Badge } from "@posthog/quill"; import type { ChangedFile, Task } from "@posthog/shared/domain-types"; import { type ReactNode, useCallback, useMemo, useState } from "react"; import { FileIcon } from "../../primitives/FileIcon"; @@ -139,6 +141,7 @@ export function FileHeaderRow({ deletions, collapsed, onToggle, + commentCount, trailing, }: { dirPath: string; @@ -147,6 +150,7 @@ export function FileHeaderRow({ deletions: number; collapsed: boolean; onToggle: () => void; + commentCount?: number; trailing?: ReactNode; }) { return ( @@ -176,6 +180,9 @@ export function FileHeaderRow({ {dirPath} + {commentCount != null && commentCount > 0 && ( + + )} {additions > 0 && ( +{additions} @@ -210,6 +217,7 @@ export function DiffFileHeader({ onDiscard, onStage, staged, + commentCount, trailing, }: { fileDiff: FileDiffMetadata; @@ -219,6 +227,7 @@ export function DiffFileHeader({ onDiscard?: () => void; onStage?: () => void; staged?: boolean; + commentCount?: number; /** Extra controls rendered after the action buttons (e.g. a "Viewed" toggle). */ trailing?: ReactNode; }) { @@ -237,6 +246,7 @@ export function DiffFileHeader({ deletions={deletions} collapsed={collapsed} onToggle={onToggle} + commentCount={commentCount} trailing={ (onStage || onDiscard || onOpenFile || trailing) && ( @@ -299,6 +309,7 @@ export function DeferredDiffPlaceholder({ onToggle, onShow, externalUrl, + commentCount, headerTrailing, }: { filePath: string; @@ -309,6 +320,7 @@ export function DeferredDiffPlaceholder({ onToggle: () => void; onShow?: () => void; externalUrl?: string; + commentCount?: number; /** Extra controls in the header row (e.g. a "Viewed" toggle). */ headerTrailing?: ReactNode; }) { @@ -323,6 +335,7 @@ export function DeferredDiffPlaceholder({ deletions={linesRemoved} collapsed={collapsed} onToggle={onToggle} + commentCount={commentCount} trailing={ headerTrailing && ( @@ -369,3 +382,18 @@ export function DeferredDiffPlaceholder({ ); } + +function PrCommentCountBadge({ count }: { count: number }) { + const label = `${count} comment${count === 1 ? "" : "s"}`; + return ( + + + {count} + comment{count === 1 ? "" : "s"} + + ); +} diff --git a/packages/ui/src/features/git-interaction/usePrDetails.ts b/packages/ui/src/features/git-interaction/usePrDetails.ts index 657b027290..270309103b 100644 --- a/packages/ui/src/features/git-interaction/usePrDetails.ts +++ b/packages/ui/src/features/git-interaction/usePrDetails.ts @@ -8,18 +8,10 @@ interface UsePrDetailsOptions { includeComments?: boolean; } -function threadsToMap(threads: PrReviewThread[]): Map { - const map = new Map(); - for (const thread of threads) { - map.set(thread.rootId, { - rootId: thread.rootId, - nodeId: thread.nodeId, - isResolved: thread.isResolved, - comments: thread.comments, - filePath: thread.filePath, - }); - } - return map; +function mapPrCommentThreads( + threads: PrReviewThread[], +): Map { + return new Map(threads.map((thread) => [thread.rootId, thread])); } export interface PrStateDetails { @@ -80,7 +72,7 @@ export function usePrDetails( }); const commentThreads = useMemo( - () => threadsToMap(commentsQuery.data ?? []), + () => mapPrCommentThreads(commentsQuery.data ?? []), [commentsQuery.data], ); diff --git a/packages/ui/src/features/sessions/sendPromptToAgent.test.ts b/packages/ui/src/features/sessions/sendPromptToAgent.test.ts index 1440d88393..7b6c070dd7 100644 --- a/packages/ui/src/features/sessions/sendPromptToAgent.test.ts +++ b/packages/ui/src/features/sessions/sendPromptToAgent.test.ts @@ -69,27 +69,22 @@ describe("sendPromptToAgent", () => { async (rejection, expectedMessage) => { mockSender.mockRejectedValueOnce(rejection); - sendPromptToAgent("task-1", "hello"); + const success = await sendPromptToAgent("task-1", "hello"); - await vi.waitFor(() => - expect(toast.error).toHaveBeenCalledWith(expectedMessage), - ); + expect(success).toBe(false); + expect(toast.error).toHaveBeenCalledWith(expectedMessage); }, ); it("does not toast when the send resolves", async () => { mockSender.mockResolvedValueOnce(undefined); - sendPromptToAgent("task-1", "hello"); + const success = await sendPromptToAgent("task-1", "hello"); - // Await the exact promise the sender returned rather than guessing how many - // microtasks the catch chain takes to settle. - await mockSender.mock.results[0]?.value; + expect(success).toBe(true); expect(toast.error).not.toHaveBeenCalled(); }); - // The send is fire-and-forget, so the panel/review side effects must run - // regardless of whether it ultimately resolves or rejects. it.each([ ["resolves", () => mockSender.mockResolvedValueOnce(undefined)], ["rejects", () => mockSender.mockRejectedValueOnce(new Error("nope"))], diff --git a/packages/ui/src/features/sessions/sendPromptToAgent.ts b/packages/ui/src/features/sessions/sendPromptToAgent.ts index 37558cb6cc..907bd1f2b1 100644 --- a/packages/ui/src/features/sessions/sendPromptToAgent.ts +++ b/packages/ui/src/features/sessions/sendPromptToAgent.ts @@ -17,20 +17,20 @@ import { export function sendPromptToAgent( taskId: string, prompt: string | ContentBlock[], -): void { - // Button/review/skill-initiated prompts are fire-and-forget, but a rejected - // send (auth failure, sandbox unreachable, agent process died) must still be - // surfaced, or the turn just shows "Generated in Xs" with no reply. - void resolveService(AGENT_PROMPT_SENDER)( +): Promise { + const sendPromise = resolveService(AGENT_PROMPT_SENDER)( taskId, prompt, - ).catch((error: unknown) => { - toast.error( - error instanceof Error - ? error.message - : "Failed to send your message to the agent. Please try again.", - ); - }); + ) + .then(() => true) + .catch((error: unknown) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to send your message to the agent. Please try again.", + ); + return false; + }); const { getReviewMode, setReviewMode } = useReviewNavigationStore.getState(); if (getReviewMode(taskId) === "expanded") { @@ -45,4 +45,6 @@ export function sendPromptToAgent( setActiveTab(taskId, result.panelId, DEFAULT_TAB_IDS.LOGS); } } + + return sendPromise; }