From 364732cdb56923d7d9eaee9b79117e81b0cc8e2a Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 20 May 2026 11:36:04 +0100 Subject: [PATCH 01/14] feat(code): add Discuss button to inbox reports Opens a chat session pre-loaded with report context and auto-submits the first message so users can talk through a report with the agent without going through the TaskInput screen manually. Falls back to the standard TaskInput with the prompt pre-filled if preconditions (repo, auth, online) don't resolve within 2.5s. --- .../src/renderer/components/MainLayout.tsx | 1 + .../components/detail/ReportDetailPane.tsx | 35 ++ .../task-detail/components/TaskInput.tsx | 482 ++++++++++-------- .../renderer/stores/navigationStore.test.ts | 23 + .../src/renderer/stores/navigationStore.ts | 6 +- 5 files changed, 338 insertions(+), 209 deletions(-) diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index 70b0dee87..ba88103e7 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -151,6 +151,7 @@ export function MainLayout() { reportAssociation={ view.reportAssociation ?? taskInputReportAssociation } + autoSubmit={view.autoSubmit} /> )} 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 e60995f11..431ca8bdb 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, @@ -288,6 +289,29 @@ export function ReportDetailPane({ report, ]); + const handleDiscussReport = useCallback(() => { + const prompt = [ + "Let's discuss this PostHog signal report.", + "", + `Report ID: ${report.id}`, + `Title: ${report.title ?? "Untitled signal"}`, + "", + "Summary:", + report.summary ?? "(no summary available)", + "", + "Use the PostHog inbox MCP tools to fetch full details (signals, artefacts, related tasks) as needed. Then summarise what's going on and ask me what I'd like to dig into.", + ].join("\n"); + navigateToTaskInput({ + initialPrompt: prompt, + initialCloudRepository: effectiveCloudRepository ?? undefined, + reportAssociation: { + reportId: report.id, + title: report.title ?? "Untitled signal", + }, + autoSubmit: true, + }); + }, [navigateToTaskInput, effectiveCloudRepository, report]); + useEffect(() => { if (!canCreateImplementationPr) return; const handler = (e: KeyboardEvent) => { @@ -370,6 +394,17 @@ export function ReportDetailPane({ )} Dismiss + + + {headerImplementationPrUrl ? ( (null); const dragCounterRef = useRef(0); const reportInputHadContentRef = useRef(false); + const hasAutoSubmittedRef = useRef(false); const [editorIsEmpty, setEditorIsEmpty] = useState(true); + const [isAutoSubmitting, setIsAutoSubmitting] = useState(!!autoSubmit); const [isDraggingFile, setIsDraggingFile] = useState(false); const [isCreatingBranch, setIsCreatingBranch] = useState(false); const [selectedBranch, setSelectedBranch] = useState(null); @@ -480,6 +484,43 @@ export function TaskInput({ signalReportId: activeReportAssociation?.reportId, }); + // Reset auto-submit state on each fresh navigation. We key off + // `prefillRequestKey` (a fresh UUID per navigation) so back-to-back Discuss + // clicks each get their own auto-submit attempt even when `autoSubmit` + // itself doesn't change reference. + useEffect(() => { + if (!prefillRequestKey) return; + hasAutoSubmittedRef.current = false; + setIsAutoSubmitting(!!autoSubmit); + }, [prefillRequestKey, autoSubmit]); + + // Fire the first message automatically once preconditions are satisfied. + // Uses `contentOverride` so we don't race the editor hydrating from the + // draft store — `handleSubmit` only requires `canSubmitBase` in that path. + useEffect(() => { + if (!isAutoSubmitting || hasAutoSubmittedRef.current) return; + if (!canSubmit || !initialPrompt) return; + hasAutoSubmittedRef.current = true; + void handleSubmit({ + segments: [{ type: "text", text: initialPrompt }], + }).then((ok) => { + if (!ok) setIsAutoSubmitting(false); + }); + }, [isAutoSubmitting, canSubmit, handleSubmit, initialPrompt]); + + // If preconditions never resolve (no repo, offline, etc.), fall back to the + // normal UI after a short grace period — the prompt stays in the editor so + // the user can submit manually once they pick a repo. + useEffect(() => { + if (!isAutoSubmitting) return; + const timer = setTimeout(() => { + if (!hasAutoSubmittedRef.current) { + setIsAutoSubmitting(false); + } + }, 2500); + return () => clearTimeout(timer); + }, [isAutoSubmitting]); + const handleModeChange = useCallback( (value: string) => { if (modeOption) { @@ -620,228 +661,253 @@ export function TaskInput({ className="relative h-full w-full" > - - - - - - + + + + + Starting discussion... + + {reportAssociation?.title && ( + - {workspaceMode === "worktree" && ( - - )} - - {workspaceMode === "cloud" ? ( - + )} + + + ) : ( + + + + + + + {workspaceMode === "worktree" && ( + - ) : ( - + {workspaceMode === "cloud" ? ( + + ) : ( + + )} + + + {cloudRegion === "dev" && ( + + + + Dev + + )} - + + + } - disabled={ - isCreatingTask || - (workspaceMode === "cloud" && !selectedCloudRepository) + historyButton={ + } - loading={workspaceMode === "cloud" ? false : branchLoading} - workspaceMode={workspaceMode} - selectedBranch={selectedBranch} - onBranchSelect={setSelectedBranch} - busyState={busyState} - cloudBranches={cloudBranches} - cloudBranchesLoading={cloudBranchesLoading} - isRefreshing={cloudBranchesRefreshing} - cloudBranchesFetchingMore={cloudBranchesFetchingMore} - cloudBranchesHasMore={cloudBranchesHasMore} - cloudSearchQuery={cloudBranchSearchQuery} - onCloudPickerClose={handleCloudBranchPickerClose} - onCloudSearchChange={handleCloudBranchSearchChange} - onCloudLoadMore={handleLoadMoreCloudBranches} - onRefresh={ - workspaceMode === "cloud" - ? handleRefreshBranches - : undefined + reasoningSelector={ + !isPreviewLoading && ( + + ) } - anchor={buttonGroupRef} + getPromptHistory={getPromptHistory} + onEmptyChange={handleEditorEmptyChange} + onSubmitClick={handleSubmit} + onSubmit={() => { + if (canSubmit) handleSubmit(); + }} /> - - {cloudRegion === "dev" && ( - - - - Dev - - - )} - - - - - } - historyButton={ - - } - reasoningSelector={ - !isPreviewLoading && ( - - ) - } - getPromptHistory={getPromptHistory} - onEmptyChange={handleEditorEmptyChange} - onSubmitClick={handleSubmit} - onSubmit={() => { - if (canSubmit) handleSubmit(); - }} - /> - {activeReportAssociation && ( -
- - - This task will be associated with report + {activeReportAssociation && ( +
+ + + This task will be associated with report + + - - - - - -
- )} - {effectiveWorkspaceMode === "cloud" && - !isLoadingRepos && - !hasGithubIntegration && ( -
- + + +
)} - - - - - + {effectiveWorkspaceMode === "cloud" && + !isLoadingRepos && + !hasGithubIntegration && ( +
+ +
+ )} + + + + + + )} { expect(getView().taskInputRequestId).toBeTruthy(); }); + it("passes autoSubmit through and mints a fresh request id", () => { + getStore().navigateToTaskInput({ + initialPrompt: "Discuss this", + reportAssociation: { reportId: "report-456", title: "Slow checkout" }, + autoSubmit: true, + }); + + expect(getView()).toMatchObject({ + type: "task-input", + autoSubmit: true, + }); + expect(getView().taskInputRequestId).toBeTruthy(); + + const firstRequestId = getView().taskInputRequestId; + getStore().navigateToInbox(); + getStore().navigateToTaskInput({ + initialPrompt: "Discuss this", + reportAssociation: { reportId: "report-456", title: "Slow checkout" }, + autoSubmit: true, + }); + 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..02c3d1485 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -32,6 +32,7 @@ interface TaskInputNavigationOptions { initialPrompt?: string; initialCloudRepository?: string; reportAssociation?: TaskInputReportAssociation; + autoSubmit?: boolean; } interface ViewState { @@ -43,6 +44,7 @@ interface ViewState { initialPrompt?: string; initialCloudRepository?: string; reportAssociation?: TaskInputReportAssociation; + autoSubmit?: boolean; } interface NavigationStore { @@ -194,7 +196,8 @@ export const useNavigationStore = create()( const hasTransientState = !!options.initialPrompt || !!options.initialCloudRepository || - !!options.reportAssociation; + !!options.reportAssociation || + !!options.autoSubmit; if (options.reportAssociation || options.initialCloudRepository) { set({ taskInputReportAssociation: options.reportAssociation, @@ -207,6 +210,7 @@ export const useNavigationStore = create()( initialPrompt: options.initialPrompt, initialCloudRepository: options.initialCloudRepository, reportAssociation: options.reportAssociation, + autoSubmit: options.autoSubmit, taskInputRequestId: hasTransientState ? (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`) : undefined, From 4e3468aa1973af977669ffebf55f5d93f9b2bdab Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 20 May 2026 16:20:33 +0100 Subject: [PATCH 02/14] fix(code): align discuss tooltip styling --- .../components/detail/ReportDetailPane.tsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) 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 431ca8bdb..1f8a0a760 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -394,17 +394,16 @@ export function ReportDetailPane({ )} Dismiss - - - + {headerImplementationPrUrl ? ( Date: Wed, 20 May 2026 16:47:22 +0100 Subject: [PATCH 03/14] fix(code): allow inbox discuss auto-submit while loading --- .../task-detail/components/TaskInput.tsx | 45 ++++++++++--------- .../task-detail/hooks/useTaskCreation.ts | 11 +++-- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 334c7e0d6..34f77c845 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -463,26 +463,27 @@ export function TaskInput({ ? selectedBranch : null; - const { isCreatingTask, canSubmit, handleSubmit } = useTaskCreation({ - editorRef, - selectedDirectory, - selectedRepository: selectedCloudRepository, - githubUserIntegrationId: selectedGithubUserIntegrationId, - workspaceMode: effectiveWorkspaceMode, - branch: branchForTaskCreation, - editorIsEmpty, - adapter, - executionMode: currentExecutionMode, - model: currentModel, - reasoningLevel: currentReasoningLevel, - onTaskCreated, - environmentId: selectedEnvironment, - sandboxEnvironmentId: - effectiveWorkspaceMode === "cloud" && selectedCloudEnvId - ? selectedCloudEnvId - : undefined, - signalReportId: activeReportAssociation?.reportId, - }); + const { isCreatingTask, canSubmitBase, canSubmit, handleSubmit } = + useTaskCreation({ + editorRef, + selectedDirectory, + selectedRepository: selectedCloudRepository, + githubUserIntegrationId: selectedGithubUserIntegrationId, + workspaceMode: effectiveWorkspaceMode, + branch: branchForTaskCreation, + editorIsEmpty, + adapter, + executionMode: currentExecutionMode, + model: currentModel, + reasoningLevel: currentReasoningLevel, + onTaskCreated, + environmentId: selectedEnvironment, + sandboxEnvironmentId: + effectiveWorkspaceMode === "cloud" && selectedCloudEnvId + ? selectedCloudEnvId + : undefined, + signalReportId: activeReportAssociation?.reportId, + }); // Reset auto-submit state on each fresh navigation. We key off // `prefillRequestKey` (a fresh UUID per navigation) so back-to-back Discuss @@ -499,14 +500,14 @@ export function TaskInput({ // draft store — `handleSubmit` only requires `canSubmitBase` in that path. useEffect(() => { if (!isAutoSubmitting || hasAutoSubmittedRef.current) return; - if (!canSubmit || !initialPrompt) return; + if (!canSubmitBase || !initialPrompt) return; hasAutoSubmittedRef.current = true; void handleSubmit({ segments: [{ type: "text", text: initialPrompt }], }).then((ok) => { if (!ok) setIsAutoSubmitting(false); }); - }, [isAutoSubmitting, canSubmit, handleSubmit, initialPrompt]); + }, [isAutoSubmitting, canSubmitBase, handleSubmit, initialPrompt]); // If preconditions never resolve (no repo, offline, etc.), fall back to the // normal UI after a short grace period — the prompt stays in the editor so diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts index 061d40a91..e673e6bd5 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -48,6 +48,7 @@ interface UseTaskCreationOptions { interface UseTaskCreationReturn { isCreatingTask: boolean; + canSubmitBase: boolean; canSubmit: boolean; handleSubmit: (contentOverride?: EditorContent) => Promise; } @@ -204,17 +205,18 @@ export function useTaskCreation({ const handleSubmit = useCallback( async (contentOverride?: EditorContent): Promise => { const editor = editorRef.current; - if (!editor) return false; + if (!editor && !contentOverride) return false; const allowSubmit = contentOverride ? canSubmitBase : canSubmit; if (!allowSubmit) return false; setIsCreatingTask(true); try { - const content = contentOverride ?? editor.getContent(); + const content = contentOverride ?? editor?.getContent(); + if (!content) return false; if (!contentOverride) { - const plainText = editor.getText()?.trim(); + const plainText = editor?.getText()?.trim(); if (plainText) { useTaskInputHistoryStore.getState().addPrompt(plainText); } @@ -253,7 +255,7 @@ export function useTaskCreation({ } useTourStore.getState().completeTour(createFirstTaskTour.id); if (!contentOverride) { - editor.clear(); + editor?.clear(); } }); @@ -306,6 +308,7 @@ export function useTaskCreation({ return { isCreatingTask, + canSubmitBase, canSubmit, handleSubmit, }; From 5a8bb2bca32f8e1ff2205501768beddb575ab15b Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 20 May 2026 16:53:49 +0100 Subject: [PATCH 04/14] fix(code): apply discuss mode override before autosubmit --- .../task-detail/components/TaskInput.tsx | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 34f77c845..6844a15fd 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -20,7 +20,10 @@ import { resolveAndAttachDroppedFiles } from "@features/message-editor/utils/per import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; -import { getCurrentModeFromConfigOptions } from "@features/sessions/stores/sessionStore"; +import { + flattenSelectOptions, + getCurrentModeFromConfigOptions, +} from "@features/sessions/stores/sessionStore"; import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useSetupStore } from "@features/setup/stores/setupStore"; @@ -457,6 +460,19 @@ export function TaskInput({ modeFallback; const currentReasoningLevel = thoughtOption?.type === "select" ? thoughtOption.currentValue : undefined; + const isTaskConfigReady = + !isPreviewLoading && + (effectiveWorkspaceMode !== "cloud" || typeof currentModel === "string"); + + useEffect(() => { + if (!initialExecutionMode || modeOption?.type !== "select") return; + if (modeOption.currentValue === initialExecutionMode) return; + const hasInitialMode = flattenSelectOptions(modeOption.options).some( + (option) => option.value === initialExecutionMode, + ); + if (!hasInitialMode) return; + setConfigOption(modeOption.id, initialExecutionMode); + }, [initialExecutionMode, modeOption, setConfigOption]); const branchForTaskCreation = effectiveWorkspaceMode === "worktree" || effectiveWorkspaceMode === "cloud" @@ -500,14 +516,20 @@ export function TaskInput({ // draft store — `handleSubmit` only requires `canSubmitBase` in that path. useEffect(() => { if (!isAutoSubmitting || hasAutoSubmittedRef.current) return; - if (!canSubmitBase || !initialPrompt) return; + if (!canSubmitBase || !isTaskConfigReady || !initialPrompt) return; hasAutoSubmittedRef.current = true; void handleSubmit({ segments: [{ type: "text", text: initialPrompt }], }).then((ok) => { if (!ok) setIsAutoSubmitting(false); }); - }, [isAutoSubmitting, canSubmitBase, handleSubmit, initialPrompt]); + }, [ + isAutoSubmitting, + canSubmitBase, + isTaskConfigReady, + handleSubmit, + initialPrompt, + ]); // If preconditions never resolve (no repo, offline, etc.), fall back to the // normal UI after a short grace period — the prompt stays in the editor so From 6853ee91f317b180605cee4b2b4ef10de671cb3a Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 20 May 2026 17:15:52 +0100 Subject: [PATCH 05/14] feat(code): add discuss prompt popover --- .../editor/components/MarkdownRenderer.tsx | 25 +++- .../components/detail/ReportDetailPane.tsx | 127 +++++++++++++----- 2 files changed, 119 insertions(+), 33 deletions(-) diff --git a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx index 52c1bc5df..85cbec17e 100644 --- a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx @@ -4,9 +4,10 @@ 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 { 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 +25,21 @@ function preprocessMarkdown(content: string): string { return content.replace(/\n([^\n].*)\n(---+|___+|\*\*\*+)\n/g, "\n$1\n\n$2\n"); } +function isPostHogCodeDeeplink(href: string | undefined): href is string { + if (!href) return false; + try { + const protocol = new URL(href).protocol; + return protocol === "posthog-code:" || protocol === "posthog-code-dev:"; + } catch { + return false; + } +} + +function markdownUrlTransform(value: string): string { + if (isPostHogCodeDeeplink(value)) return value; + return defaultUrlTransform(value); +} + const HeadingText = ({ children }: { children: React.ReactNode }) => ( {children} @@ -92,9 +108,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 +211,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 1f8a0a760..3d9a57fa0 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -27,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"; @@ -50,6 +52,7 @@ import { useNavigationStore } from "@stores/navigationStore"; import { useQuery } from "@tanstack/react-query"; import { isMac } from "@utils/platform"; import { + type FormEvent, type ReactNode, useCallback, useEffect, @@ -169,6 +172,8 @@ export function ReportDetailPane({ suppressDisabledReason, isDismissMutationPending = false, }: ReportDetailPaneProps) { + const [discussQuestion, setDiscussQuestion] = useState(""); + const [discussQuestionOpen, setDiscussQuestionOpen] = useState(false); const { data: me } = useMeQuery(); // ── Report data ───────────────────────────────────────────────────────── @@ -289,28 +294,35 @@ export function ReportDetailPane({ report, ]); - const handleDiscussReport = useCallback(() => { - const prompt = [ - "Let's discuss this PostHog signal report.", - "", - `Report ID: ${report.id}`, - `Title: ${report.title ?? "Untitled signal"}`, - "", - "Summary:", - report.summary ?? "(no summary available)", - "", - "Use the PostHog inbox MCP tools to fetch full details (signals, artefacts, related tasks) as needed. Then summarise what's going on and ask me what I'd like to dig into.", - ].join("\n"); - navigateToTaskInput({ - initialPrompt: prompt, - initialCloudRepository: effectiveCloudRepository ?? undefined, - reportAssociation: { - reportId: report.id, - title: report.title ?? "Untitled signal", - }, - autoSubmit: true, - }); - }, [navigateToTaskInput, effectiveCloudRepository, report]); + const handleDiscussReport = useCallback( + (question?: string) => { + const trimmedQuestion = question?.trim(); + const reportLink = `${getDeeplinkProtocol(import.meta.env.DEV)}://inbox/${report.id}`; + const prompt = trimmedQuestion + ? `Discuss PostHog inbox report ${report.id} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, then answer this first: ${trimmedQuestion}` + : `Discuss PostHog inbox report ${report.id} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, then give me a brief readout and ask what I want to dig into.`; + setDiscussQuestionOpen(false); + navigateToTaskInput({ + initialPrompt: prompt, + initialCloudRepository: effectiveCloudRepository ?? undefined, + reportAssociation: { + reportId: report.id, + title: report.title ?? "Untitled signal", + }, + initialExecutionMode: "auto", + autoSubmit: true, + }); + }, + [navigateToTaskInput, effectiveCloudRepository, report], + ); + + const handleDiscussSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + handleDiscussReport(discussQuestion); + }, + [discussQuestion, handleDiscussReport], + ); useEffect(() => { if (!canCreateImplementationPr) return; @@ -394,16 +406,67 @@ export function ReportDetailPane({ )} Dismiss - + + + + + + + +
+ setDiscussQuestion(event.target.value)} + /> + + + + + +
+
+
{headerImplementationPrUrl ? ( Date: Wed, 20 May 2026 17:17:38 +0100 Subject: [PATCH 06/14] fix(code): clear autosubmitted task prompt draft --- .../features/task-detail/components/TaskInput.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 6844a15fd..7b9177b0b 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -521,7 +521,14 @@ export function TaskInput({ void handleSubmit({ segments: [{ type: "text", text: initialPrompt }], }).then((ok) => { - if (!ok) setIsAutoSubmitting(false); + if (!ok) { + setIsAutoSubmitting(false); + return; + } + const draftActions = useDraftStore.getState().actions; + draftActions.clearPendingContent(sessionId); + draftActions.setDraft(sessionId, null); + editorRef.current?.clear(); }); }, [ isAutoSubmitting, @@ -529,6 +536,7 @@ export function TaskInput({ isTaskConfigReady, handleSubmit, initialPrompt, + sessionId, ]); // If preconditions never resolve (no repo, offline, etc.), fall back to the From c075c7c8abce9b4460e2e5d6ccc9ecac41f0db9d Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 20 May 2026 20:02:51 +0100 Subject: [PATCH 07/14] fix(code): preserve discuss auto mode --- apps/code/src/renderer/components/MainLayout.tsx | 1 + .../renderer/features/task-detail/components/TaskInput.tsx | 3 +++ apps/code/src/renderer/stores/navigationStore.test.ts | 2 ++ apps/code/src/renderer/stores/navigationStore.ts | 6 +++++- 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index ba88103e7..19234f3ae 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -151,6 +151,7 @@ export function MainLayout() { reportAssociation={ view.reportAssociation ?? taskInputReportAssociation } + initialExecutionMode={view.initialExecutionMode} autoSubmit={view.autoSubmit} /> )} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 7b9177b0b..97df3533b 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -43,6 +43,7 @@ import { useAuthStore } from "@renderer/features/auth/stores/authStore"; import { useDraftStore } from "@renderer/features/message-editor/stores/draftStore"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; +import type { ExecutionMode } from "@shared/types"; import { type TaskInputReportAssociation, useNavigationStore, @@ -64,6 +65,7 @@ interface TaskInputProps { initialPromptKey?: string; initialCloudRepository?: string; reportAssociation?: TaskInputReportAssociation; + initialExecutionMode?: ExecutionMode; autoSubmit?: boolean; } @@ -74,6 +76,7 @@ export function TaskInput({ initialPromptKey, initialCloudRepository, reportAssociation, + initialExecutionMode, autoSubmit, }: TaskInputProps = {}) { const { cloudRegion } = useAuthStore(); diff --git a/apps/code/src/renderer/stores/navigationStore.test.ts b/apps/code/src/renderer/stores/navigationStore.test.ts index 08b158b95..cc77b3744 100644 --- a/apps/code/src/renderer/stores/navigationStore.test.ts +++ b/apps/code/src/renderer/stores/navigationStore.test.ts @@ -114,11 +114,13 @@ describe("navigationStore", () => { getStore().navigateToTaskInput({ initialPrompt: "Discuss this", reportAssociation: { reportId: "report-456", title: "Slow checkout" }, + initialExecutionMode: "auto", autoSubmit: true, }); expect(getView()).toMatchObject({ type: "task-input", + initialExecutionMode: "auto", autoSubmit: true, }); expect(getView().taskInputRequestId).toBeTruthy(); diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 02c3d1485..a3d3a764e 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -1,7 +1,7 @@ import { foldersApi } from "@features/folders/hooks/useFolders"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { getTaskDirectory } from "@hooks/useRepositoryDirectory"; -import type { Task } from "@shared/types"; +import type { ExecutionMode, Task } from "@shared/types"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { track } from "@utils/analytics"; import { electronStorage } from "@utils/electronStorage"; @@ -32,6 +32,7 @@ interface TaskInputNavigationOptions { initialPrompt?: string; initialCloudRepository?: string; reportAssociation?: TaskInputReportAssociation; + initialExecutionMode?: ExecutionMode; autoSubmit?: boolean; } @@ -44,6 +45,7 @@ interface ViewState { initialPrompt?: string; initialCloudRepository?: string; reportAssociation?: TaskInputReportAssociation; + initialExecutionMode?: ExecutionMode; autoSubmit?: boolean; } @@ -197,6 +199,7 @@ export const useNavigationStore = create()( !!options.initialPrompt || !!options.initialCloudRepository || !!options.reportAssociation || + !!options.initialExecutionMode || !!options.autoSubmit; if (options.reportAssociation || options.initialCloudRepository) { set({ @@ -210,6 +213,7 @@ export const useNavigationStore = create()( initialPrompt: options.initialPrompt, initialCloudRepository: options.initialCloudRepository, reportAssociation: options.reportAssociation, + initialExecutionMode: options.initialExecutionMode, autoSubmit: options.autoSubmit, taskInputRequestId: hasTransientState ? (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`) From 3f9a19167a087fb6f77e1ad9aa6cb4bd93daf01e Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 20 May 2026 20:05:00 +0100 Subject: [PATCH 08/14] fix(code): use discuss execution mode for autosubmit --- .../src/renderer/features/task-detail/components/TaskInput.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 97df3533b..48f52aaad 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -459,6 +459,7 @@ export function TaskInput({ ? (lastUsedInitialTaskMode ?? adapterDefault) : adapterDefault; const currentExecutionMode = + initialExecutionMode ?? getCurrentModeFromConfigOptions(modeOption ? [modeOption] : undefined) ?? modeFallback; const currentReasoningLevel = From 3b4a8669d6cad7e058e8628c58ea52a3e0cc5342 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 21 May 2026 12:03:03 +0100 Subject: [PATCH 09/14] fix(inbox): instrument Discuss action and unblock markdown test - Add 'discuss' to InboxReportActionType with optional has_question flag - Fire INBOX_REPORT_ACTION from the Discuss handler (popover + button) - Defer trpcClient load in MarkdownRenderer link onClick so UserMessage.test doesn't fail at module init without an electronTRPC global --- .../features/editor/components/MarkdownRenderer.tsx | 7 +++++-- .../features/inbox/components/detail/ReportDetailPane.tsx | 3 ++- apps/code/src/shared/types/analytics.ts | 3 +++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx index 85cbec17e..77e7656ff 100644 --- a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx @@ -4,7 +4,6 @@ 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 { memo, useMemo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; @@ -115,7 +114,11 @@ export const baseComponents: Components = { onClick={(event) => { if (!isDeeplink || !href) return; event.preventDefault(); - void trpcClient.os.openExternal.mutate({ url: href }); + // Lazy-load the tRPC client so tests that render markdown don't need + // an electronTRPC global at module init time. + void import("@renderer/trpc/client").then(({ trpcClient }) => + trpcClient.os.openExternal.mutate({ url: href }), + ); }} target="_blank" rel="noopener noreferrer" 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 cc318cc6f..46929a417 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -379,6 +379,7 @@ export function ReportDetailPane({ const prompt = trimmedQuestion ? `Discuss PostHog inbox report ${report.id} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, then answer this first: ${trimmedQuestion}` : `Discuss PostHog inbox report ${report.id} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, then give me a brief readout and ask what I want to dig into.`; + fireDetailAction("discuss", { has_question: !!trimmedQuestion }); setDiscussQuestionOpen(false); navigateToTaskInput({ initialPrompt: prompt, @@ -391,7 +392,7 @@ export function ReportDetailPane({ autoSubmit: true, }); }, - [navigateToTaskInput, effectiveCloudRepository, report], + [navigateToTaskInput, effectiveCloudRepository, report, fireDetailAction], ); const handleDiscussSubmit = useCallback( diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 72fe93df6..da60f43f2 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,8 @@ 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; } // Subscription / billing events From 46e00151cb975f2b236b79a085f6f1bdc644b997 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 21 May 2026 12:07:25 +0100 Subject: [PATCH 10/14] feat(inbox): capture Discuss question text in analytics Add optional question_text field (truncated to 500 chars) to InboxReportActionProperties so we can see what users are asking when they submit Discuss via the popover. --- .../features/inbox/components/detail/ReportDetailPane.tsx | 7 ++++++- apps/code/src/shared/types/analytics.ts | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 46929a417..61bcba880 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -379,7 +379,12 @@ export function ReportDetailPane({ const prompt = trimmedQuestion ? `Discuss PostHog inbox report ${report.id} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, then answer this first: ${trimmedQuestion}` : `Discuss PostHog inbox report ${report.id} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, then give me a brief readout and ask what I want to dig into.`; - fireDetailAction("discuss", { has_question: !!trimmedQuestion }); + fireDetailAction("discuss", { + has_question: !!trimmedQuestion, + question_text: trimmedQuestion + ? trimmedQuestion.slice(0, 500) + : undefined, + }); setDiscussQuestionOpen(false); navigateToTaskInput({ initialPrompt: prompt, diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index da60f43f2..709ef0a18 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -504,6 +504,9 @@ export interface InboxReportActionProperties { 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 From 75662a8754272541c16417c8556596628eb27028 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 21 May 2026 13:43:09 +0100 Subject: [PATCH 11/14] refactor(deeplink): hoist isPostHogCodeDeeplink to shared/deeplink Move the helper out of MarkdownRenderer so other renderer surfaces can reuse it instead of redefining the protocol check. --- .../editor/components/MarkdownRenderer.tsx | 11 +---------- apps/code/src/shared/deeplink.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx index 77e7656ff..6eb5a8eed 100644 --- a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx @@ -4,6 +4,7 @@ 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 { isPostHogCodeDeeplink } from "@shared/deeplink"; import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; @@ -24,16 +25,6 @@ function preprocessMarkdown(content: string): string { return content.replace(/\n([^\n].*)\n(---+|___+|\*\*\*+)\n/g, "\n$1\n\n$2\n"); } -function isPostHogCodeDeeplink(href: string | undefined): href is string { - if (!href) return false; - try { - const protocol = new URL(href).protocol; - return protocol === "posthog-code:" || protocol === "posthog-code-dev:"; - } catch { - return false; - } -} - function markdownUrlTransform(value: string): string { if (isPostHogCodeDeeplink(value)) return value; return defaultUrlTransform(value); 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; + } +} From 32fe704fedcbc064114481d284a340e8b1be7200 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 21 May 2026 13:46:14 +0100 Subject: [PATCH 12/14] refactor(inbox): extract Discuss prompt construction to a builder Pull the inline prompt template out of ReportDetailPane into a buildDiscussReportPrompt util with its own tests, so the Discuss prompt copy lives in one place and is easy to tweak. --- .../components/detail/ReportDetailPane.tsx | 10 ++-- .../utils/buildDiscussReportPrompt.test.ts | 47 +++++++++++++++++++ .../inbox/utils/buildDiscussReportPrompt.ts | 20 ++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts create mode 100644 apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts 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 61bcba880..85698c3db 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -63,6 +63,7 @@ import { useState, } from "react"; import { toast } from "sonner"; +import { buildDiscussReportPrompt } from "../../utils/buildDiscussReportPrompt"; import { ReportImplementationPrLink } from "../utils/ReportImplementationPrLink"; import { SignalReportActionabilityBadge } from "../utils/SignalReportActionabilityBadge"; import { SignalReportPriorityBadge } from "../utils/SignalReportPriorityBadge"; @@ -375,10 +376,11 @@ export function ReportDetailPane({ const handleDiscussReport = useCallback( (question?: string) => { const trimmedQuestion = question?.trim(); - const reportLink = `${getDeeplinkProtocol(import.meta.env.DEV)}://inbox/${report.id}`; - const prompt = trimmedQuestion - ? `Discuss PostHog inbox report ${report.id} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, then answer this first: ${trimmedQuestion}` - : `Discuss PostHog inbox report ${report.id} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, then give me a brief readout and ask what I want to dig into.`; + const prompt = buildDiscussReportPrompt({ + reportId: report.id, + question: trimmedQuestion, + isDevBuild: import.meta.env.DEV, + }); fireDetailAction("discuss", { has_question: !!trimmedQuestion, question_text: trimmedQuestion 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.`; +} From 9615f397cc21562a68dd9e410c3809963475a7a7 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 21 May 2026 14:10:30 +0100 Subject: [PATCH 13/14] refactor(inbox): create Discuss tasks directly without TaskInput Replace the auto-submit dance that routed Discuss clicks through TaskInput (loading placeholder, three effects, 2.5s fallback timeout, canSubmitBase workaround) with a dedicated useDiscussReport hook that calls taskService.createTask straight from the inbox detail pane. The Discuss button now shows an in-place spinner plus a loading toast while the task is created; on success we jump to the task detail page, on failure we surface a toast and stay on the inbox. TaskInput drops the autoSubmit / initialExecutionMode props and the associated render branch, navigationStore drops the matching transient view fields, and useTaskCreation reverts to requiring a mounted editor. --- .../src/renderer/components/MainLayout.tsx | 2 - .../components/detail/ReportDetailPane.tsx | 36 +- .../features/inbox/hooks/useDiscussReport.ts | 202 +++++++ .../task-detail/components/TaskInput.tsx | 559 +++++++----------- .../task-detail/hooks/useTaskCreation.ts | 11 +- .../renderer/stores/navigationStore.test.ts | 15 +- .../src/renderer/stores/navigationStore.ts | 12 +- 7 files changed, 457 insertions(+), 380 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index 19234f3ae..70b0dee87 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -151,8 +151,6 @@ export function MainLayout() { reportAssociation={ view.reportAssociation ?? taskInputReportAssociation } - initialExecutionMode={view.initialExecutionMode} - autoSubmit={view.autoSubmit} /> )} 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 85698c3db..5a2e0d13f 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -63,7 +63,7 @@ import { useState, } from "react"; import { toast } from "sonner"; -import { buildDiscussReportPrompt } from "../../utils/buildDiscussReportPrompt"; +import { useDiscussReport } from "../../hooks/useDiscussReport"; import { ReportImplementationPrLink } from "../utils/ReportImplementationPrLink"; import { SignalReportActionabilityBadge } from "../utils/SignalReportActionabilityBadge"; import { SignalReportPriorityBadge } from "../utils/SignalReportPriorityBadge"; @@ -373,14 +373,15 @@ export function ReportDetailPane({ fireDetailAction, ]); + const { discussReport, isDiscussing } = useDiscussReport({ + reportId: report.id, + reportTitle: report.title, + cloudRepository: effectiveCloudRepository, + }); + const handleDiscussReport = useCallback( - (question?: string) => { + async (question?: string) => { const trimmedQuestion = question?.trim(); - const prompt = buildDiscussReportPrompt({ - reportId: report.id, - question: trimmedQuestion, - isDevBuild: import.meta.env.DEV, - }); fireDetailAction("discuss", { has_question: !!trimmedQuestion, question_text: trimmedQuestion @@ -388,18 +389,9 @@ export function ReportDetailPane({ : undefined, }); setDiscussQuestionOpen(false); - navigateToTaskInput({ - initialPrompt: prompt, - initialCloudRepository: effectiveCloudRepository ?? undefined, - reportAssociation: { - reportId: report.id, - title: report.title ?? "Untitled signal", - }, - initialExecutionMode: "auto", - autoSubmit: true, - }); + await discussReport(trimmedQuestion); }, - [navigateToTaskInput, effectiveCloudRepository, report, fireDetailAction], + [discussReport, fireDetailAction], ); const handleDiscussSubmit = useCallback( @@ -521,9 +513,14 @@ export function ReportDetailPane({ variant="soft" className="gap-1 rounded-r-none text-[12px]" tooltipContent="Open a chat session about this report" + disabled={isDiscussing} onClick={() => handleDiscussReport()} > - + {isDiscussing ? ( + + ) : ( + + )} Discuss diff --git a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts new file mode 100644 index 000000000..b81a1e28c --- /dev/null +++ b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts @@ -0,0 +1,202 @@ +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 { trpcClient } from "@renderer/trpc/client"; +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 { buildDiscussReportPrompt } from "../utils/buildDiscussReportPrompt"; + +const log = logger.scope("discuss-report"); + +interface UseDiscussReportOptions { + reportId: string; + reportTitle: string | null; + cloudRepository: string | null; +} + +interface UseDiscussReportReturn { + /** Create a Discuss task for the report and navigate to it on success. */ + discussReport: (question?: string) => 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/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 48f52aaad..eb00b588c 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -20,10 +20,7 @@ import { resolveAndAttachDroppedFiles } from "@features/message-editor/utils/per import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; -import { - flattenSelectOptions, - getCurrentModeFromConfigOptions, -} from "@features/sessions/stores/sessionStore"; +import { getCurrentModeFromConfigOptions } from "@features/sessions/stores/sessionStore"; import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useSetupStore } from "@features/setup/stores/setupStore"; @@ -38,12 +35,11 @@ import { } from "@hooks/useIntegrations"; import { X } from "@phosphor-icons/react"; import { ButtonGroup } from "@posthog/quill"; -import { Flex, Spinner, Text, Tooltip } from "@radix-ui/themes"; +import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { useAuthStore } from "@renderer/features/auth/stores/authStore"; import { useDraftStore } from "@renderer/features/message-editor/stores/draftStore"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; -import type { ExecutionMode } from "@shared/types"; import { type TaskInputReportAssociation, useNavigationStore, @@ -65,8 +61,6 @@ interface TaskInputProps { initialPromptKey?: string; initialCloudRepository?: string; reportAssociation?: TaskInputReportAssociation; - initialExecutionMode?: ExecutionMode; - autoSubmit?: boolean; } export function TaskInput({ @@ -76,8 +70,6 @@ export function TaskInput({ initialPromptKey, initialCloudRepository, reportAssociation, - initialExecutionMode, - autoSubmit, }: TaskInputProps = {}) { const { cloudRegion } = useAuthStore(); const trpcReact = useTRPC(); @@ -111,10 +103,8 @@ export function TaskInput({ const buttonGroupRef = useRef(null); const dragCounterRef = useRef(0); const reportInputHadContentRef = useRef(false); - const hasAutoSubmittedRef = useRef(false); const [editorIsEmpty, setEditorIsEmpty] = useState(true); - const [isAutoSubmitting, setIsAutoSubmitting] = useState(!!autoSubmit); const [isDraggingFile, setIsDraggingFile] = useState(false); const [isCreatingBranch, setIsCreatingBranch] = useState(false); const [selectedBranch, setSelectedBranch] = useState(null); @@ -459,102 +449,36 @@ export function TaskInput({ ? (lastUsedInitialTaskMode ?? adapterDefault) : adapterDefault; const currentExecutionMode = - initialExecutionMode ?? getCurrentModeFromConfigOptions(modeOption ? [modeOption] : undefined) ?? modeFallback; const currentReasoningLevel = thoughtOption?.type === "select" ? thoughtOption.currentValue : undefined; - const isTaskConfigReady = - !isPreviewLoading && - (effectiveWorkspaceMode !== "cloud" || typeof currentModel === "string"); - - useEffect(() => { - if (!initialExecutionMode || modeOption?.type !== "select") return; - if (modeOption.currentValue === initialExecutionMode) return; - const hasInitialMode = flattenSelectOptions(modeOption.options).some( - (option) => option.value === initialExecutionMode, - ); - if (!hasInitialMode) return; - setConfigOption(modeOption.id, initialExecutionMode); - }, [initialExecutionMode, modeOption, setConfigOption]); const branchForTaskCreation = effectiveWorkspaceMode === "worktree" || effectiveWorkspaceMode === "cloud" ? selectedBranch : null; - const { isCreatingTask, canSubmitBase, canSubmit, handleSubmit } = - useTaskCreation({ - editorRef, - selectedDirectory, - selectedRepository: selectedCloudRepository, - githubUserIntegrationId: selectedGithubUserIntegrationId, - workspaceMode: effectiveWorkspaceMode, - branch: branchForTaskCreation, - editorIsEmpty, - adapter, - executionMode: currentExecutionMode, - model: currentModel, - reasoningLevel: currentReasoningLevel, - onTaskCreated, - environmentId: selectedEnvironment, - sandboxEnvironmentId: - effectiveWorkspaceMode === "cloud" && selectedCloudEnvId - ? selectedCloudEnvId - : undefined, - signalReportId: activeReportAssociation?.reportId, - }); - - // Reset auto-submit state on each fresh navigation. We key off - // `prefillRequestKey` (a fresh UUID per navigation) so back-to-back Discuss - // clicks each get their own auto-submit attempt even when `autoSubmit` - // itself doesn't change reference. - useEffect(() => { - if (!prefillRequestKey) return; - hasAutoSubmittedRef.current = false; - setIsAutoSubmitting(!!autoSubmit); - }, [prefillRequestKey, autoSubmit]); - - // Fire the first message automatically once preconditions are satisfied. - // Uses `contentOverride` so we don't race the editor hydrating from the - // draft store — `handleSubmit` only requires `canSubmitBase` in that path. - useEffect(() => { - if (!isAutoSubmitting || hasAutoSubmittedRef.current) return; - if (!canSubmitBase || !isTaskConfigReady || !initialPrompt) return; - hasAutoSubmittedRef.current = true; - void handleSubmit({ - segments: [{ type: "text", text: initialPrompt }], - }).then((ok) => { - if (!ok) { - setIsAutoSubmitting(false); - return; - } - const draftActions = useDraftStore.getState().actions; - draftActions.clearPendingContent(sessionId); - draftActions.setDraft(sessionId, null); - editorRef.current?.clear(); - }); - }, [ - isAutoSubmitting, - canSubmitBase, - isTaskConfigReady, - handleSubmit, - initialPrompt, - sessionId, - ]); - - // If preconditions never resolve (no repo, offline, etc.), fall back to the - // normal UI after a short grace period — the prompt stays in the editor so - // the user can submit manually once they pick a repo. - useEffect(() => { - if (!isAutoSubmitting) return; - const timer = setTimeout(() => { - if (!hasAutoSubmittedRef.current) { - setIsAutoSubmitting(false); - } - }, 2500); - return () => clearTimeout(timer); - }, [isAutoSubmitting]); + const { isCreatingTask, canSubmit, handleSubmit } = useTaskCreation({ + editorRef, + selectedDirectory, + selectedRepository: selectedCloudRepository, + githubUserIntegrationId: selectedGithubUserIntegrationId, + workspaceMode: effectiveWorkspaceMode, + branch: branchForTaskCreation, + editorIsEmpty, + adapter, + executionMode: currentExecutionMode, + model: currentModel, + reasoningLevel: currentReasoningLevel, + onTaskCreated, + environmentId: selectedEnvironment, + sandboxEnvironmentId: + effectiveWorkspaceMode === "cloud" && selectedCloudEnvId + ? selectedCloudEnvId + : undefined, + signalReportId: activeReportAssociation?.reportId, + }); const handleModeChange = useCallback( (value: string) => { @@ -696,253 +620,228 @@ export function TaskInput({ className="relative h-full w-full" > - {isAutoSubmitting ? ( - - - - - - Starting discussion... - - {reportAssociation?.title && ( - + + + + + - {reportAssociation.title} - - )} - - - ) : ( - - - - - - + {workspaceMode === "worktree" && ( + - {workspaceMode === "worktree" && ( - - )} - - {workspaceMode === "cloud" ? ( - - ) : ( - - )} - + {workspaceMode === "cloud" ? ( + + ) : ( + - - {cloudRegion === "dev" && ( - - - - Dev - - )} - - - - + currentBranch={currentBranch} + defaultBranch={ + workspaceMode === "cloud" + ? cloudDefaultBranch + : defaultBranch } - historyButton={ - + disabled={ + isCreatingTask || + (workspaceMode === "cloud" && !selectedCloudRepository) } - reasoningSelector={ - !isPreviewLoading && ( - - ) + loading={workspaceMode === "cloud" ? false : branchLoading} + workspaceMode={workspaceMode} + selectedBranch={selectedBranch} + onBranchSelect={setSelectedBranch} + busyState={busyState} + cloudBranches={cloudBranches} + cloudBranchesLoading={cloudBranchesLoading} + isRefreshing={cloudBranchesRefreshing} + cloudBranchesFetchingMore={cloudBranchesFetchingMore} + cloudBranchesHasMore={cloudBranchesHasMore} + cloudSearchQuery={cloudBranchSearchQuery} + onCloudPickerClose={handleCloudBranchPickerClose} + onCloudSearchChange={handleCloudBranchSearchChange} + onCloudLoadMore={handleLoadMoreCloudBranches} + onRefresh={ + workspaceMode === "cloud" + ? handleRefreshBranches + : undefined } - getPromptHistory={getPromptHistory} - onEmptyChange={handleEditorEmptyChange} - onSubmitClick={handleSubmit} - onSubmit={() => { - if (canSubmit) handleSubmit(); - }} + anchor={buttonGroupRef} /> - {activeReportAssociation && ( -
- - - This task will be associated with report - - + + {cloudRegion === "dev" && ( + + + + Dev + + + )} + + + + + } + historyButton={ + + } + reasoningSelector={ + !isPreviewLoading && ( + + ) + } + getPromptHistory={getPromptHistory} + onEmptyChange={handleEditorEmptyChange} + onSubmitClick={handleSubmit} + onSubmit={() => { + if (canSubmit) handleSubmit(); + }} + /> + {activeReportAssociation && ( +
+ + + This task will be associated with report - - - + + + + + +
+ )} + {effectiveWorkspaceMode === "cloud" && + !isLoadingRepos && + !hasGithubIntegration && ( +
+
)} - {effectiveWorkspaceMode === "cloud" && - !isLoadingRepos && - !hasGithubIntegration && ( -
- -
- )} - -
- - - - )} + + + + + Promise; } @@ -205,18 +204,17 @@ export function useTaskCreation({ const handleSubmit = useCallback( async (contentOverride?: EditorContent): Promise => { const editor = editorRef.current; - if (!editor && !contentOverride) return false; + if (!editor) return false; const allowSubmit = contentOverride ? canSubmitBase : canSubmit; if (!allowSubmit) return false; setIsCreatingTask(true); try { - const content = contentOverride ?? editor?.getContent(); - if (!content) return false; + const content = contentOverride ?? editor.getContent(); if (!contentOverride) { - const plainText = editor?.getText()?.trim(); + const plainText = editor.getText()?.trim(); if (plainText) { useTaskInputHistoryStore.getState().addPrompt(plainText); } @@ -255,7 +253,7 @@ export function useTaskCreation({ } useTourStore.getState().completeTour(createFirstTaskTour.id); if (!contentOverride) { - editor?.clear(); + editor.clear(); } }); @@ -308,7 +306,6 @@ export function useTaskCreation({ return { isCreatingTask, - canSubmitBase, canSubmit, handleSubmit, }; diff --git a/apps/code/src/renderer/stores/navigationStore.test.ts b/apps/code/src/renderer/stores/navigationStore.test.ts index cc77b3744..0a6584ee8 100644 --- a/apps/code/src/renderer/stores/navigationStore.test.ts +++ b/apps/code/src/renderer/stores/navigationStore.test.ts @@ -110,27 +110,18 @@ describe("navigationStore", () => { expect(getView().taskInputRequestId).toBeTruthy(); }); - it("passes autoSubmit through and mints a fresh request id", () => { + it("mints a fresh taskInputRequestId on each navigation with transient state", () => { getStore().navigateToTaskInput({ initialPrompt: "Discuss this", reportAssociation: { reportId: "report-456", title: "Slow checkout" }, - initialExecutionMode: "auto", - autoSubmit: true, }); - - expect(getView()).toMatchObject({ - type: "task-input", - initialExecutionMode: "auto", - autoSubmit: true, - }); - expect(getView().taskInputRequestId).toBeTruthy(); - const firstRequestId = getView().taskInputRequestId; + expect(firstRequestId).toBeTruthy(); + getStore().navigateToInbox(); getStore().navigateToTaskInput({ initialPrompt: "Discuss this", reportAssociation: { reportId: "report-456", title: "Slow checkout" }, - autoSubmit: true, }); expect(getView().taskInputRequestId).not.toBe(firstRequestId); }); diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 5da7ac4ea..bb43e334a 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -1,7 +1,7 @@ import { foldersApi } from "@features/folders/hooks/useFolders"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { getTaskDirectory } from "@hooks/useRepositoryDirectory"; -import type { ExecutionMode, Task } from "@shared/types"; +import type { Task } from "@shared/types"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { track } from "@utils/analytics"; import { electronStorage } from "@utils/electronStorage"; @@ -32,8 +32,6 @@ interface TaskInputNavigationOptions { initialPrompt?: string; initialCloudRepository?: string; reportAssociation?: TaskInputReportAssociation; - initialExecutionMode?: ExecutionMode; - autoSubmit?: boolean; } interface ViewState { @@ -45,8 +43,6 @@ interface ViewState { initialPrompt?: string; initialCloudRepository?: string; reportAssociation?: TaskInputReportAssociation; - initialExecutionMode?: ExecutionMode; - autoSubmit?: boolean; } interface NavigationStore { @@ -198,9 +194,7 @@ export const useNavigationStore = create()( const hasTransientState = !!options.initialPrompt || !!options.initialCloudRepository || - !!options.reportAssociation || - !!options.initialExecutionMode || - !!options.autoSubmit; + !!options.reportAssociation; if (options.reportAssociation || options.initialCloudRepository) { set({ taskInputReportAssociation: options.reportAssociation, @@ -213,8 +207,6 @@ export const useNavigationStore = create()( initialPrompt: options.initialPrompt, initialCloudRepository: options.initialCloudRepository, reportAssociation: options.reportAssociation, - initialExecutionMode: options.initialExecutionMode, - autoSubmit: options.autoSubmit, taskInputRequestId: hasTransientState ? (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`) : undefined, From cfe91072509178eae2563ec89f0b801c89cdc90c Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Fri, 22 May 2026 11:16:54 +0100 Subject: [PATCH 14/14] refactor(markdown): drop inline trpc import, mock client in UserMessage test The lazy `import("@renderer/trpc/client")` inside the link onClick was working around UserMessage.test.tsx failing at module init (trpc/client.ts calls ipcLink() which needs window.electronTRPC). Top-level import matches every other call site in the renderer; the test gets the same vi.mock pattern other test files use. --- .../features/editor/components/MarkdownRenderer.tsx | 7 ++----- .../components/session-update/UserMessage.test.tsx | 8 +++++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx index 6eb5a8eed..050e10e00 100644 --- a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx @@ -4,6 +4,7 @@ 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"; @@ -105,11 +106,7 @@ export const baseComponents: Components = { onClick={(event) => { if (!isDeeplink || !href) return; event.preventDefault(); - // Lazy-load the tRPC client so tests that render markdown don't need - // an electronTRPC global at module init time. - void import("@renderer/trpc/client").then(({ trpcClient }) => - trpcClient.os.openExternal.mutate({ url: href }), - ); + void trpcClient.os.openExternal.mutate({ url: href }); }} target="_blank" rel="noopener noreferrer" 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(