From e31183b28d2f17e6e554251b9a6af502c625f6a2 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 23 Apr 2026 21:48:58 -0700 Subject: [PATCH] Wizard integration and first data-less tasks --- .../src/main/services/workspace/service.ts | 11 +- apps/code/src/renderer/App.tsx | 8 +- .../src/renderer/components/MainLayout.tsx | 2 + apps/code/src/renderer/di/container.ts | 4 + apps/code/src/renderer/di/tokens.ts | 1 + .../inbox/components/InboxSignalsTab.tsx | 41 +- .../components/detail/ReportDetailPane.tsx | 15 +- .../inbox/components/list/ReportListRow.tsx | 18 +- .../components/utils/ReportCardContent.tsx | 5 + .../onboarding/components/OnboardingFlow.tsx | 8 + .../onboarding/components/SignalsStep.tsx | 2 +- .../context-collection/SuggestedTasks.tsx | 139 ++++ .../onboarding/hooks/useOnboardingFlow.ts | 13 + .../onboarding/stores/onboardingStore.ts | 5 + .../components/sections/AdvancedSettings.tsx | 2 + .../components/DiscoveredTaskDetailPane.tsx | 198 +++++ .../components/RecommendedSetupTasks.tsx | 71 ++ .../setup/components/SetupScanFeed.tsx | 268 +++++++ .../features/setup/components/SetupView.tsx | 243 ++++++ .../features/setup/hooks/useSetupRun.ts | 42 + .../src/renderer/features/setup/prompts.ts | 37 + .../setup/services/setupRunService.ts | 727 ++++++++++++++++++ .../features/setup/stores/setupStore.ts | 194 +++++ .../code/src/renderer/features/setup/types.ts | 86 +++ .../setup/utils/buildDiscoveredTaskPrompt.ts | 28 + .../features/setup/utils/categoryConfig.ts | 46 ++ .../utils/discoveredTaskToSignalReport.ts | 25 + .../sidebar/components/SidebarMenu.tsx | 28 + .../sidebar/components/items/SetupItem.tsx | 64 ++ .../features/sidebar/hooks/useSidebarData.ts | 6 +- .../task-detail/components/TaskInput.tsx | 14 +- .../hooks/useDetectedCloudRepository.ts | 20 + .../src/renderer/stores/navigationStore.ts | 11 +- apps/code/src/shared/types/analytics.ts | 117 +++ .../src/adapters/claude/session/options.ts | 2 +- 35 files changed, 2484 insertions(+), 17 deletions(-) create mode 100644 apps/code/src/renderer/features/onboarding/components/context-collection/SuggestedTasks.tsx create mode 100644 apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailPane.tsx create mode 100644 apps/code/src/renderer/features/setup/components/RecommendedSetupTasks.tsx create mode 100644 apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx create mode 100644 apps/code/src/renderer/features/setup/components/SetupView.tsx create mode 100644 apps/code/src/renderer/features/setup/hooks/useSetupRun.ts create mode 100644 apps/code/src/renderer/features/setup/prompts.ts create mode 100644 apps/code/src/renderer/features/setup/services/setupRunService.ts create mode 100644 apps/code/src/renderer/features/setup/stores/setupStore.ts create mode 100644 apps/code/src/renderer/features/setup/types.ts create mode 100644 apps/code/src/renderer/features/setup/utils/buildDiscoveredTaskPrompt.ts create mode 100644 apps/code/src/renderer/features/setup/utils/categoryConfig.ts create mode 100644 apps/code/src/renderer/features/setup/utils/discoveredTaskToSignalReport.ts create mode 100644 apps/code/src/renderer/features/sidebar/components/items/SetupItem.tsx create mode 100644 apps/code/src/renderer/hooks/useDetectedCloudRepository.ts diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts index 186a5c7bb..dea586081 100644 --- a/apps/code/src/main/services/workspace/service.ts +++ b/apps/code/src/main/services/workspace/service.ts @@ -14,6 +14,7 @@ import { import { CreateOrSwitchBranchSaga } from "@posthog/git/sagas/branch"; import { DetachHeadSaga } from "@posthog/git/sagas/head"; import { WorktreeManager } from "@posthog/git/worktree"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { inject, injectable } from "inversify"; import type { RepositoryRepository } from "../../db/repositories/repository-repository"; import type { WorkspaceRepository } from "../../db/repositories/workspace-repository"; @@ -340,9 +341,9 @@ export class WorkspaceService extends TypedEventEmitter branchName, error, }); - trackAppEvent("branch_link_default_branch_unknown", { - taskId, - branchName, + trackAppEvent(ANALYTICS_EVENTS.BRANCH_LINK_DEFAULT_BRANCH_UNKNOWN, { + task_id: taskId, + branch_name: branchName, }); return; } @@ -368,7 +369,7 @@ export class WorkspaceService extends TypedEventEmitter taskId, branchName, }); - trackAppEvent("branch_linked", { + trackAppEvent(ANALYTICS_EVENTS.BRANCH_LINKED, { task_id: taskId, branch_name: branchName, source: source ?? "unknown", @@ -382,7 +383,7 @@ export class WorkspaceService extends TypedEventEmitter taskId, branchName: null, }); - trackAppEvent("branch_unlinked", { + trackAppEvent(ANALYTICS_EVENTS.BRANCH_UNLINKED, { task_id: taskId, source: source ?? "unknown", }); diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index 15babd5de..4c93f2c99 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -41,6 +41,9 @@ function App() { const hasCompletedOnboarding = useOnboardingStore( (state) => state.hasCompletedOnboarding, ); + const selectedDirectory = useOnboardingStore( + (state) => state.selectedDirectory, + ); const isAuthenticated = authState.status === "authenticated"; const hasCodeAccess = authState.hasCodeAccess; const isDarkMode = useThemeStore((state) => state.isDarkMode); @@ -210,8 +213,11 @@ function App() { } // Rendering: onboarding (includes auth + invite code gate) → main app + // We also route to onboarding when no directory is selected — without one, the + // main app has nothing meaningful to show (the dev "Skip setup" button can + // produce this state by flipping hasCompletedOnboarding without picking a directory). const renderContent = () => { - if (!hasCompletedOnboarding) { + if (!hasCompletedOnboarding || !selectedDirectory) { return ( diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index cb2696f69..d25f27967 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -14,6 +14,7 @@ import { McpServersView } from "@features/mcp-servers/components/McpServersView" import { FolderSettingsView } from "@features/settings/components/FolderSettingsView"; import { SettingsDialog } from "@features/settings/components/SettingsDialog"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { SetupView } from "@features/setup/components/SetupView"; import { MainSidebar } from "@features/sidebar/components/MainSidebar"; import { useSidebarData } from "@features/sidebar/hooks/useSidebarData"; import { useVisualTaskOrder } from "@features/sidebar/hooks/useVisualTaskOrder"; @@ -132,6 +133,7 @@ export function MainLayout() { {view.type === "skills" && } {view.type === "mcp-servers" && } + {view.type === "setup" && } diff --git a/apps/code/src/renderer/di/container.ts b/apps/code/src/renderer/di/container.ts index 169472098..b70cd4ed6 100644 --- a/apps/code/src/renderer/di/container.ts +++ b/apps/code/src/renderer/di/container.ts @@ -1,4 +1,5 @@ import "reflect-metadata"; +import { SetupRunService } from "@features/setup/services/setupRunService"; import { TaskService } from "@features/task-detail/service/service"; import type { TrpcRouter } from "@main/trpc/router"; import { trpcClient } from "@renderer/trpc"; @@ -20,6 +21,9 @@ container // Bind services container.bind(RENDERER_TOKENS.TaskService).to(TaskService); +container + .bind(RENDERER_TOKENS.SetupRunService) + .to(SetupRunService); export function get(token: symbol): T { return container.get(token); diff --git a/apps/code/src/renderer/di/tokens.ts b/apps/code/src/renderer/di/tokens.ts index 9fec3380a..7b60ca586 100644 --- a/apps/code/src/renderer/di/tokens.ts +++ b/apps/code/src/renderer/di/tokens.ts @@ -10,4 +10,5 @@ export const RENDERER_TOKENS = Object.freeze({ // Services TaskService: Symbol.for("Renderer.TaskService"), + SetupRunService: Symbol.for("Renderer.SetupRunService"), }); diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index 1053cc46a..2c40b35f1 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -24,6 +24,9 @@ import { isReportUpForReview, } from "@features/inbox/utils/filterReports"; import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; +import { DiscoveredTaskDetailPane } from "@features/setup/components/DiscoveredTaskDetailPane"; +import { RecommendedSetupTasks } from "@features/setup/components/RecommendedSetupTasks"; +import { useSetupStore } from "@features/setup/stores/setupStore"; import { useIntegrations, useRepositoryIntegration, @@ -227,6 +230,9 @@ export function InboxSignalsTab() { // ── Click handler: plain / cmd / shift ────────────────────────────────── const handleReportClick = useCallback( (reportId: string, event: { metaKey: boolean; shiftKey: boolean }) => { + // Selecting a real report clears any discovered-task selection so the + // detail pane can swap to the report. + useSetupStore.getState().selectDiscoveredTask(null); if (event.shiftKey) { selectRange( reportId, @@ -310,6 +316,28 @@ export function InboxSignalsTab() { }; }, [sidebarIsResizing, setSidebarWidth, setSidebarIsResizing]); + // ── Discovered-task suggestions (rendered inline at top of list) ─────── + const discoveredTasks = useSetupStore((s) => s.discoveredTasks); + const hasDiscoveredTasks = discoveredTasks.length > 0; + const selectedDiscoveredTaskId = useSetupStore( + (s) => s.selectedDiscoveredTaskId, + ); + const selectDiscoveredTask = useSetupStore((s) => s.selectDiscoveredTask); + const selectedDiscoveredTask = + discoveredTasks.find((t) => t.id === selectedDiscoveredTaskId) ?? null; + + const handleSelectDiscoveredTask = useCallback( + (taskId: string) => { + selectDiscoveredTask(taskId); + clearSelection(); + }, + [selectDiscoveredTask, clearSelection], + ); + + const handleCloseDiscoveredTaskPane = useCallback(() => { + selectDiscoveredTask(null); + }, [selectDiscoveredTask]); + // ── Layout mode (computed early — needed by focus effect below) ──────── const hasReports = allReports.length > 0; const hasActiveFilters = @@ -317,7 +345,10 @@ export function InboxSignalsTab() { suggestedReviewerFilter.length > 0 || statusFilter.length < 5; const shouldShowTwoPane = - hasReports || !!searchQuery.trim() || hasActiveFilters; + hasReports || + !!searchQuery.trim() || + hasActiveFilters || + hasDiscoveredTasks; // Sticky: once we enter two-pane mode, stay there even if a refetch // momentarily empties the list (e.g. when sort order changes). @@ -520,6 +551,9 @@ export function InboxSignalsTab() { onConfigureSources={() => setSourcesDialogOpen(true)} /> + + ) : selectedDiscoveredTask ? ( + ) : ( )} 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 a6bf1908b..1a9510c2d 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -4,6 +4,7 @@ import { useInboxReportSignals, } from "@features/inbox/hooks/useInboxReports"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; +import { useDetectedCloudRepository } from "@hooks/useDetectedCloudRepository"; import { useMeQuery } from "@hooks/useMeQuery"; import { ArrowSquareOutIcon, @@ -15,6 +16,7 @@ import { XIcon, } from "@phosphor-icons/react"; import { Box, Flex, ScrollArea, Text, Tooltip } from "@radix-ui/themes"; +import { useTRPC } from "@renderer/trpc"; import { EXTERNAL_LINKS } from "@renderer/utils/links"; import { getDeeplinkProtocol } from "@shared/deeplink"; import type { @@ -29,6 +31,7 @@ import type { Task, } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; +import { useQuery } from "@tanstack/react-query"; import { type ReactNode, useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { SignalReportActionabilityBadge } from "../utils/SignalReportActionabilityBadge"; @@ -201,6 +204,14 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { // ── Task creation ─────────────────────────────────────────────────────── const { navigateToTaskInput } = useNavigationStore(); const { data: reportRepository } = useReportRepository(report.id); + const trpcReact = useTRPC(); + const { data: mostRecentRepo } = useQuery( + trpcReact.folders.getMostRecentlyAccessedRepository.queryOptions(), + ); + const detectedFallbackRepo = useDetectedCloudRepository( + !reportRepository ? mostRecentRepo?.path : null, + ); + const effectiveCloudRepository = reportRepository ?? detectedFallbackRepo; /** True when the report is waiting on user input before implementation can proceed. * Covers the `pending_input` status and the `ready + requires_human_input` combination @@ -222,7 +233,7 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { if (!canCreateImplementationPr) return; 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: reportRepository ?? undefined, + initialCloudRepository: effectiveCloudRepository ?? undefined, reportAssociation: { reportId: report.id, title: report.title ?? "Untitled signal", @@ -231,7 +242,7 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { }, [ canCreateImplementationPr, navigateToTaskInput, - reportRepository, + effectiveCloudRepository, report, ]); diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx b/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx index f5f3056ec..c44e9b6e0 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx @@ -4,7 +4,7 @@ import { FileTextIcon } from "@phosphor-icons/react"; import { Checkbox, Flex, Tooltip } from "@radix-ui/themes"; import type { SignalReport } from "@shared/types"; import { motion } from "framer-motion"; -import type { KeyboardEvent, MouseEvent } from "react"; +import type { KeyboardEvent, MouseEvent, ReactNode } from "react"; function SourceProductIcon({ sourceProducts }: { sourceProducts?: string[] }) { const firstProduct = sourceProducts?.[0]; @@ -45,6 +45,10 @@ interface ReportListRowProps { onClick: (event: { metaKey: boolean; shiftKey: boolean }) => void; onToggleChecked: () => void; index: number; + /** Optional badge rendered before the standard status/priority/actionability badges. */ + prependBadges?: ReactNode; + /** Optional override for the icon shown in the left-side icon column. */ + iconOverride?: ReactNode; } export function ReportListRow({ @@ -54,6 +58,8 @@ export function ReportListRow({ onClick, onToggleChecked, index, + prependBadges, + iconOverride, }: ReportListRowProps) { const isInteractiveTarget = (target: EventTarget | null): boolean => { return ( @@ -142,11 +148,17 @@ export function ReportListRow({ } /> ) : ( - + (iconOverride ?? ( + + )) )}
- +
diff --git a/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx b/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx index 9a980eb23..0901323a7 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx +++ b/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx @@ -7,6 +7,7 @@ import { SignalReportSummaryMarkdown } from "@features/inbox/components/utils/Si import { EyeIcon, LightningIcon } from "@phosphor-icons/react"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import type { SignalReport } from "@shared/types"; +import type { ReactNode } from "react"; interface ReportCardContentProps { report: SignalReport; @@ -14,12 +15,15 @@ interface ReportCardContentProps { showMeta?: boolean; /** Tighter vertical and horizontal gaps for inbox list rows. */ compact?: boolean; + /** Optional badge node rendered before the standard status/priority/actionability badges. */ + prependBadges?: ReactNode; } export function ReportCardContent({ report, showMeta = false, compact = false, + prependBadges, }: ReportCardContentProps) { const isReady = report.status === "ready"; @@ -57,6 +61,7 @@ export function ReportCardContent({ wrap="wrap" className="min-w-0 flex-1" > + {prependBadges} {!isReady && } state.completeOnboarding, ); + const hasCompletedSetup = useOnboardingStore( + (state) => state.hasCompletedSetup, + ); const resetOnboarding = useOnboardingStore((state) => state.resetOnboarding); + const navigateToSetup = useNavigationStore((state) => state.navigateToSetup); const logoutMutation = useLogoutMutation(); const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", @@ -51,6 +56,9 @@ export function OnboardingFlow() { const handleComplete = () => { completeOnboarding(); + if (!hasCompletedSetup) { + navigateToSetup(); + } }; const footerRight = ( diff --git a/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx b/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx index 121269afd..5ae2f3ea1 100644 --- a/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx @@ -136,7 +136,7 @@ export function SignalsStep({ onNext, onBack }: SignalsStepProps) { onClick={() => void handleContinue()} disabled={isLoading} > - Continue + Continue to setup ) : ( diff --git a/apps/code/src/renderer/features/onboarding/components/context-collection/SuggestedTasks.tsx b/apps/code/src/renderer/features/onboarding/components/context-collection/SuggestedTasks.tsx new file mode 100644 index 000000000..7e97c0dc3 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/context-collection/SuggestedTasks.tsx @@ -0,0 +1,139 @@ +import type { DiscoveredTask } from "@features/setup/types"; +import { + CATEGORY_CONFIG, + FALLBACK_CATEGORY_CONFIG, +} from "@features/setup/utils/categoryConfig"; +import { ArrowRight } from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; +import { motion } from "framer-motion"; + +type Variant = "default" | "compact"; + +interface SuggestedTasksProps { + tasks: DiscoveredTask[]; + onSelectTask: (task: DiscoveredTask) => void; + variant?: Variant; + /** When set, uses CSS grid with the given column class instead of a vertical stack. */ + layoutClassName?: string; +} + +export function SuggestedTasks({ + tasks, + onSelectTask, + variant = "default", + layoutClassName, +}: SuggestedTasksProps) { + if (tasks.length === 0) { + return ( + + No issues found. Your codebase looks clean! + + ); + } + + const containerClass = layoutClassName ?? "flex w-full flex-col gap-3"; + + return ( +
+ {tasks.map((task, index) => ( + + ))} +
+ ); +} + +interface SuggestedTaskCardProps { + task: DiscoveredTask; + index: number; + variant: Variant; + onSelect: (task: DiscoveredTask) => void; +} + +function SuggestedTaskCard({ + task, + index, + variant, + onSelect, +}: SuggestedTaskCardProps) { + const config = CATEGORY_CONFIG[task.category] ?? FALLBACK_CATEGORY_CONFIG; + const TaskIcon = config.icon; + const isCompact = variant === "compact"; + const iconSize = isCompact ? 14 : 18; + const titleSize = isCompact ? "1" : "2"; + + return ( + onSelect(task)} + type="button" + className={`flex w-full cursor-pointer items-start rounded-xl border border-(--gray-a3) bg-(--color-panel-solid) text-left transition-[border-color,box-shadow] ${ + isCompact ? "gap-2.5 px-2.5 py-2" : "gap-3.5 px-[18px] py-4" + }`} + style={{ + boxShadow: "0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)", + }} + whileHover={{ + borderColor: `var(--${config.color}-6)`, + boxShadow: "0 2px 8px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04)", + }} + > + + + + + + + {task.title} + + + + + {task.description} + + {task.file && ( + + {task.file} + {task.lineHint ? `:${task.lineHint}` : ""} + + )} + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts index 68956f06b..2fa3c68b4 100644 --- a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts +++ b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts @@ -1,9 +1,19 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import type { SetupRunService } from "@features/setup/services/setupRunService"; +import { get } from "@renderer/di/container"; +import { RENDERER_TOKENS } from "@renderer/di/tokens"; import { trpcClient } from "@renderer/trpc/client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ONBOARDING_STEPS, type OnboardingStep } from "../types"; +function kickOffSetupRuns(directory: string): void { + if (!directory) return; + const service = get(RENDERER_TOKENS.SetupRunService); + service.startWizard(directory); + service.startDiscovery(directory); +} + export interface DetectedRepo { organization: string; repository: string; @@ -46,6 +56,7 @@ export function useOnboardingFlow() { }) .catch(() => {}) .finally(() => setIsDetectingRepo(false)); + kickOffSetupRuns(selectedDirectory); }, [selectedDirectory]); const handleDirectoryChange = useCallback( @@ -54,6 +65,8 @@ export function useOnboardingFlow() { setDetectedRepo(null); if (!path) return; + kickOffSetupRuns(path); + setIsDetectingRepo(true); try { const result = await trpcClient.git.detectRepo.query({ diff --git a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts index 1835733ac..34af17e24 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts @@ -8,6 +8,7 @@ const log = logger.scope("onboarding-store"); interface OnboardingStoreState { currentStep: OnboardingStep; hasCompletedOnboarding: boolean; + hasCompletedSetup: boolean; selectedProjectId: number | null; selectedDirectory: string; } @@ -15,6 +16,7 @@ interface OnboardingStoreState { interface OnboardingStoreActions { setCurrentStep: (step: OnboardingStep) => void; completeOnboarding: () => void; + completeSetup: () => void; resetOnboarding: () => void; resetSelections: () => void; selectProjectId: (projectId: number | null) => void; @@ -26,6 +28,7 @@ type OnboardingStore = OnboardingStoreState & OnboardingStoreActions; const initialState: OnboardingStoreState = { currentStep: "welcome", hasCompletedOnboarding: false, + hasCompletedSetup: false, selectedProjectId: null, selectedDirectory: "", }; @@ -40,6 +43,7 @@ export const useOnboardingStore = create()( log.info("completeOnboarding"); set({ hasCompletedOnboarding: true }); }, + completeSetup: () => set({ hasCompletedSetup: true }), resetOnboarding: () => set({ ...initialState }), resetSelections: () => set({ @@ -54,6 +58,7 @@ export const useOnboardingStore = create()( partialize: (state) => ({ currentStep: state.currentStep, hasCompletedOnboarding: state.hasCompletedOnboarding, + hasCompletedSetup: state.hasCompletedSetup, selectedProjectId: state.selectedProjectId, selectedDirectory: state.selectedDirectory, }), diff --git a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx index 4544d6902..d2dd8d256 100644 --- a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx @@ -2,6 +2,7 @@ import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore" import { SettingRow } from "@features/settings/components/SettingRow"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { useSetupStore } from "@features/setup/stores/setupStore"; import { useTourStore } from "@features/tour/stores/tourStore"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { Button, Flex, Switch } from "@radix-ui/themes"; @@ -27,6 +28,7 @@ export function AdvancedSettings() { onClick={() => { useSettingsDialogStore.getState().close(); useOnboardingStore.getState().resetOnboarding(); + useSetupStore.getState().resetSetup(); }} > Reset diff --git a/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailPane.tsx b/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailPane.tsx new file mode 100644 index 000000000..a4efd6abd --- /dev/null +++ b/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailPane.tsx @@ -0,0 +1,198 @@ +import { Badge } from "@components/ui/Badge"; +import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; +import { useFolders } from "@features/folders/hooks/useFolders"; +import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { useSetupStore } from "@features/setup/stores/setupStore"; +import type { DiscoveredTask } from "@features/setup/types"; +import { buildDiscoveredTaskPrompt } from "@features/setup/utils/buildDiscoveredTaskPrompt"; +import { + CATEGORY_CONFIG, + FALLBACK_CATEGORY_CONFIG, +} from "@features/setup/utils/categoryConfig"; +import { useDetectedCloudRepository } from "@hooks/useDetectedCloudRepository"; +import { PlusIcon, SparkleIcon, X as XIcon } from "@phosphor-icons/react"; +import { Box, Button, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { useNavigationStore } from "@stores/navigationStore"; +import { track } from "@utils/analytics"; + +interface DiscoveredTaskDetailPaneProps { + task: DiscoveredTask; + onClose: () => void; +} + +export function DiscoveredTaskDetailPane({ + task, + onClose, +}: DiscoveredTaskDetailPaneProps) { + const config = CATEGORY_CONFIG[task.category] ?? FALLBACK_CATEGORY_CONFIG; + const CategoryIcon = config.icon; + + const tasks = useSetupStore((s) => s.discoveredTasks); + const selectedDirectory = useOnboardingStore((s) => s.selectedDirectory); + const completeSetup = useOnboardingStore((s) => s.completeSetup); + const navigateToTaskInput = useNavigationStore((s) => s.navigateToTaskInput); + const { folders } = useFolders(); + const detectedCloudRepository = useDetectedCloudRepository(selectedDirectory); + + const handleCreateTask = () => { + const position = tasks.findIndex((t) => t.id === task.id); + track(ANALYTICS_EVENTS.SETUP_TASK_SELECTED, { + discovered_task_id: task.id, + category: task.category, + position: position >= 0 ? position : 0, + total_discovered: tasks.length, + }); + + const initialPrompt = buildDiscoveredTaskPrompt(task); + const folderId = folders.find((f) => f.path === selectedDirectory)?.id; + completeSetup(); + useSetupStore.getState().removeDiscoveredTask(task.id); + navigateToTaskInput({ + initialPrompt, + folderId, + initialCloudRepository: detectedCloudRepository ?? undefined, + }); + }; + + const handleDismiss = () => { + const position = tasks.findIndex((t) => t.id === task.id); + track(ANALYTICS_EVENTS.SETUP_TASK_DISMISSED, { + discovered_task_id: task.id, + category: task.category, + position: position >= 0 ? position : 0, + total_discovered: tasks.length, + }); + useSetupStore.getState().removeDiscoveredTask(task.id); + }; + + return ( + <> + + + + + Suggested + + + {task.title} + + + + + + + + + + + + + + + {config.label} + + {task.file && ( + <> + + · + + + {task.file} + {task.lineHint ? `:${task.lineHint}` : ""} + + + )} + + + + + {task.impact && ( + + + Why it matters + + + + )} + + {task.recommendation && ( + + + Suggested approach + + + + )} + + + Suggested locally from a quick scan of your codebase. Open it as a + task to investigate and fix. + + + + + + + + + + ); +} + +function ProseSection({ content }: { content: string }) { + return ( + + + + ); +} diff --git a/apps/code/src/renderer/features/setup/components/RecommendedSetupTasks.tsx b/apps/code/src/renderer/features/setup/components/RecommendedSetupTasks.tsx new file mode 100644 index 000000000..afcf0da53 --- /dev/null +++ b/apps/code/src/renderer/features/setup/components/RecommendedSetupTasks.tsx @@ -0,0 +1,71 @@ +import { Badge } from "@components/ui/Badge"; +import { ReportListRow } from "@features/inbox/components/list/ReportListRow"; +import { useSetupStore } from "@features/setup/stores/setupStore"; +import { discoveredTaskToSignalReport } from "@features/setup/utils/discoveredTaskToSignalReport"; +import { SparkleIcon } from "@phosphor-icons/react"; +import { Flex, Text, Tooltip } from "@radix-ui/themes"; +import { useMemo } from "react"; + +interface RecommendedSetupTasksProps { + onSelectTask: (taskId: string) => void; +} + +export function RecommendedSetupTasks({ + onSelectTask, +}: RecommendedSetupTasksProps) { + const tasks = useSetupStore((s) => s.discoveredTasks); + const discoveryStatus = useSetupStore((s) => s.discoveryStatus); + const selectedDiscoveredTaskId = useSetupStore( + (s) => s.selectedDiscoveredTaskId, + ); + + const fakeReports = useMemo( + () => tasks.map(discoveredTaskToSignalReport), + [tasks], + ); + + if (tasks.length === 0) return null; + + return ( + + {discoveryStatus === "running" && ( + + + scanning for more… + + + )} + {fakeReports.map((report, index) => ( + onSelectTask(report.id)} + onToggleChecked={() => {}} + iconOverride={ + + + + + + } + prependBadges={ + + + Suggested + + } + /> + ))} + + ); +} diff --git a/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx b/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx new file mode 100644 index 000000000..eacbf89b0 --- /dev/null +++ b/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx @@ -0,0 +1,268 @@ +import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; +import type { Icon } from "@phosphor-icons/react"; +import { + ArrowsClockwise, + ArrowsLeftRight, + Brain, + CheckCircle, + FileText, + Globe, + MagnifyingGlass, + PencilSimple, + Terminal, + Trash, + Wrench, +} from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; +import { AnimatePresence, motion } from "framer-motion"; + +interface ActivityEntry { + id: number; + toolCallId: string; + tool: string; + filePath: string | null; + title: string; +} + +interface SetupScanFeedProps { + label: string; + icon: Icon; + color: string; + currentTool: string | null; + recentEntries: ActivityEntry[]; + isDone: boolean; + doneLabel?: string; +} + +const TOOL_VERBS: Record = { + Read: "Reading a file...", + Glob: "Searching files...", + Grep: "Searching code...", + Bash: "Running a command...", + Edit: "Making changes...", + Write: "Writing a file...", + Agent: "Thinking...", + ListDirectory: "Browsing files...", + ToolSearch: "Looking up tools...", + WebSearch: "Searching the web...", + WebFetch: "Fetching a page...", + NotebookEdit: "Editing notebook...", + Monitor: "Monitoring...", + SearchReplace: "Making changes...", + MultiEdit: "Making changes...", + StructuredOutput: "Preparing results...", + create_output: "Preparing results...", + WrappingUp: "Wrapping up...", + TodoRead: "Reviewing tasks...", + TodoWrite: "Updating tasks...", + TaskCreate: "Creating a task...", + TaskUpdate: "Updating a task...", + TaskGet: "Checking task status...", + TaskList: "Listing tasks...", + AskFollowupQuestion: "Thinking...", +}; + +const TOOL_KIND: Record = { + Read: "read", + Edit: "edit", + Write: "edit", + Grep: "search", + Glob: "search", + Bash: "execute", + Agent: "think", + ToolSearch: "search", + WebSearch: "search", + WebFetch: "fetch", + StructuredOutput: "other", + create_output: "other", + WrappingUp: "think", +}; + +const KIND_ICONS: Record = { + read: FileText, + edit: PencilSimple, + delete: Trash, + move: ArrowsLeftRight, + search: MagnifyingGlass, + execute: Terminal, + think: Brain, + fetch: Globe, + switch_mode: ArrowsClockwise, + other: Wrench, +}; + +function shortenPath(path: string): string { + const parts = path.split("/"); + if (parts.length <= 3) return path; + return `.../${parts.slice(-3).join("/")}`; +} + +const GENERIC_TITLES = new Set([ + "Read File", + "Execute command", + "Edit", + "Write", + "Find", + "Fetch", + "Working", + "Task", + "Terminal", +]); + +function entryDisplayText(entry: ActivityEntry): string { + if (entry.filePath) return shortenPath(entry.filePath); + if (entry.title && !GENERIC_TITLES.has(entry.title)) return entry.title; + return TOOL_VERBS[entry.tool] ?? "Working..."; +} + +function toolLabel(tool: string): string { + return TOOL_VERBS[tool] ?? "Working..."; +} + +export function SetupScanFeed({ + label, + icon: LabelIcon, + color, + currentTool, + recentEntries, + isDone, + doneLabel = "Complete", +}: SetupScanFeedProps) { + const activeLabel = currentTool ? toolLabel(currentTool) : "Starting..."; + + return ( + + + + + {isDone ? ( + + + + ) : ( + + )} + + + {label} + + + +
+ + {!isDone && activeLabel && ( + + + + {activeLabel} + + + )} + {isDone && ( + + + {doneLabel} + + + )} + +
+
+ + {!isDone && recentEntries.length > 0 && ( + + + + {recentEntries.slice(-4).map((entry, index, arr) => { + const isLatest = index === arr.length - 1; + const kind = TOOL_KIND[entry.tool] ?? "other"; + const EntryIcon = KIND_ICONS[kind] ?? Wrench; + const entryText = entryDisplayText(entry); + return ( + + + + + {entryText} + + + + ); + })} + + + + )} +
+ ); +} diff --git a/apps/code/src/renderer/features/setup/components/SetupView.tsx b/apps/code/src/renderer/features/setup/components/SetupView.tsx new file mode 100644 index 000000000..f302f3754 --- /dev/null +++ b/apps/code/src/renderer/features/setup/components/SetupView.tsx @@ -0,0 +1,243 @@ +import { DotPatternBackground } from "@components/DotPatternBackground"; +import { SuggestedTasks } from "@features/onboarding/components/context-collection/SuggestedTasks"; +import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { SetupScanFeed } from "@features/setup/components/SetupScanFeed"; +import { useSetupRun } from "@features/setup/hooks/useSetupRun"; +import { useSetupStore } from "@features/setup/stores/setupStore"; +import type { DiscoveredTask } from "@features/setup/types"; +import { buildDiscoveredTaskPrompt } from "@features/setup/utils/buildDiscoveredTaskPrompt"; +import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; +import { MagicWand, Robot, Rocket } from "@phosphor-icons/react"; +import { Box, Button, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import explorerHog from "@renderer/assets/images/hedgehogs/explorer-hog.png"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { useNavigationStore } from "@stores/navigationStore"; +import { track } from "@utils/analytics"; +import { motion } from "framer-motion"; +import { useEffect } from "react"; + +export function SetupView() { + const { + discoveryFeed, + wizardFeed, + isDiscoveryDone, + isWizardStarted, + wizardSkipped, + discoveredTasks, + error, + } = useSetupRun(); + const completeSetup = useOnboardingStore((state) => state.completeSetup); + const navigateToTaskInput = useNavigationStore( + (state) => state.navigateToTaskInput, + ); + + useSetHeaderContent( + + + + {isDiscoveryDone ? "Tasks ready" : "Finish setup"} + + , + ); + + useEffect(() => { + track(ANALYTICS_EVENTS.SETUP_VIEWED, { + discovery_status: useSetupStore.getState().discoveryStatus, + }); + }, []); + + const handleSelectTask = (task: DiscoveredTask) => { + const position = discoveredTasks.findIndex((t) => t.id === task.id); + track(ANALYTICS_EVENTS.SETUP_TASK_SELECTED, { + discovered_task_id: task.id, + category: task.category, + position: position >= 0 ? position : 0, + total_discovered: discoveredTasks.length, + }); + + const initialPrompt = buildDiscoveredTaskPrompt(task); + completeSetup(); + useSetupStore.getState().removeDiscoveredTask(task.id); + navigateToTaskInput({ initialPrompt }); + }; + + // Mid-scan: leave discovery running so the sidebar surfaces tasks when ready. + const handleSkipDuringScan = () => { + track(ANALYTICS_EVENTS.SETUP_SKIPPED, { + discovery_status: useSetupStore.getState().discoveryStatus, + had_discovered_tasks: discoveredTasks.length > 0, + entry_point: "during_scan", + }); + completeSetup(); + navigateToTaskInput(); + }; + + const handleSkipAfterDone = () => { + track(ANALYTICS_EVENTS.SETUP_SKIPPED, { + discovery_status: useSetupStore.getState().discoveryStatus, + had_discovered_tasks: discoveredTasks.length > 0, + entry_point: "after_done", + }); + useSetupStore.getState().resetDiscovery(); + completeSetup(); + navigateToTaskInput(); + }; + + return ( + + + + + + + + {isDiscoveryDone + ? "Your starter tasks are ready" + : "Finding your first tasks"} + + + {isDiscoveryDone + ? "Pick one to get going, or start from scratch — your suggestions stay in the sidebar." + : "This takes about a minute. We're scanning your code for a handful of starter tasks you can run in one click — bug fixes, cleanup, and PostHog enhancements where they apply."} + + + + + + {isWizardStarted && !wizardSkipped && ( + + + + )} + + + + + + + {!isDiscoveryDone && ( + + + + + + Skimming your codebase for a few starter tasks… + + + + + + + Suggested tasks will appear in the sidebar when ready. + + + + + )} + + {error && ( + + {error} + + )} + + {isDiscoveryDone && ( + + + {discoveredTasks.length > 0 && ( + + + Recommended first tasks + + + + )} + + + + + + + )} + + + + ); +} diff --git a/apps/code/src/renderer/features/setup/hooks/useSetupRun.ts b/apps/code/src/renderer/features/setup/hooks/useSetupRun.ts new file mode 100644 index 000000000..ef229907b --- /dev/null +++ b/apps/code/src/renderer/features/setup/hooks/useSetupRun.ts @@ -0,0 +1,42 @@ +import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import type { SetupRunService } from "@features/setup/services/setupRunService"; +import { useSetupStore } from "@features/setup/stores/setupStore"; +import { get } from "@renderer/di/container"; +import { RENDERER_TOKENS } from "@renderer/di/tokens"; +import { useEffect, useRef } from "react"; + +export function useSetupRun() { + const selectedDirectory = useOnboardingStore((s) => s.selectedDirectory); + const discoveryStatus = useSetupStore((s) => s.discoveryStatus); + const discoveredTasks = useSetupStore((s) => s.discoveredTasks); + const wizardTaskId = useSetupStore((s) => s.wizardTaskId); + const wizardSkipped = useSetupStore((s) => s.wizardSkipped); + const discoveryFeed = useSetupStore((s) => s.discoveryFeed); + const wizardFeed = useSetupStore((s) => s.wizardFeed); + const error = useSetupStore((s) => s.error); + + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + if (discoveryStatus === "done") return; + if (!selectedDirectory) return; + + const service = get(RENDERER_TOKENS.SetupRunService); + service.startWizard(selectedDirectory); + service.startDiscovery(selectedDirectory); + }, [discoveryStatus, selectedDirectory]); + + return { + discoveryFeed, + wizardFeed, + isDiscoveryDone: discoveryStatus === "done", + isWizardStarted: !!wizardTaskId, + wizardSkipped, + discoveredTasks, + wizardTaskId, + error, + }; +} diff --git a/apps/code/src/renderer/features/setup/prompts.ts b/apps/code/src/renderer/features/setup/prompts.ts new file mode 100644 index 000000000..a3816fbd4 --- /dev/null +++ b/apps/code/src/renderer/features/setup/prompts.ts @@ -0,0 +1,37 @@ +export const WIZARD_PROMPT = `/instrument-integration + +After the integration is wired up, also instrument error tracking and session replay (run \`/instrument-error-tracking\`, then add session replay if the framework's posthog-js config supports it). + +Run autonomously with sensible defaults — do not ask the user questions. If the PostHog API key isn't already in the project's env files and you can't read it from the PostHog MCP server, leave a placeholder env var and note it in the PR body rather than blocking.`; + +export const DISCOVERY_PROMPT = `You are analyzing this codebase to find the highest-value first tasks for the developer. + +Scan the codebase for issues in two tiers. Tier 1 applies to every repo. Tier 2 only applies when PostHog is already installed (look for posthog-js, posthog-node, posthog-react-native or similar PostHog SDK imports). + +## Tier 1 -- Code health (always) + +- **Dead code**: Unused exports, unreachable branches, orphaned files, stale imports. Category: dead_code +- **Duplication / KISS violations**: Copy-pasted logic that should be a shared function, over-abstracted code that could be simpler. Category: duplication +- **Security vulnerabilities**: XSS, SQL injection, command injection, hardcoded secrets, open redirects, missing auth checks, insecure deserialization. Category: security +- **Bugs**: Null dereferences, race conditions, unchecked array access, off-by-one errors, unhandled promise rejections around I/O. Category: bug +- **Performance anti-patterns**: N+1 queries, unbounded loops, synchronous blocking on hot paths, missing pagination. Category: performance + +## Tier 2 -- PostHog-specific (only when PostHog SDK is detected) + +- **Stale feature flags**: Flags that are always evaluated the same way, flags referenced in code but never toggled, flags guarding code that shipped long ago. Category: stale_feature_flag +- **Error tracking gaps**: Catch blocks that swallow errors without reporting, missing error boundaries, untracked 5xx responses. Category: error_tracking +- **Event tracking improvements**: Key user actions (signup, purchase, invite, upgrade) with no analytics event, events missing useful properties (plan, user role, page context). Category: event_tracking +- **Funnel weak spots**: Multi-step flows (onboarding, checkout, activation) where intermediate steps have no tracking, making drop-off invisible. Category: funnel + +## Rules + +- Be concrete: reference exact file paths, function names and line numbers — but put paths/lines in the dedicated \`file\` and \`lineHint\` fields, not in the title or description. +- Title: short, action-oriented header (under 60 characters), no paths or line numbers. +- Description: a clear paragraph (2–4 sentences) explaining the problem and the conditions under which it manifests. +- Impact: 1–3 sentences on why it matters (concrete consequence, blast radius, or risk). +- Recommendation: 2–4 sentences pointing at the right shape of the fix without writing the patch. Reference specific functions, types, or files involved. +- Prioritize by impact. Lead with findings that save the most time or prevent the most damage. +- Do NOT suggest documentation, comment, or style/formatting changes. +- Maximum 4 tasks. Quality over quantity. + +When you are done analyzing, call create_output with your findings.`; diff --git a/apps/code/src/renderer/features/setup/services/setupRunService.ts b/apps/code/src/renderer/features/setup/services/setupRunService.ts new file mode 100644 index 000000000..7cd34853d --- /dev/null +++ b/apps/code/src/renderer/features/setup/services/setupRunService.ts @@ -0,0 +1,727 @@ +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { fetchAuthState } from "@features/auth/hooks/authQueries"; +import { DISCOVERY_PROMPT, WIZARD_PROMPT } from "@features/setup/prompts"; +import { useSetupStore } from "@features/setup/stores/setupStore"; +import { + type DiscoveredTask, + TASK_DISCOVERY_JSON_SCHEMA, +} from "@features/setup/types"; +import type { PostHogAPIClient } from "@renderer/api/posthogClient"; +import { + type TaskCreationInput, + TaskCreationSaga, +} from "@renderer/sagas/task/task-creation"; +import { trpcClient } from "@renderer/trpc/client"; +import { isTerminalStatus, type Task } from "@shared/types"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; +import { captureException, track } from "@utils/analytics"; +import { logger } from "@utils/logger"; +import { queryClient } from "@utils/queryClient"; +import { injectable } from "inversify"; + +const log = logger.scope("setup-run-service"); + +interface ActivityEntry { + id: number; + toolCallId: string; + tool: string; + filePath: string | null; + title: string; +} + +let activityIdCounter = 0; + +function extractPathFromRawInput( + tool: string, + rawInput: Record | undefined, +): string | null { + if (!rawInput) return null; + + switch (tool) { + case "Read": + case "Edit": + case "Write": + return (rawInput.file_path as string) ?? null; + case "Grep": + return (rawInput.pattern as string) + ? `"${rawInput.pattern}"${rawInput.path ? ` in ${rawInput.path}` : ""}` + : ((rawInput.path as string) ?? null); + case "Glob": + return (rawInput.pattern as string) ?? null; + case "Bash": { + const cmd = rawInput.command as string | undefined; + if (!cmd) return null; + return cmd.length > 80 ? `${cmd.slice(0, 77)}...` : cmd; + } + default: { + const filePath = + rawInput.file_path ?? rawInput.path ?? rawInput.notebook_path; + if (typeof filePath === "string") return filePath; + const pattern = rawInput.pattern; + if (typeof pattern === "string") return `"${pattern}"`; + const command = rawInput.command; + if (typeof command === "string") + return command.length > 80 ? `${command.slice(0, 77)}...` : command; + const url = rawInput.url; + if (typeof url === "string") return url; + const query = rawInput.query; + if (typeof query === "string") return query; + return null; + } + } +} + +function extractToolCall( + update: Record, +): ActivityEntry | null { + const sessionUpdate = update.sessionUpdate as string | undefined; + if (sessionUpdate !== "tool_call" && sessionUpdate !== "tool_call_update") + return null; + + const meta = update._meta as + | { claudeCode?: { toolName?: string } } + | undefined; + const tool = meta?.claudeCode?.toolName ?? "Working"; + const locations = update.locations as + | { path?: string; line?: number }[] + | undefined; + const rawInput = (update.rawInput ?? update.input) as + | Record + | undefined; + const filePath = + locations?.[0]?.path ?? extractPathFromRawInput(tool, rawInput); + const title = (update.title as string) ?? ""; + const toolCallId = (update.toolCallId as string) ?? ""; + + activityIdCounter += 1; + return { id: activityIdCounter, toolCallId, tool, filePath, title }; +} + +function extractAgentMessageText( + update: Record, +): string | null { + if (update.sessionUpdate !== "agent_message_chunk") return null; + const content = update.content as + | { type?: string; text?: string } + | undefined; + if (content?.type !== "text" || !content.text) return null; + return content.text; +} + +function handleSessionUpdate( + payload: unknown, + pushActivity: (entry: ActivityEntry) => void, + pushAssistantText?: (text: string) => void, +) { + const acpMsg = payload as { message?: Record }; + const inner = acpMsg.message; + if (!inner) return; + + if ("method" in inner && inner.method === "session/update") { + const params = inner.params as Record | undefined; + if (!params) return; + + const update = (params.update as Record) ?? params; + + const entry = extractToolCall(update); + if (entry) { + pushActivity(entry); + return; + } + + if (pushAssistantText) { + const text = extractAgentMessageText(update); + if (text) pushAssistantText(text); + } + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +const POSTHOG_PACKAGES = [ + "posthog-js", + "posthog-node", + "posthog-react-native", + "@posthog/react-native-session-replay", + "posthog-android", + "posthog-ios", + "posthog-flutter", +]; + +async function isPosthogInstalled(repoPath: string): Promise { + try { + const content = await trpcClient.fs.readRepoFile.query({ + repoPath, + filePath: "package.json", + }); + if (!content) return false; + const pkg = JSON.parse(content); + const allDeps = { + ...pkg.dependencies, + ...pkg.devDependencies, + }; + return POSTHOG_PACKAGES.some((name) => name in allDeps); + } catch { + return false; + } +} + +async function resolveWizardWorkspaceMode( + client: PostHogAPIClient, +): Promise<"cloud" | "local"> { + try { + const integrations = await client.getIntegrations(); + const hasGithub = (integrations as { kind: string }[]).some( + (i) => i.kind === "github", + ); + if (hasGithub) return "cloud"; + } catch (err) { + log.warn("Failed to check GitHub integration, falling back to local", { + error: err, + }); + } + return "local"; +} + +@injectable() +export class SetupRunService { + private discoverySubscription: { unsubscribe: () => void } | null = null; + private wizardSubscription: { unsubscribe: () => void } | null = null; + private discoveryAbort: AbortController | null = null; + private wizardAbort: AbortController | null = null; + private discoveryStartedAt: number | null = null; + private discoveryStarting = false; + private wizardStarting = false; + + startDiscovery(directory: string): void { + if (this.discoveryStarting) return; + const status = useSetupStore.getState().discoveryStatus; + if (status === "running" || status === "done") return; + this.discoveryStarting = true; + this.runDiscovery(directory) + .catch((err) => { + log.error("Discovery startup failed", { error: err }); + }) + .finally(() => { + this.discoveryStarting = false; + }); + } + + startWizard(directory: string): void { + if (this.wizardStarting) return; + const state = useSetupStore.getState(); + if (state.wizardTaskId || state.wizardSkipped) return; + this.wizardStarting = true; + this.runWizard(directory) + .catch((err) => { + log.error("Wizard startup failed", { error: err }); + }) + .finally(() => { + this.wizardStarting = false; + }); + } + + cancel(): void { + this.discoveryAbort?.abort(); + this.discoveryAbort = null; + this.wizardAbort?.abort(); + this.wizardAbort = null; + this.discoverySubscription = null; + this.wizardSubscription = null; + this.discoveryStartedAt = null; + useSetupStore.getState().resetDiscovery(); + } + + private async runWizard(directory: string): Promise { + const existingId = useSetupStore.getState().wizardTaskId; + if (existingId) { + log.debug("Wizard task already exists, skipping", { + wizardTaskId: existingId, + }); + return; + } + + this.wizardAbort?.abort(); + const abort = new AbortController(); + this.wizardAbort = abort; + + log.debug("Starting wizard task"); + try { + const client = await getAuthenticatedClient(); + if (abort.signal.aborted) return; + if (!client) { + log.error("getAuthenticatedClient returned null for wizard"); + track(ANALYTICS_EVENTS.SETUP_WIZARD_FAILED, { + reason: "unauthenticated_client", + }); + return; + } + + if (!directory) { + log.warn("No directory for wizard task"); + track(ANALYTICS_EVENTS.SETUP_WIZARD_FAILED, { + reason: "missing_directory", + }); + return; + } + + if (await isPosthogInstalled(directory)) { + if (abort.signal.aborted) return; + log.info("PostHog already installed, skipping wizard"); + useSetupStore.getState().skipWizard(); + track(ANALYTICS_EVENTS.SETUP_WIZARD_FAILED, { + reason: "already_installed", + }); + return; + } + + const workspaceMode = await resolveWizardWorkspaceMode(client); + if (abort.signal.aborted) return; + log.info("Wizard workspace mode resolved", { workspaceMode }); + + const sagaInput: TaskCreationInput = { + taskDescription: WIZARD_PROMPT, + content: WIZARD_PROMPT, + repoPath: directory, + workspaceMode, + executionMode: "auto", + }; + + const saga = new TaskCreationSaga({ + posthogClient: client, + onTaskReady: ({ task }) => { + if (abort.signal.aborted) return; + useSetupStore.getState().setWizardTaskId(task.id); + track(ANALYTICS_EVENTS.SETUP_WIZARD_STARTED, { + wizard_task_id: task.id, + workspace_mode: workspaceMode, + }); + queryClient.invalidateQueries({ queryKey: ["tasks", "list"] }); + this.subscribeToWizardEvents(task.id, abort.signal); + }, + }); + + const result = await saga.run(sagaInput); + if (abort.signal.aborted) return; + + if (!result.success) { + throw new Error( + `Wizard saga failed at step: ${result.failedStep ?? "unknown"}`, + ); + } + } catch (err) { + if (abort.signal.aborted) return; + log.error("Failed to start wizard task", { error: err }); + const message = + err instanceof Error ? err.message : "Failed to start wizard task."; + track(ANALYTICS_EVENTS.SETUP_WIZARD_FAILED, { + reason: "startup_error", + error_message: message, + }); + if (err instanceof Error) { + captureException(err, { scope: "setup.start_wizard_task" }); + } + } finally { + if (this.wizardAbort === abort) { + this.wizardAbort = null; + } + } + } + + private subscribeToWizardEvents(taskId: string, signal: AbortSignal): void { + const checkForRun = async () => { + const client = await getAuthenticatedClient(); + if (!client || signal.aborted) return; + + for (let i = 0; i < 30; i++) { + try { + await sleep(2000, signal); + } catch { + return; // aborted + } + try { + const taskData = (await client.getTask(taskId)) as unknown as Task; + if (signal.aborted) return; + const runId = taskData.latest_run?.id; + if (runId) { + log.debug("Wizard run found, subscribing", { taskId, runId }); + const sub = trpcClient.agent.onSessionEvent.subscribe( + { taskRunId: runId }, + { + onData: (payload: unknown) => { + handleSessionUpdate(payload, (entry) => { + useSetupStore.getState().pushWizardActivity(entry); + }); + }, + onError: (err) => { + log.error("Wizard subscription error", { error: err }); + }, + }, + ); + this.wizardSubscription = sub; + signal.addEventListener( + "abort", + () => { + sub.unsubscribe(); + if (this.wizardSubscription === sub) { + this.wizardSubscription = null; + } + }, + { once: true }, + ); + return; + } + } catch { + // keep polling + } + } + }; + checkForRun().catch((err) => + log.error("Wizard event subscribe failed", { error: err }), + ); + } + + private async runDiscovery(directory: string): Promise { + const state = useSetupStore.getState(); + if ( + state.discoveryStatus === "done" || + state.discoveryStatus === "running" + ) { + return; + } + + this.discoveryAbort?.abort(); + const abort = new AbortController(); + this.discoveryAbort = abort; + + try { + const authState = await fetchAuthState(); + if (abort.signal.aborted) return; + const apiHost = authState.cloudRegion + ? getCloudUrlFromRegion(authState.cloudRegion) + : null; + const projectId = authState.projectId; + + if (!apiHost || !projectId) { + log.error("Missing auth for discovery", { apiHost, projectId }); + useSetupStore.getState().failDiscovery("Authentication required."); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + reason: "startup_error", + error_message: "missing_auth", + }); + return; + } + + const client = await getAuthenticatedClient(); + if (abort.signal.aborted) return; + if (!client) { + useSetupStore.getState().failDiscovery("Authentication required."); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + reason: "startup_error", + error_message: "unauthenticated_client", + }); + return; + } + + if (!directory) { + useSetupStore.getState().failDiscovery("No directory selected."); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + reason: "startup_error", + error_message: "missing_directory", + }); + return; + } + + const task = (await client.createTask({ + title: "Discover first tasks", + description: DISCOVERY_PROMPT, + json_schema: TASK_DISCOVERY_JSON_SCHEMA as Record, + })) as unknown as Task; + if (abort.signal.aborted) return; + + const taskRun = await client.createTaskRun(task.id); + if (abort.signal.aborted) return; + if (!taskRun?.id) { + throw new Error("Failed to create discovery task run"); + } + + useSetupStore.getState().startDiscovery(task.id, taskRun.id); + this.discoveryStartedAt = Date.now(); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED, { + discovery_task_id: task.id, + discovery_task_run_id: taskRun.id, + }); + + await trpcClient.agent.start.mutate({ + taskId: task.id, + taskRunId: taskRun.id, + repoPath: directory, + apiHost, + projectId, + permissionMode: "bypassPermissions", + jsonSchema: TASK_DISCOVERY_JSON_SCHEMA as Record, + }); + if (abort.signal.aborted) return; + + trpcClient.agent.prompt + .mutate({ + sessionId: taskRun.id, + prompt: [{ type: "text", text: DISCOVERY_PROMPT }], + }) + .catch((err) => { + log.error("Failed to send discovery prompt", { error: err }); + }); + + let completed = false; + let subscription: { unsubscribe: () => void } | null = null; + + type CompletionSource = + | "structured_output" + | "terminal_status" + | "missing_output"; + + const finishSuccess = ( + tasks: DiscoveredTask[], + signalSource: CompletionSource, + ) => { + if (completed || abort.signal.aborted) return; + completed = true; + subscription?.unsubscribe(); + if (this.discoverySubscription === subscription) { + this.discoverySubscription = null; + } + + const startedAt = this.discoveryStartedAt; + const durationSeconds = startedAt + ? Math.round((Date.now() - startedAt) / 1000) + : 0; + + log.info("Discovery completed", { + taskCount: tasks.length, + signalSource, + }); + useSetupStore.getState().completeDiscovery(tasks); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_COMPLETED, { + discovery_task_id: task.id, + discovery_task_run_id: taskRun.id, + task_count: tasks.length, + duration_seconds: durationSeconds, + signal_source: signalSource, + }); + }; + + const finishFailure = ( + reason: "failed" | "cancelled" | "timeout", + message: string, + ) => { + if (completed || abort.signal.aborted) return; + completed = true; + subscription?.unsubscribe(); + if (this.discoverySubscription === subscription) { + this.discoverySubscription = null; + } + + log.error("Discovery failed", { reason }); + useSetupStore.getState().failDiscovery(message); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + discovery_task_id: task.id, + discovery_task_run_id: taskRun.id, + reason, + }); + }; + + let signalRetryStarted = false; + const handleStructuredOutputSignal = async () => { + if (signalRetryStarted) return; + signalRetryStarted = true; + const startedAt = Date.now(); + const TIMEOUT_MS = 8000; + const MAX_DELAY_MS = 4000; + let delay = 500; + while (Date.now() - startedAt < TIMEOUT_MS) { + try { + await sleep(delay, abort.signal); + } catch { + return; // aborted + } + if (completed) return; + try { + const run = await client.getTaskRun(task.id, taskRun.id); + if (completed || abort.signal.aborted) return; + const output = run.output as { tasks?: DiscoveredTask[] } | null; + if (output?.tasks) { + finishSuccess(output.tasks, "structured_output"); + return; + } + } catch (err) { + log.warn("Failed to fetch run after StructuredOutput signal", { + error: err, + }); + } + delay = Math.min(delay * 2, MAX_DELAY_MS); + } + }; + + let structuredOutputSeen = false; + let wrapupBuffer = ""; + const WRAPUP_TOOL_CALL_ID = "discovery-wrapup"; + const pushWrapupActivity = (text: string) => { + if (!structuredOutputSeen) return; + wrapupBuffer = (wrapupBuffer + text).slice(-200); + activityIdCounter += 1; + useSetupStore.getState().pushDiscoveryActivity({ + id: activityIdCounter, + toolCallId: WRAPUP_TOOL_CALL_ID, + tool: "WrappingUp", + filePath: null, + title: wrapupBuffer.trim(), + }); + }; + + subscription = trpcClient.agent.onSessionEvent.subscribe( + { taskRunId: taskRun.id }, + { + onData: (payload: unknown) => { + handleSessionUpdate( + payload, + (entry) => { + useSetupStore.getState().pushDiscoveryActivity(entry); + if (entry.tool === "StructuredOutput") { + structuredOutputSeen = true; + handleStructuredOutputSignal().catch((err) => + log.warn("StructuredOutput handler failed", { error: err }), + ); + } + }, + pushWrapupActivity, + ); + }, + onError: (err) => { + log.error("Discovery subscription error", { error: err }); + }, + }, + ); + this.discoverySubscription = subscription; + const subscriptionAtAbort = subscription; + abort.signal.addEventListener( + "abort", + () => { + subscriptionAtAbort.unsubscribe(); + if (this.discoverySubscription === subscriptionAtAbort) { + this.discoverySubscription = null; + } + }, + { once: true }, + ); + + const pollForCompletion = async () => { + const maxAttempts = 120; + const intervalMs = 5000; + + for (let i = 0; i < maxAttempts; i++) { + try { + await sleep(intervalMs, abort.signal); + } catch { + return; // aborted + } + if (completed) return; + + try { + const run = await client.getTaskRun(task.id, taskRun.id); + if (completed || abort.signal.aborted) return; + + const output = run.output as { tasks?: DiscoveredTask[] } | null; + + if (isTerminalStatus(run.status)) { + if (run.status === "completed" && output?.tasks) { + finishSuccess(output.tasks, "terminal_status"); + } else if ( + run.status === "failed" || + run.status === "cancelled" + ) { + finishFailure( + run.status, + "Discovery failed. You can skip or retry.", + ); + } else { + finishSuccess([], "missing_output"); + } + return; + } + + if (output?.tasks) { + finishSuccess(output.tasks, "missing_output"); + return; + } + } catch (err) { + log.warn("Failed to poll discovery", { + attempt: i + 1, + error: err, + }); + } + } + + finishFailure("timeout", "Discovery timed out. You can skip or retry."); + }; + + pollForCompletion().catch((err) => { + if (abort.signal.aborted) return; + log.error("Discovery poll failed", { error: err }); + if (!completed) { + completed = true; + subscription?.unsubscribe(); + if (this.discoverySubscription === subscription) { + this.discoverySubscription = null; + } + useSetupStore + .getState() + .failDiscovery("Discovery failed unexpectedly."); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + discovery_task_id: task.id, + discovery_task_run_id: taskRun.id, + reason: "failed", + error_message: + err instanceof Error ? err.message : "discovery_poll_error", + }); + if (err instanceof Error) { + captureException(err, { scope: "setup.discovery_poll" }); + } + } + }); + } catch (err) { + if (abort.signal.aborted) return; + log.error("Failed to start discovery", { error: err }); + const message = + err instanceof Error ? err.message : "Failed to start discovery."; + useSetupStore.getState().failDiscovery(message); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + reason: "startup_error", + error_message: message, + }); + if (err instanceof Error) { + captureException(err, { scope: "setup.start_discovery" }); + } + } finally { + if (this.discoveryAbort === abort) { + this.discoveryAbort = null; + } + } + } +} diff --git a/apps/code/src/renderer/features/setup/stores/setupStore.ts b/apps/code/src/renderer/features/setup/stores/setupStore.ts new file mode 100644 index 000000000..bdd8bdd22 --- /dev/null +++ b/apps/code/src/renderer/features/setup/stores/setupStore.ts @@ -0,0 +1,194 @@ +import type { DiscoveredTask } from "@features/setup/types"; +import { logger } from "@utils/logger"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +const log = logger.scope("setup-store"); + +type DiscoveryStatus = "idle" | "running" | "done" | "error"; + +interface ActivityEntry { + id: number; + toolCallId: string; + tool: string; + filePath: string | null; + title: string; +} + +export interface AgentFeedState { + currentTool: string | null; + currentFilePath: string | null; + recentEntries: ActivityEntry[]; +} + +const EMPTY_FEED: AgentFeedState = { + currentTool: null, + currentFilePath: null, + recentEntries: [], +}; + +interface SetupStoreState { + discoveredTasks: DiscoveredTask[]; + discoveryStatus: DiscoveryStatus; + discoveryTaskId: string | null; + discoveryTaskRunId: string | null; + wizardTaskId: string | null; + wizardSkipped: boolean; + discoveryFeed: AgentFeedState; + wizardFeed: AgentFeedState; + error: string | null; + selectedDiscoveredTaskId: string | null; +} + +interface SetupStoreActions { + startDiscovery: (taskId: string, taskRunId: string) => void; + completeDiscovery: (tasks: DiscoveredTask[]) => void; + failDiscovery: (message?: string) => void; + resetDiscovery: () => void; + removeDiscoveredTask: (taskId: string) => void; + selectDiscoveredTask: (taskId: string | null) => void; + setWizardTaskId: (taskId: string) => void; + skipWizard: () => void; + pushDiscoveryActivity: (entry: ActivityEntry) => void; + pushWizardActivity: (entry: ActivityEntry) => void; + /** Wipes all setup state — discovered tasks, wizard, feeds, selection. */ + resetSetup: () => void; +} + +type SetupStore = SetupStoreState & SetupStoreActions; + +const initialState: SetupStoreState = { + discoveredTasks: [], + discoveryStatus: "idle", + discoveryTaskId: null, + discoveryTaskRunId: null, + wizardTaskId: null, + wizardSkipped: false, + discoveryFeed: EMPTY_FEED, + wizardFeed: EMPTY_FEED, + error: null, + selectedDiscoveredTaskId: null, +}; + +function pushEntry(prev: AgentFeedState, entry: ActivityEntry): AgentFeedState { + const existingIdx = entry.toolCallId + ? prev.recentEntries.findIndex((e) => e.toolCallId === entry.toolCallId) + : -1; + + let newEntries: ActivityEntry[]; + if (existingIdx >= 0) { + newEntries = [...prev.recentEntries]; + const old = newEntries[existingIdx]; + newEntries[existingIdx] = { + ...old, + tool: entry.tool || old.tool, + filePath: entry.filePath || old.filePath, + title: entry.title || old.title, + }; + } else { + newEntries = [...prev.recentEntries.slice(-4), entry]; + } + + return { + currentTool: entry.tool, + currentFilePath: entry.filePath ?? prev.currentFilePath, + recentEntries: newEntries, + }; +} + +export const useSetupStore = create()( + persist( + (set) => ({ + ...initialState, + + startDiscovery: (taskId, taskRunId) => { + log.info("Discovery started", { taskId, taskRunId }); + set({ + discoveryStatus: "running", + discoveryTaskId: taskId, + discoveryTaskRunId: taskRunId, + discoveredTasks: [], + discoveryFeed: EMPTY_FEED, + error: null, + }); + }, + + completeDiscovery: (tasks) => { + log.info("Discovery completed", { taskCount: tasks.length }); + set({ + discoveryStatus: "done", + discoveredTasks: tasks, + error: null, + }); + }, + + failDiscovery: (message) => { + log.warn("Discovery failed", { message }); + set({ discoveryStatus: "error", error: message ?? null }); + }, + + resetDiscovery: () => { + log.info("Discovery reset"); + set({ + discoveryStatus: "idle", + discoveryTaskId: null, + discoveryTaskRunId: null, + discoveredTasks: [], + discoveryFeed: EMPTY_FEED, + error: null, + }); + }, + + removeDiscoveredTask: (taskId) => { + set((state) => ({ + discoveredTasks: state.discoveredTasks.filter((t) => t.id !== taskId), + selectedDiscoveredTaskId: + state.selectedDiscoveredTaskId === taskId + ? null + : state.selectedDiscoveredTaskId, + })); + }, + + selectDiscoveredTask: (taskId) => { + set({ selectedDiscoveredTaskId: taskId }); + }, + + setWizardTaskId: (taskId) => { + log.info("Wizard task created", { taskId }); + set({ wizardTaskId: taskId }); + }, + + skipWizard: () => { + log.info("Wizard skipped (PostHog already installed)"); + set({ wizardSkipped: true }); + }, + + pushDiscoveryActivity: (entry) => { + set((state) => ({ + discoveryFeed: pushEntry(state.discoveryFeed, entry), + })); + }, + + pushWizardActivity: (entry) => { + set((state) => ({ + wizardFeed: pushEntry(state.wizardFeed, entry), + })); + }, + + resetSetup: () => { + log.info("Setup state reset"); + set({ ...initialState }); + }, + }), + { + name: "setup-store", + partialize: (state) => ({ + discoveredTasks: state.discoveredTasks, + discoveryStatus: + state.discoveryStatus === "done" + ? ("done" as const) + : ("idle" as const), + }), + }, + ), +); diff --git a/apps/code/src/renderer/features/setup/types.ts b/apps/code/src/renderer/features/setup/types.ts new file mode 100644 index 000000000..eb61a4008 --- /dev/null +++ b/apps/code/src/renderer/features/setup/types.ts @@ -0,0 +1,86 @@ +export interface DiscoveredTask { + id: string; + title: string; + description: string; + category: + | "bug" + | "security" + | "dead_code" + | "duplication" + | "performance" + | "stale_feature_flag" + | "error_tracking" + | "event_tracking" + | "funnel"; + file?: string; + lineHint?: number; + impact?: string; + recommendation?: string; +} + +export const TASK_DISCOVERY_JSON_SCHEMA = { + type: "object", + properties: { + tasks: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", description: "A short kebab-case identifier" }, + title: { + type: "string", + description: + "Short, action-oriented header — under 60 characters. No file paths or line numbers.", + }, + description: { + type: "string", + description: + "A clear paragraph (2–4 sentences) describing the problem: what's wrong and the conditions under which it manifests. Do NOT include the file path or line number — those go in the file/lineHint fields.", + }, + category: { + type: "string", + enum: [ + "bug", + "security", + "dead_code", + "duplication", + "performance", + "stale_feature_flag", + "error_tracking", + "event_tracking", + "funnel", + ], + }, + file: { + type: "string", + description: "Relative file path where the issue lives", + }, + lineHint: { + type: "integer", + description: "Approximate line number", + }, + impact: { + type: "string", + description: + "Why this matters — concrete impact, blast radius, or risk. 1–3 sentences. Be specific (e.g. 'silently drops auth errors so users see a successful login UI even when backend rejects them').", + }, + recommendation: { + type: "string", + description: + "Suggested approach to fix, in plain prose. 2–4 sentences pointing at the right shape of the fix without writing the patch. Reference any specific functions, types, or files involved.", + }, + }, + required: [ + "id", + "title", + "description", + "category", + "impact", + "recommendation", + ], + }, + maxItems: 4, + }, + }, + required: ["tasks"], +} as const; diff --git a/apps/code/src/renderer/features/setup/utils/buildDiscoveredTaskPrompt.ts b/apps/code/src/renderer/features/setup/utils/buildDiscoveredTaskPrompt.ts new file mode 100644 index 000000000..8ec6d0e42 --- /dev/null +++ b/apps/code/src/renderer/features/setup/utils/buildDiscoveredTaskPrompt.ts @@ -0,0 +1,28 @@ +import type { DiscoveredTask } from "@features/setup/types"; + +export function buildDiscoveredTaskPrompt(task: DiscoveredTask): string { + const sections: string[] = [ + "Investigate this issue and implement the fix. Open a PR if appropriate.", + "", + task.title, + "", + task.description, + ]; + + if (task.impact) { + sections.push("", "Why it matters:", task.impact); + } + + if (task.recommendation) { + sections.push("", "Suggested approach:", task.recommendation); + } + + if (task.file) { + const location = task.lineHint + ? `${task.file}:${task.lineHint}` + : task.file; + sections.push("", `File: ${location}`); + } + + return sections.join("\n"); +} diff --git a/apps/code/src/renderer/features/setup/utils/categoryConfig.ts b/apps/code/src/renderer/features/setup/utils/categoryConfig.ts new file mode 100644 index 000000000..b60d96c8b --- /dev/null +++ b/apps/code/src/renderer/features/setup/utils/categoryConfig.ts @@ -0,0 +1,46 @@ +import type { DiscoveredTask } from "@features/setup/types"; +import type { Icon } from "@phosphor-icons/react"; +import { + Bug, + ChartLine, + Copy, + Flag, + Funnel, + Lightning, + Lock, + Trash, + Warning, + Wrench, +} from "@phosphor-icons/react"; + +export interface CategoryConfig { + icon: Icon; + color: string; + label: string; +} + +// Single source of truth for how each `DiscoveredTask` category renders. +// Consumers (suggestion cards, detail pane, etc.) read from here so that +// adding a category to `DiscoveredTask` only requires updating one map. +export const CATEGORY_CONFIG: Record< + DiscoveredTask["category"], + CategoryConfig +> = { + bug: { icon: Bug, color: "red", label: "Bug" }, + security: { icon: Lock, color: "red", label: "Security" }, + dead_code: { icon: Trash, color: "gray", label: "Dead code" }, + duplication: { icon: Copy, color: "orange", label: "Duplication" }, + performance: { icon: Lightning, color: "green", label: "Performance" }, + stale_feature_flag: { icon: Flag, color: "amber", label: "Stale flag" }, + error_tracking: { icon: Warning, color: "orange", label: "Error tracking" }, + event_tracking: { icon: ChartLine, color: "blue", label: "Event tracking" }, + funnel: { icon: Funnel, color: "violet", label: "Funnel" }, +}; + +// Fallback when a `DiscoveredTask.category` somehow doesn't match the map +// (e.g. an agent emits a value the schema didn't constrain). +export const FALLBACK_CATEGORY_CONFIG: CategoryConfig = { + icon: Wrench, + color: "gray", + label: "Suggestion", +}; diff --git a/apps/code/src/renderer/features/setup/utils/discoveredTaskToSignalReport.ts b/apps/code/src/renderer/features/setup/utils/discoveredTaskToSignalReport.ts new file mode 100644 index 000000000..e744a1e14 --- /dev/null +++ b/apps/code/src/renderer/features/setup/utils/discoveredTaskToSignalReport.ts @@ -0,0 +1,25 @@ +import type { DiscoveredTask } from "@features/setup/types"; +import type { SignalReport } from "@shared/types"; + +export function discoveredTaskToSignalReport( + task: DiscoveredTask, +): SignalReport { + const now = new Date().toISOString(); + return { + id: task.id, + title: task.title, + summary: task.description, + status: "ready", + total_weight: 0, + signal_count: 0, + created_at: now, + updated_at: now, + artefact_count: 0, + priority: null, + actionability: null, + already_addressed: null, + is_suggested_reviewer: false, + source_products: undefined, + implementation_pr_url: null, + }; +} diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 14b768738..ab90d7bdf 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -6,7 +6,9 @@ import { INBOX_PIPELINE_STATUS_FILTER, INBOX_REFETCH_INTERVAL_MS, } from "@features/inbox/utils/inboxConstants"; +import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { getSessionService } from "@features/sessions/service/service"; +import { useSetupStore } from "@features/setup/stores/setupStore"; import { archiveTaskImperative, useArchiveTask, @@ -29,6 +31,7 @@ import { useTaskViewed } from "../hooks/useTaskViewed"; import { CommandCenterItem } from "./items/CommandCenterItem"; import { InboxItem, NewTaskItem } from "./items/HomeItem"; import { McpServersItem } from "./items/McpServersItem"; +import { SetupItem } from "./items/SetupItem"; import { SkillsItem } from "./items/SkillsItem"; import { SidebarItem } from "./SidebarItem"; import { TaskListView } from "./TaskListView"; @@ -42,6 +45,7 @@ function SidebarMenuComponent() { navigateToCommandCenter, navigateToSkills, navigateToMcpServers, + navigateToSetup, } = useNavigationStore(); const { data: allTasks = [] } = useTasks(); @@ -54,6 +58,17 @@ function SidebarMenuComponent() { const { archiveTask } = useArchiveTask(); const { togglePin } = usePinnedTasks(); + const hasCompletedSetup = useOnboardingStore( + (state) => state.hasCompletedSetup, + ); + const showSetupItem = useSetupStore((s) => { + if (!hasCompletedSetup) return true; + if (s.discoveryStatus === "running") return true; + if (s.discoveryStatus === "done" && s.discoveredTasks.length > 0) + return true; + return false; + }); + const sidebarData = useSidebarData({ activeView: view, }); @@ -120,6 +135,10 @@ function SidebarMenuComponent() { navigateToMcpServers(); }; + const handleSetupClick = () => { + navigateToSetup(); + }; + const handleTaskClick = (taskId: string) => { const task = taskMap.get(taskId); if (task) { @@ -283,6 +302,15 @@ function SidebarMenuComponent() { /> + {showSetupItem && ( + + + + )} + void; +} + +type ItemState = "running" | "ready" | "finish"; + +function selectItemState( + status: "idle" | "running" | "done" | "error", + taskCount: number, +): ItemState { + if (status === "running") return "running"; + if (status === "done" && taskCount > 0) return "ready"; + return "finish"; +} + +const LABELS: Record = { + running: "Scanning your code", + ready: "Tasks ready", + finish: "Finish setup", +}; + +export function SetupItem({ isActive, onClick }: SetupItemProps) { + const state = useSetupStore((s) => + selectItemState(s.discoveryStatus, s.discoveredTasks.length), + ); + + return ( + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index 6909c083b..5363db336 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -45,6 +45,7 @@ export interface SidebarData { isCommandCenterActive: boolean; isSkillsActive: boolean; isMcpServersActive: boolean; + isSetupActive: boolean; isLoading: boolean; activeTaskId: string | null; pinnedTasks: TaskData[]; @@ -64,7 +65,8 @@ interface ViewState { | "archived" | "command-center" | "skills" - | "mcp-servers"; + | "mcp-servers" + | "setup"; data?: Task; } @@ -130,6 +132,7 @@ export function useSidebarData({ const isCommandCenterActive = activeView.type === "command-center"; const isSkillsActive = activeView.type === "skills"; const isMcpServersActive = activeView.type === "mcp-servers"; + const isSetupActive = activeView.type === "setup"; const activeTaskId = activeView.type === "task-detail" && activeView.data @@ -249,6 +252,7 @@ export function useSidebarData({ isCommandCenterActive, isSkillsActive, isMcpServersActive, + isSetupActive, isLoading, activeTaskId, pinnedTasks, 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 7f7713805..80cf5e094 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -353,7 +353,18 @@ export function TaskInput({ }, [lastUsedCloudRepository, selectedRepository]); useEffect(() => { - if (isLoadingRepos || !selectedRepository || selectedCloudRepository) { + // Clear `selectedRepository` only when the list has actually loaded AND the + // selection is missing from it — i.e. the repo was removed from the user's + // integrations. Bail out when `repositories` is empty: that can happen + // transiently after `isLoadingRepos` flips false but before the + // per-integration queries have produced data, and clearing here would + // wipe out a freshly-supplied `initialCloudRepository` prefill. + if ( + isLoadingRepos || + repositories.length === 0 || + !selectedRepository || + selectedCloudRepository + ) { return; } @@ -363,6 +374,7 @@ export function TaskInput({ } }, [ isLoadingRepos, + repositories.length, lastUsedCloudRepository, selectedCloudRepository, selectedRepository, diff --git a/apps/code/src/renderer/hooks/useDetectedCloudRepository.ts b/apps/code/src/renderer/hooks/useDetectedCloudRepository.ts new file mode 100644 index 000000000..efc449175 --- /dev/null +++ b/apps/code/src/renderer/hooks/useDetectedCloudRepository.ts @@ -0,0 +1,20 @@ +import { useTRPC } from "@renderer/trpc"; +import { useQuery } from "@tanstack/react-query"; + +export function useDetectedCloudRepository( + folderPath: string | null | undefined, +): string | null { + const trpcReact = useTRPC(); + const { data } = useQuery( + trpcReact.git.detectRepo.queryOptions( + { directoryPath: folderPath ?? "" }, + { + enabled: !!folderPath, + staleTime: 60_000, + }, + ), + ); + + if (!data?.organization || !data?.repository) return null; + return `${data.organization}/${data.repository}`.toLowerCase(); +} diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 3edf3bcba..93a9338c2 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -20,7 +20,8 @@ type ViewType = | "archived" | "command-center" | "skills" - | "mcp-servers"; + | "mcp-servers" + | "setup"; export interface TaskInputReportAssociation { reportId: string; @@ -62,6 +63,7 @@ interface NavigationStore { navigateToCommandCenter: () => void; navigateToSkills: () => void; navigateToMcpServers: () => void; + navigateToSetup: () => void; goBack: () => void; goForward: () => void; canGoBack: () => boolean; @@ -98,6 +100,9 @@ const isSameView = (view1: ViewState, view2: ViewState): boolean => { if (view1.type === "mcp-servers" && view2.type === "mcp-servers") { return true; } + if (view1.type === "setup" && view2.type === "setup") { + return true; + } return false; }; @@ -280,6 +285,10 @@ export const useNavigationStore = create()( navigate({ type: "mcp-servers" }); }, + navigateToSetup: () => { + navigate({ type: "setup" }); + }, + goBack: () => { const { history, historyIndex } = get(); if (historyIndex > 0) { diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 03740d8f8..d49022f89 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -124,6 +124,25 @@ export interface AgentFileActivityProperties { branch_name: string | null; } +// Branch link events +type BranchLinkSource = "agent" | "user" | "unknown"; + +export interface BranchLinkedProperties { + task_id: string; + branch_name: string; + source: BranchLinkSource; +} + +export interface BranchUnlinkedProperties { + task_id: string; + source: BranchLinkSource; +} + +export interface BranchLinkDefaultBranchUnknownProperties { + task_id: string; + branch_name: string; +} + // File interactions export interface FileOpenedProperties { file_extension: string; @@ -250,6 +269,76 @@ export interface TaskFeedbackProperties { feedback_comment?: string; } +// Setup / onboarding events +type SetupDiscoveredTaskCategory = + | "bug" + | "security" + | "dead_code" + | "duplication" + | "performance" + | "stale_feature_flag" + | "error_tracking" + | "event_tracking" + | "funnel"; + +export interface SetupViewedProperties { + discovery_status: "idle" | "running" | "done" | "error"; +} + +export interface SetupDiscoveryStartedProperties { + discovery_task_id: string; + discovery_task_run_id: string; +} + +export interface SetupDiscoveryCompletedProperties { + discovery_task_id: string; + discovery_task_run_id: string; + task_count: number; + duration_seconds: number; + signal_source: "structured_output" | "terminal_status" | "missing_output"; +} + +export interface SetupDiscoveryFailedProperties { + discovery_task_id?: string; + discovery_task_run_id?: string; + reason: "failed" | "cancelled" | "timeout" | "startup_error"; + error_message?: string; +} + +export interface SetupTaskSelectedProperties { + discovered_task_id: string; + category: SetupDiscoveredTaskCategory; + position: number; + total_discovered: number; +} + +export interface SetupTaskDismissedProperties { + discovered_task_id: string; + category: SetupDiscoveredTaskCategory; + position: number; + total_discovered: number; +} + +export interface SetupSkippedProperties { + discovery_status: "idle" | "running" | "done" | "error"; + had_discovered_tasks: boolean; + entry_point: "during_scan" | "after_done"; +} + +export interface SetupWizardStartedProperties { + wizard_task_id: string; + workspace_mode?: string; +} + +export interface SetupWizardFailedProperties { + reason: + | "unauthenticated_client" + | "missing_directory" + | "startup_error" + | "already_installed"; + error_message?: string; +} + // Event names as constants export const ANALYTICS_EVENTS = { // App lifecycle @@ -277,6 +366,9 @@ export const ANALYTICS_EVENTS = { GIT_ACTION_EXECUTED: "Git action executed", PR_CREATED: "PR created", AGENT_FILE_ACTIVITY: "Agent file activity", + BRANCH_LINKED: "Branch linked", + BRANCH_UNLINKED: "Branch unlinked", + BRANCH_LINK_DEFAULT_BRANCH_UNKNOWN: "Branch link default branch unknown", // File interactions FILE_OPENED: "File opened", @@ -316,6 +408,17 @@ export const ANALYTICS_EVENTS = { // Tour events TOUR_EVENT: "Tour event", + // Setup / onboarding events + SETUP_VIEWED: "Setup viewed", + SETUP_DISCOVERY_STARTED: "Setup discovery started", + SETUP_DISCOVERY_COMPLETED: "Setup discovery completed", + SETUP_DISCOVERY_FAILED: "Setup discovery failed", + SETUP_TASK_SELECTED: "Setup task selected", + SETUP_TASK_DISMISSED: "Setup task dismissed", + SETUP_SKIPPED: "Setup skipped", + SETUP_WIZARD_STARTED: "Setup wizard started", + SETUP_WIZARD_FAILED: "Setup wizard failed", + // Error events TASK_CREATION_FAILED: "Task creation failed", AGENT_SESSION_ERROR: "Agent session error", @@ -341,6 +444,9 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.GIT_ACTION_EXECUTED]: GitActionExecutedProperties; [ANALYTICS_EVENTS.PR_CREATED]: PrCreatedProperties; [ANALYTICS_EVENTS.AGENT_FILE_ACTIVITY]: AgentFileActivityProperties; + [ANALYTICS_EVENTS.BRANCH_LINKED]: BranchLinkedProperties; + [ANALYTICS_EVENTS.BRANCH_UNLINKED]: BranchUnlinkedProperties; + [ANALYTICS_EVENTS.BRANCH_LINK_DEFAULT_BRANCH_UNKNOWN]: BranchLinkDefaultBranchUnknownProperties; // File interactions [ANALYTICS_EVENTS.FILE_OPENED]: FileOpenedProperties; @@ -380,6 +486,17 @@ export type EventPropertyMap = { // Tour events [ANALYTICS_EVENTS.TOUR_EVENT]: TourEventProperties; + // Setup / onboarding events + [ANALYTICS_EVENTS.SETUP_VIEWED]: SetupViewedProperties; + [ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED]: SetupDiscoveryStartedProperties; + [ANALYTICS_EVENTS.SETUP_DISCOVERY_COMPLETED]: SetupDiscoveryCompletedProperties; + [ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED]: SetupDiscoveryFailedProperties; + [ANALYTICS_EVENTS.SETUP_TASK_SELECTED]: SetupTaskSelectedProperties; + [ANALYTICS_EVENTS.SETUP_TASK_DISMISSED]: SetupTaskDismissedProperties; + [ANALYTICS_EVENTS.SETUP_SKIPPED]: SetupSkippedProperties; + [ANALYTICS_EVENTS.SETUP_WIZARD_STARTED]: SetupWizardStartedProperties; + [ANALYTICS_EVENTS.SETUP_WIZARD_FAILED]: SetupWizardFailedProperties; + // Error events [ANALYTICS_EVENTS.TASK_CREATION_FAILED]: TaskCreationFailedProperties; [ANALYTICS_EVENTS.AGENT_SESSION_ERROR]: AgentSessionErrorProperties; diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index c8686edf2..7aadf0107 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -154,7 +154,7 @@ function buildHooks( const PH_EXPLORE_AGENT: NonNullable[string] = { description: 'Fast agent for exploring and understanding codebases. Use this when you need to find files by pattern (eg. "src/components/**/*.tsx"), search for code or keywords (eg. "where is the auth middleware?"), or answer questions about how the codebase works (eg. "how does the session service handle reconnects?"). When calling this agent, specify a thoroughness level: "quick" for targeted lookups, "medium" for broader exploration, or "very thorough" for comprehensive analysis across multiple locations.', - model: "haiku", + model: "sonnet", prompt: `You are a fast, read-only codebase exploration agent. Your job is to find files, search code, read the most relevant sources, and report findings clearly.