diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx index 81ff7aad4..39eeb2270 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -50,7 +50,6 @@ import type { Task, } from "@shared/types"; import type { InboxReportActionProperties } from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; import { useQuery } from "@tanstack/react-query"; import { isMac } from "@utils/platform"; import { @@ -63,6 +62,7 @@ import { useState, } from "react"; import { toast } from "sonner"; +import { useCreatePrReport } from "../../hooks/useCreatePrReport"; import { useDiscussReport } from "../../hooks/useDiscussReport"; import { ReportImplementationPrLink } from "../utils/ReportImplementationPrLink"; import { SignalReportActionabilityBadge } from "../utils/SignalReportActionabilityBadge"; @@ -265,7 +265,6 @@ export function ReportDetailPane({ ); // ── Task creation ─────────────────────────────────────────────────────── - const { navigateToTaskInput } = useNavigationStore(); const { data: reportRepository } = useReportRepository(report.id); const trpcReact = useTRPC(); const { data: mostRecentRepo } = useQuery( @@ -360,22 +359,20 @@ export function ReportDetailPane({ [fireDetailAction], ); - const handleCreateImplementationTask = useCallback(() => { - if (!canCreateImplementationPr) return; + const { createPrReport, isCreatingPr } = useCreatePrReport({ + reportId: report.id, + reportTitle: report.title, + cloudRepository: effectiveCloudRepository, + }); + + const handleCreateImplementationTask = useCallback(async () => { + if (!canCreateImplementationPr || isCreatingPr) return; fireDetailAction("create_pr"); - navigateToTaskInput({ - initialPrompt: `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`, - initialCloudRepository: effectiveCloudRepository ?? undefined, - reportAssociation: { - reportId: report.id, - title: report.title ?? "Untitled signal", - }, - }); + await createPrReport(); }, [ canCreateImplementationPr, - navigateToTaskInput, - effectiveCloudRepository, - report, + isCreatingPr, + createPrReport, fireDetailAction, ]); @@ -614,9 +611,10 @@ export function ReportDetailPane({ size="1" variant="solid" className="gap-1 text-[12px]" + disabled={isCreatingPr} onClick={handleCreateImplementationTask} > - + {isCreatingPr ? : } Create PR diff --git a/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts b/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts new file mode 100644 index 000000000..5ebf3f564 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts @@ -0,0 +1,173 @@ +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { useCreateTask } from "@features/tasks/hooks/useTasks"; +import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; +import { get } from "@renderer/di/container"; +import { RENDERER_TOKENS } from "@renderer/di/tokens"; +import { toast } from "@renderer/utils/toast"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; +import { useNavigationStore } from "@stores/navigationStore"; +import { track } from "@utils/analytics"; +import { logger } from "@utils/logger"; +import { useCallback, useState } from "react"; +import { toast as sonnerToast } from "sonner"; +import type { + TaskCreationInput, + TaskService, +} from "../../task-detail/service/service"; +import { buildCreatePrReportPrompt } from "../utils/buildCreatePrReportPrompt"; +import { resolveDefaultModel } from "../utils/resolveDefaultModel"; + +const log = logger.scope("create-pr-report"); + +interface UseCreatePrReportOptions { + reportId: string; + reportTitle: string | null; + cloudRepository: string | null; +} + +interface UseCreatePrReportReturn { + /** Create an auto-mode implementation task for the report and navigate to it on success. */ + createPrReport: () => Promise; + /** True while the task is being created. */ + isCreatingPr: boolean; +} + +/** + * Create an implementation (PR) task directly from the inbox detail pane. + * + * Mirrors the Discuss flow: bypasses TaskInput so the user stays on the inbox + * until the task is ready, then jumps straight to the task detail page. The + * agent gets a short prompt that points it at the inbox MCP tools instead of + * inlining the entire report summary. + */ +export function useCreatePrReport({ + reportId, + reportTitle, + cloudRepository, +}: UseCreatePrReportOptions): UseCreatePrReportReturn { + const [isCreatingPr, setIsCreatingPr] = useState(false); + const { navigateToTask } = useNavigationStore(); + const { getUserIntegrationIdForRepo } = useUserRepositoryIntegration(); + const { invalidateTasks } = useCreateTask(); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + + const createPrReport = useCallback(async () => { + if (isCreatingPr) return; + if (!cloudRepository) { + toast.error("Pick a cloud repository before creating a PR"); + return; + } + + const githubUserIntegrationId = + getUserIntegrationIdForRepo(cloudRepository); + if (!githubUserIntegrationId) { + toast.error("Connect a GitHub integration to create a PR"); + return; + } + + if (!cloudRegion) { + toast.error("Sign in to create a PR"); + return; + } + + setIsCreatingPr(true); + const toastId = toast.loading( + "Starting PR task...", + reportTitle ?? undefined, + ); + + const prompt = buildCreatePrReportPrompt({ + reportId, + isDevBuild: import.meta.env.DEV, + }); + + const settings = useSettingsStore.getState(); + const adapter = settings.lastUsedAdapter ?? "claude"; + const apiHost = getCloudUrlFromRegion(cloudRegion); + + const model = + settings.lastUsedModel ?? (await resolveDefaultModel(apiHost, adapter)); + + if (!model) { + sonnerToast.dismiss(toastId); + toast.error("Failed to start PR task", { + description: + "Couldn't resolve a default model. Open the task page once and pick a model, then try again.", + }); + setIsCreatingPr(false); + return; + } + + const input: TaskCreationInput = { + content: prompt, + taskDescription: prompt, + repository: cloudRepository, + githubUserIntegrationId, + workspaceMode: "cloud", + executionMode: "auto", + adapter, + model, + reasoningLevel: settings.lastUsedReasoningEffort ?? undefined, + cloudPrAuthorshipMode: "user", + cloudRunSource: "signal_report", + signalReportId: reportId, + }; + + try { + const taskService = get(RENDERER_TOKENS.TaskService); + const result = await taskService.createTask(input, (output) => { + invalidateTasks(output.task); + navigateToTask(output.task); + }); + + if (result.success) { + sonnerToast.dismiss(toastId); + track(ANALYTICS_EVENTS.TASK_CREATED, { + auto_run: true, + created_from: "command-menu", + repository_provider: "github", + workspace_mode: "cloud", + has_branch: false, + cloud_run_source: "signal_report", + cloud_pr_authorship_mode: "user", + adapter, + }); + } else { + sonnerToast.dismiss(toastId); + toast.error("Failed to start PR task", { + description: result.error, + }); + log.error("Create PR task creation failed", { + failedStep: result.failedStep, + error: result.error, + reportId, + reportTitle, + }); + } + } catch (error) { + sonnerToast.dismiss(toastId); + const description = + error instanceof Error ? error.message : "Unknown error"; + toast.error("Failed to start PR task", { description }); + log.error("Unexpected error during Create PR task creation", { + error, + reportId, + }); + } finally { + setIsCreatingPr(false); + } + }, [ + isCreatingPr, + cloudRepository, + cloudRegion, + reportId, + reportTitle, + getUserIntegrationIdForRepo, + invalidateTasks, + navigateToTask, + ]); + + return { createPrReport, isCreatingPr }; +} diff --git a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts index b81a1e28c..7fdd66cde 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts @@ -4,7 +4,6 @@ import { useCreateTask } from "@features/tasks/hooks/useTasks"; import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; import { get } from "@renderer/di/container"; import { RENDERER_TOKENS } from "@renderer/di/tokens"; -import { trpcClient } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { getCloudUrlFromRegion } from "@shared/utils/urls"; @@ -18,6 +17,7 @@ import type { TaskService, } from "../../task-detail/service/service"; import { buildDiscussReportPrompt } from "../utils/buildDiscussReportPrompt"; +import { resolveDefaultModel } from "../utils/resolveDefaultModel"; const log = logger.scope("discuss-report"); @@ -34,32 +34,6 @@ interface UseDiscussReportReturn { isDiscussing: boolean; } -/** - * Resolve the default model for the given adapter via the preview-config - * tRPC query. Returns the server's `currentValue` for the `model` option, or - * undefined if the call fails or the option is missing. - */ -async function resolveDefaultModel( - apiHost: string, - adapter: "claude" | "codex", -): Promise { - try { - const options = await trpcClient.agent.getPreviewConfigOptions.query({ - apiHost, - adapter, - }); - const modelOption = options.find( - (o) => o.id === "model" || o.category === "model", - ); - if (modelOption?.type === "select" && modelOption.currentValue) { - return modelOption.currentValue; - } - } catch (error) { - log.warn("Failed to resolve default model for Discuss", { error, adapter }); - } - return undefined; -} - /** * Create a Discuss task directly from the inbox detail pane. * diff --git a/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.test.ts b/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.test.ts new file mode 100644 index 000000000..5087a137d --- /dev/null +++ b/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { buildCreatePrReportPrompt } from "./buildCreatePrReportPrompt"; + +describe("buildCreatePrReportPrompt", () => { + it.each([ + { isDevBuild: false, expectedScheme: "posthog-code" }, + { isDevBuild: true, expectedScheme: "posthog-code-dev" }, + ])( + "uses the $expectedScheme deeplink scheme when isDevBuild=$isDevBuild", + ({ isDevBuild, expectedScheme }) => { + const prompt = buildCreatePrReportPrompt({ + reportId: "abc123", + isDevBuild, + }); + expect(prompt).toContain(`${expectedScheme}://inbox/abc123`); + }, + ); + + it("references the inbox MCP tools so the agent fetches the detail itself", () => { + const prompt = buildCreatePrReportPrompt({ + reportId: "abc123", + isDevBuild: false, + }); + expect(prompt).toContain("inbox MCP tools"); + }); + + it("asks the agent to open a PR", () => { + const prompt = buildCreatePrReportPrompt({ + reportId: "abc123", + isDevBuild: false, + }); + expect(prompt).toMatch(/open a PR/i); + }); + + it("tells the agent to stop rather than guess if the report can't be fetched", () => { + const prompt = buildCreatePrReportPrompt({ + reportId: "abc123", + isDevBuild: false, + }); + expect(prompt).toMatch(/can't fetch the report/i); + expect(prompt).toMatch(/instead of guessing/i); + }); +}); diff --git a/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.ts b/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.ts new file mode 100644 index 000000000..3e67772b0 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.ts @@ -0,0 +1,14 @@ +import { getDeeplinkProtocol } from "@shared/deeplink"; + +interface BuildCreatePrReportPromptOptions { + reportId: string; + isDevBuild: boolean; +} + +export function buildCreatePrReportPrompt({ + reportId, + isDevBuild, +}: BuildCreatePrReportPromptOptions): string { + const reportLink = `${getDeeplinkProtocol(isDevBuild)}://inbox/${reportId}`; + return `Act on PostHog inbox report ${reportId} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, its signals, and any suggested reviewers; investigate the root cause; implement the fix; and open a PR. If you can't fetch the report, stop and report that instead of guessing what it contains.`; +} diff --git a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts index 389e0dadc..a6e078a37 100644 --- a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts +++ b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts @@ -44,4 +44,18 @@ describe("buildDiscussReportPrompt", () => { }); expect(prompt).toContain("brief readout"); }); + + it("tells the agent to say so rather than guess if the report can't be fetched", () => { + const withQuestion = buildDiscussReportPrompt({ + reportId: "abc123", + question: "Why is conversion dropping?", + isDevBuild: false, + }); + const withoutQuestion = buildDiscussReportPrompt({ + reportId: "abc123", + isDevBuild: false, + }); + expect(withQuestion).toMatch(/can't fetch the report/i); + expect(withoutQuestion).toMatch(/can't fetch the report/i); + }); }); diff --git a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts index bb4361402..fe815ca8f 100644 --- a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts +++ b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts @@ -14,7 +14,10 @@ export function buildDiscussReportPrompt({ const trimmedQuestion = question?.trim(); const reportLink = `${getDeeplinkProtocol(isDevBuild)}://inbox/${reportId}`; const intro = `Discuss PostHog inbox report ${reportId} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report,`; - return trimmedQuestion + const guard = + " If you can't fetch the report, say so instead of guessing what it contains."; + const body = trimmedQuestion ? `${intro} then answer this first: ${trimmedQuestion}` : `${intro} then give me a brief readout and ask what I want to dig into.`; + return `${body}${guard}`; } diff --git a/apps/code/src/renderer/features/inbox/utils/resolveDefaultModel.ts b/apps/code/src/renderer/features/inbox/utils/resolveDefaultModel.ts new file mode 100644 index 000000000..21690f976 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/utils/resolveDefaultModel.ts @@ -0,0 +1,34 @@ +import { trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; + +const log = logger.scope("resolve-default-model"); + +/** + * Resolve the default model for the given adapter via the preview-config + * tRPC query. Returns the server's `currentValue` for the `model` option, or + * undefined if the call fails or the option is missing. + * + * Used by inbox flows that create cloud tasks directly (Discuss, Create PR) + * without going through TaskInput — they need a model to pass to the saga + * and the user hasn't necessarily picked one yet. + */ +export async function resolveDefaultModel( + apiHost: string, + adapter: "claude" | "codex", +): Promise { + try { + const options = await trpcClient.agent.getPreviewConfigOptions.query({ + apiHost, + adapter, + }); + const modelOption = options.find( + (o) => o.id === "model" || o.category === "model", + ); + if (modelOption?.type === "select" && modelOption.currentValue) { + return modelOption.currentValue; + } + } catch (error) { + log.warn("Failed to resolve default model", { error, adapter }); + } + return undefined; +}