Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
]);

Expand Down Expand Up @@ -614,9 +611,10 @@ export function ReportDetailPane({
size="1"
variant="solid"
className="gap-1 text-[12px]"
disabled={isCreatingPr}
onClick={handleCreateImplementationTask}
>
<Plus size={12} />
{isCreatingPr ? <Spinner size="1" /> : <Plus size={12} />}
Create PR
</Button>
</Tooltip>
Expand Down
173 changes: 173 additions & 0 deletions apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
/** 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Harden in-flight guard against rapid double submission

The isCreatingPr guard is state-based and is checked before setIsCreatingPr(true) runs, so two very fast triggers (e.g., double-click or key-repeat on Cmd/Ctrl+Enter) in the same render frame can both pass this check and start parallel createTask calls. That can create duplicate PR tasks for a single report. Use a synchronous in-flight ref/mutex (or set-and-check atomically) so re-entrant calls are blocked immediately.

Useful? React with 👍 / 👎.

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;
Comment on lines +63 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delay missing-integration error until repo mappings load

This new Create PR path treats getUserIntegrationIdForRepo(...) returning undefined as “no GitHub integration,” but that mapping is populated asynchronously by useUserRepositoryIntegration (it is built from async repository queries). If the user clicks Create PR before those queries finish, we now show a false "Connect a GitHub integration" error and abort task creation, even when the integration exists. Please gate this check on the integration-loading state (or trigger a refresh/retry) before surfacing the missing-integration toast.

Useful? React with 👍 / 👎.

}

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,
Comment on lines +108 to +111
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid auto-running Create PR for reports awaiting user input

This flow now always creates tasks with executionMode: "auto" while ReportDetailPane still exposes Create PR for pending_input / requires_human_input reports. In that state the user is expected to supply missing context, but bypassing TaskInput removes the pre-run input step and immediately launches an implementation run with incomplete requirements, which can lead to avoidable failed or low-quality PR tasks. Gate auto-run to immediately-actionable reports or keep a manual-input path for pending-input reports.

Useful? React with 👍 / 👎.

reasoningLevel: settings.lastUsedReasoningEffort ?? undefined,
cloudPrAuthorshipMode: "user",
cloudRunSource: "signal_report",
signalReportId: reportId,
};

try {
const taskService = get<TaskService>(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,
});
Comment thread
andrewm4894 marked this conversation as resolved.
} 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 };
}
28 changes: 1 addition & 27 deletions apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");

Expand All @@ -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<string | undefined> {
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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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.`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Loading
Loading