diff --git a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx index 52c1bc5df..050e10e00 100644 --- a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx @@ -4,9 +4,11 @@ import { HighlightedCode } from "@components/HighlightedCode"; import { List, ListItem } from "@components/List"; import { parseGithubIssueUrl } from "@features/message-editor/utils/githubIssueUrl"; import { Blockquote, Checkbox, Code, Kbd, Text } from "@radix-ui/themes"; +import { trpcClient } from "@renderer/trpc/client"; +import { isPostHogCodeDeeplink } from "@shared/deeplink"; import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; -import ReactMarkdown from "react-markdown"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import type { PluggableList } from "unified"; import { GithubRefChip } from "./GithubRefChip"; @@ -24,6 +26,11 @@ function preprocessMarkdown(content: string): string { return content.replace(/\n([^\n].*)\n(---+|___+|\*\*\*+)\n/g, "\n$1\n\n$2\n"); } +function markdownUrlTransform(value: string): string { + if (isPostHogCodeDeeplink(value)) return value; + return defaultUrlTransform(value); +} + const HeadingText = ({ children }: { children: React.ReactNode }) => ( {children} @@ -92,9 +99,15 @@ export const baseComponents: Components = { ); } + const isDeeplink = isPostHogCodeDeeplink(href); return ( { + if (!isDeeplink || !href) return; + event.preventDefault(); + void trpcClient.os.openExternal.mutate({ url: href }); + }} target="_blank" rel="noopener noreferrer" className="markdown-link inline-flex items-center gap-[2px]" @@ -189,6 +202,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ remarkPlugins={plugins} rehypePlugins={rehypePlugins} components={components} + urlTransform={markdownUrlTransform} > {processedContent} 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 b82108a38..5a2e0d13f 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -15,6 +15,7 @@ import { ArrowSquareOutIcon, CaretDownIcon, CaretRightIcon, + ChatCircleIcon, EyeIcon, LinkSimpleIcon, Plus, @@ -26,9 +27,11 @@ import { Kbd } from "@posthog/quill"; import { Box, Flex, + Popover, ScrollArea, Spinner, Text, + TextField, Tooltip, } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc"; @@ -51,6 +54,7 @@ import { useNavigationStore } from "@stores/navigationStore"; import { useQuery } from "@tanstack/react-query"; import { isMac } from "@utils/platform"; import { + type FormEvent, type ReactNode, useCallback, useEffect, @@ -59,6 +63,7 @@ import { useState, } from "react"; import { toast } from "sonner"; +import { useDiscussReport } from "../../hooks/useDiscussReport"; import { ReportImplementationPrLink } from "../utils/ReportImplementationPrLink"; import { SignalReportActionabilityBadge } from "../utils/SignalReportActionabilityBadge"; import { SignalReportPriorityBadge } from "../utils/SignalReportPriorityBadge"; @@ -186,6 +191,8 @@ export function ReportDetailPane({ onReportAction, onScroll, }: ReportDetailPaneProps) { + const [discussQuestion, setDiscussQuestion] = useState(""); + const [discussQuestionOpen, setDiscussQuestionOpen] = useState(false); const { data: me } = useMeQuery(); // ── Report data ───────────────────────────────────────────────────────── @@ -366,6 +373,35 @@ export function ReportDetailPane({ fireDetailAction, ]); + const { discussReport, isDiscussing } = useDiscussReport({ + reportId: report.id, + reportTitle: report.title, + cloudRepository: effectiveCloudRepository, + }); + + const handleDiscussReport = useCallback( + async (question?: string) => { + const trimmedQuestion = question?.trim(); + fireDetailAction("discuss", { + has_question: !!trimmedQuestion, + question_text: trimmedQuestion + ? trimmedQuestion.slice(0, 500) + : undefined, + }); + setDiscussQuestionOpen(false); + await discussReport(trimmedQuestion); + }, + [discussReport, fireDetailAction], + ); + + const handleDiscussSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + handleDiscussReport(discussQuestion); + }, + [discussQuestion, handleDiscussReport], + ); + // Bind native scroll listener to the Radix ScrollArea viewport (Radix doesn't forward onScroll). // The viewport's data-report-id attribute is set from report.id so we both (a) track the // current report in the DOM for debugging and (b) give biome's useExhaustiveDependencies @@ -471,6 +507,73 @@ export function ReportDetailPane({ )} Dismiss + + + + + + + +
+ setDiscussQuestion(event.target.value)} + /> + + + + + +
+
+
{headerImplementationPrUrl ? ( Promise; + /** True while a Discuss task is being created. */ + 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. + * + * Bypasses TaskInput entirely so the user stays on the inbox until the task is + * ready, then jumps straight to the task detail page. On failure we surface a + * toast and stay put. + */ +export function useDiscussReport({ + reportId, + reportTitle, + cloudRepository, +}: UseDiscussReportOptions): UseDiscussReportReturn { + const [isDiscussing, setIsDiscussing] = useState(false); + const { navigateToTask } = useNavigationStore(); + const { getUserIntegrationIdForRepo } = useUserRepositoryIntegration(); + const { invalidateTasks } = useCreateTask(); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + + const discussReport = useCallback( + async (question?: string) => { + if (isDiscussing) return; + if (!cloudRepository) { + toast.error("Pick a cloud repository before starting a discussion"); + return; + } + + const githubUserIntegrationId = + getUserIntegrationIdForRepo(cloudRepository); + if (!githubUserIntegrationId) { + toast.error("Connect a GitHub integration to start a discussion"); + return; + } + + if (!cloudRegion) { + toast.error("Sign in to start a discussion"); + return; + } + + setIsDiscussing(true); + const toastId = toast.loading( + "Starting discussion...", + reportTitle ?? undefined, + ); + + const prompt = buildDiscussReportPrompt({ + reportId, + question, + 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 discussion", { + description: + "Couldn't resolve a default model. Open the task page once and pick a model, then try again.", + }); + setIsDiscussing(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 discussion", { + description: result.error, + }); + log.error("Discuss 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 discussion", { description }); + log.error("Unexpected error during Discuss task creation", { + error, + reportId, + }); + } finally { + setIsDiscussing(false); + } + }, + [ + isDiscussing, + cloudRepository, + cloudRegion, + reportId, + reportTitle, + getUserIntegrationIdForRepo, + invalidateTasks, + navigateToTask, + ], + ); + + return { discussReport, isDiscussing }; +} diff --git a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts new file mode 100644 index 000000000..389e0dadc --- /dev/null +++ b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { buildDiscussReportPrompt } from "./buildDiscussReportPrompt"; + +describe("buildDiscussReportPrompt", () => { + it("uses the production deeplink scheme outside dev builds", () => { + const prompt = buildDiscussReportPrompt({ + reportId: "abc123", + isDevBuild: false, + }); + expect(prompt).toContain("posthog-code://inbox/abc123"); + }); + + it("uses the dev deeplink scheme in dev builds", () => { + const prompt = buildDiscussReportPrompt({ + reportId: "abc123", + isDevBuild: true, + }); + expect(prompt).toContain("posthog-code-dev://inbox/abc123"); + }); + + it("falls back to the open-ended readout when no question is given", () => { + const prompt = buildDiscussReportPrompt({ + reportId: "abc123", + isDevBuild: false, + }); + expect(prompt).toContain("give me a brief readout"); + }); + + it("incorporates a trimmed question when provided", () => { + const prompt = buildDiscussReportPrompt({ + reportId: "abc123", + question: " Why is conversion dropping? ", + isDevBuild: false, + }); + expect(prompt).toContain("answer this first: Why is conversion dropping?"); + expect(prompt).not.toContain("brief readout"); + }); + + it("treats a whitespace-only question as no question", () => { + const prompt = buildDiscussReportPrompt({ + reportId: "abc123", + question: " ", + isDevBuild: false, + }); + expect(prompt).toContain("brief readout"); + }); +}); diff --git a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts new file mode 100644 index 000000000..bb4361402 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts @@ -0,0 +1,20 @@ +import { getDeeplinkProtocol } from "@shared/deeplink"; + +interface BuildDiscussReportPromptOptions { + reportId: string; + question?: string; + isDevBuild: boolean; +} + +export function buildDiscussReportPrompt({ + reportId, + question, + isDevBuild, +}: BuildDiscussReportPromptOptions): string { + 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 + ? `${intro} then answer this first: ${trimmedQuestion}` + : `${intro} then give me a brief readout and ask what I want to dig into.`; +} diff --git a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx index a1ac9cd60..801c7f372 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx @@ -1,8 +1,14 @@ import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { UserMessage } from "./UserMessage"; +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + os: { openExternal: { mutate: vi.fn() } }, + }, +})); + describe("UserMessage", () => { it("renders attachment chips for cloud prompts", () => { render( diff --git a/apps/code/src/renderer/stores/navigationStore.test.ts b/apps/code/src/renderer/stores/navigationStore.test.ts index 2bdf5f6a5..0a6584ee8 100644 --- a/apps/code/src/renderer/stores/navigationStore.test.ts +++ b/apps/code/src/renderer/stores/navigationStore.test.ts @@ -110,6 +110,22 @@ describe("navigationStore", () => { expect(getView().taskInputRequestId).toBeTruthy(); }); + it("mints a fresh taskInputRequestId on each navigation with transient state", () => { + getStore().navigateToTaskInput({ + initialPrompt: "Discuss this", + reportAssociation: { reportId: "report-456", title: "Slow checkout" }, + }); + const firstRequestId = getView().taskInputRequestId; + expect(firstRequestId).toBeTruthy(); + + getStore().navigateToInbox(); + getStore().navigateToTaskInput({ + initialPrompt: "Discuss this", + reportAssociation: { reportId: "report-456", title: "Slow checkout" }, + }); + expect(getView().taskInputRequestId).not.toBe(firstRequestId); + }); + it("clears task input report association", () => { getStore().navigateToTaskInput({ initialPrompt: "Fix this report", diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 47ca4f1f9..bb43e334a 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -258,7 +258,6 @@ export const useNavigationStore = create()( navigateToInbox: () => { navigate({ type: "inbox" }); - track(ANALYTICS_EVENTS.INBOX_VIEWED); }, navigateToArchived: () => { diff --git a/apps/code/src/shared/deeplink.ts b/apps/code/src/shared/deeplink.ts index 0da6e8eba..00a81f603 100644 --- a/apps/code/src/shared/deeplink.ts +++ b/apps/code/src/shared/deeplink.ts @@ -7,3 +7,19 @@ export function getDeeplinkProtocol(isDevBuild: boolean): string { ? DEEPLINK_PROTOCOL_DEVELOPMENT : DEEPLINK_PROTOCOL_PRODUCTION; } + +/** True when `href` parses as a PostHog Code deep link (production or dev scheme). */ +export function isPostHogCodeDeeplink( + href: string | undefined, +): href is string { + if (!href) return false; + try { + const protocol = new URL(href).protocol; + return ( + protocol === `${DEEPLINK_PROTOCOL_PRODUCTION}:` || + protocol === `${DEEPLINK_PROTOCOL_DEVELOPMENT}:` + ); + } catch { + return false; + } +} diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index e91f667ee..709ef0a18 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -428,6 +428,7 @@ export type InboxReportActionType = | "create_pr" | "open_pr" | "copy_link" + | "discuss" | "expand_signal" | "collapse_signal" | "expand_signal_section" @@ -501,6 +502,11 @@ export interface InboxReportActionProperties { signal_section?: "relevant_code" | "data_queried"; why_field?: "priority" | "actionability"; task_section?: "research" | "implementation"; + // True when the user submitted Discuss with a first question via the popover. + has_question?: boolean; + // The first question text the user typed before hitting Discuss. Truncated to + // 500 chars to keep event payloads bounded. + question_text?: string; } // Subscription / billing events @@ -597,7 +603,6 @@ export const ANALYTICS_EVENTS = { ONBOARDING_ABANDONED: "Onboarding abandoned", AI_CONSENT_GATE_SHOWN: "Ai consent gate shown", AI_CONSENT_APPROVED: "Ai consent approved", - INBOX_VIEWED: "Inbox viewed", // Setup / onboarding events SETUP_DISCOVERY_STARTED: "Setup discovery started", @@ -704,7 +709,6 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.ONBOARDING_ABANDONED]: OnboardingAbandonedProperties; [ANALYTICS_EVENTS.AI_CONSENT_GATE_SHOWN]: AiConsentGateShownProperties; [ANALYTICS_EVENTS.AI_CONSENT_APPROVED]: never; - [ANALYTICS_EVENTS.INBOX_VIEWED]: never; // Setup / onboarding events [ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED]: SetupDiscoveryStartedProperties;