diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts index 2510b65c1..92161dbe1 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 af6b83c5a..d5cb04302 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -13,6 +13,7 @@ import { useInboxDeepLink } from "@features/inbox/hooks/useInboxDeepLink"; 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"; @@ -129,6 +130,8 @@ export function MainLayout() { {view.type === "command-center" && } {view.type === "skills" && } + + {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..393086f3f 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -24,10 +24,10 @@ import { isReportUpForReview, } from "@features/inbox/utils/filterReports"; import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; -import { - useIntegrations, - useRepositoryIntegration, -} from "@hooks/useIntegrations"; +import { DiscoveredTaskDetailPane } from "@features/setup/components/DiscoveredTaskDetailPane"; +import { RecommendedSetupTasks } from "@features/setup/components/RecommendedSetupTasks"; +import { useSetupStore } from "@features/setup/stores/setupStore"; +import { useIntegrations, useRepositoryIntegration } from "@hooks/useIntegrations"; import { Box, Flex, ScrollArea } from "@radix-ui/themes"; import type { SignalReportsQueryParams } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; @@ -227,6 +227,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 +313,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 +342,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 +548,9 @@ export function InboxSignalsTab() { onConfigureSources={() => setSourcesDialogOpen(true)} /> + + ) : selectedDiscoveredTask ? ( + ) : ( )} 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/context-collection/SuggestedTasks.tsx b/apps/code/src/renderer/features/onboarding/components/context-collection/SuggestedTasks.tsx new file mode 100644 index 000000000..c3046b390 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/context-collection/SuggestedTasks.tsx @@ -0,0 +1,181 @@ +import type { DiscoveredTask } from "@features/setup/types"; +import type { Icon } from "@phosphor-icons/react"; +import { + ArrowRight, + Bug, + ChartLine, + Copy, + Flag, + Funnel, + Lightning, + Lock, + Trash, + Warning, + Wrench, +} from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; +import { motion } from "framer-motion"; + +const CATEGORY_CONFIG: Record< + DiscoveredTask["category"], + { icon: Icon; color: string } +> = { + bug: { icon: Bug, color: "red" }, + security: { icon: Lock, color: "red" }, + dead_code: { icon: Trash, color: "gray" }, + duplication: { icon: Copy, color: "orange" }, + performance: { icon: Lightning, color: "green" }, + stale_feature_flag: { icon: Flag, color: "amber" }, + error_tracking: { icon: Warning, color: "orange" }, + event_tracking: { icon: ChartLine, color: "blue" }, + funnel: { icon: Funnel, color: "violet" }, +}; + +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] ?? { + icon: Wrench, + color: "gray", + }; + const TaskIcon = config.icon; + const isCompact = variant === "compact"; + + const padding = isCompact ? "8px 10px" : "16px 18px"; + const gap = isCompact ? 10 : 14; + const iconBox = isCompact ? 24 : 32; + const iconSize = isCompact ? 14 : 18; + const titleSize = isCompact ? "1" : "2"; + const descClamp = isCompact ? "line-clamp-1" : "line-clamp-2"; + + return ( + onSelect(task)} + type="button" + className="cursor-pointer rounded-xl border border-(--gray-a3) bg-(--color-panel-solid) text-left transition-[border-color,box-shadow]" + style={{ + display: "flex", + alignItems: "flex-start", + gap, + padding, + boxShadow: "0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)", + width: "100%", + }} + 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 c2563bc63..bc75d76ca 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; isConnectingGithub: boolean; selectedProjectId: number | null; selectedDirectory: string; @@ -16,6 +17,7 @@ interface OnboardingStoreState { interface OnboardingStoreActions { setCurrentStep: (step: OnboardingStep) => void; completeOnboarding: () => void; + completeSetup: () => void; resetOnboarding: () => void; resetSelections: () => void; setConnectingGithub: (isConnecting: boolean) => void; @@ -28,6 +30,7 @@ type OnboardingStore = OnboardingStoreState & OnboardingStoreActions; const initialState: OnboardingStoreState = { currentStep: "welcome", hasCompletedOnboarding: false, + hasCompletedSetup: false, isConnectingGithub: false, selectedProjectId: null, selectedDirectory: "", @@ -43,6 +46,7 @@ export const useOnboardingStore = create()( log.info("completeOnboarding"); set({ hasCompletedOnboarding: true }); }, + completeSetup: () => set({ hasCompletedSetup: true }), resetOnboarding: () => set({ ...initialState }), resetSelections: () => set({ @@ -59,6 +63,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..c4d028c82 --- /dev/null +++ b/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailPane.tsx @@ -0,0 +1,230 @@ +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 { + Bug, + ChartLine, + Copy, + Flag, + Funnel, + type Icon, + Lightning, + Lock, + PlusIcon, + SparkleIcon, + Trash, + Warning, + Wrench, + 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"; + +const CATEGORY_CONFIG: Record< + DiscoveredTask["category"], + { icon: Icon; color: string; label: string } +> = { + 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" }, +}; + +interface DiscoveredTaskDetailPaneProps { + task: DiscoveredTask; + onClose: () => void; +} + +export function DiscoveredTaskDetailPane({ + task, + onClose, +}: DiscoveredTaskDetailPaneProps) { + const config = CATEGORY_CONFIG[task.category] ?? { + icon: Wrench, + color: "gray", + label: "Suggestion", + }; + 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 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 }); + }; + + 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 ( + <> + {/* ── Header bar ─ mirrors ReportDetailPane structure ──────── */} + + + + + Suggested + + + {task.title} + + + + + + + + {/* ── Body ─────────────────────────────────────────────────── */} + + + {/* Category meta row */} + + + + + + {config.label} + + {task.file && ( + <> + + · + + + {task.file} + {task.lineHint ? `:${task.lineHint}` : ""} + + + )} + + + {/* Description */} + + + {/* Impact */} + {task.impact && ( + + + Why it matters + + + + )} + + {/* Recommendation */} + {task.recommendation && ( + + + Suggested approach + + + + )} + + {/* Footnote */} + + Suggested locally from a quick scan of your codebase. Open it as a + task to investigate and fix. + + + + + {/* ── Bottom action bar ─ mirrors ReportTaskLogs visual ─────── */} + + + + + + ); +} + +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..31427572a --- /dev/null +++ b/apps/code/src/renderer/features/setup/components/RecommendedSetupTasks.tsx @@ -0,0 +1,72 @@ +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, + ); + + // Adapt to SignalReport shape so we can reuse ReportListRow exactly. + 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..475b426f4 --- /dev/null +++ b/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx @@ -0,0 +1,319 @@ +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..ffdade797 --- /dev/null +++ b/apps/code/src/renderer/features/setup/components/SetupView.tsx @@ -0,0 +1,246 @@ +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 { useRef } 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, + ); + const viewTrackedRef = useRef(false); + + useSetHeaderContent( + + + + Finish setup + + , + ); + + if (!viewTrackedRef.current) { + viewTrackedRef.current = true; + 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 }); + }; + + const handleSkip = () => { + track(ANALYTICS_EVENTS.SETUP_SKIPPED, { + discovery_status: useSetupStore.getState().discoveryStatus, + had_discovered_tasks: discoveredTasks.length > 0, + }); + completeSetup(); + navigateToTaskInput(); + }; + + return ( + + + + + + + + Setting up PostHog + + + We're configuring your integration and scanning for quick wins. + + + + + + {isWizardStarted && !wizardSkipped && ( + + + + )} + + + + + + + + + + + {isDiscoveryDone + ? "Pick a task to get started, or skip for now." + : "Hang tight while we get everything 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..9900ed06f --- /dev/null +++ b/apps/code/src/renderer/features/setup/prompts.ts @@ -0,0 +1,57 @@ +export const WIZARD_PROMPT = `You are a PostHog integration wizard. Your job is to set up PostHog in this repository by detecting the framework, installing the SDK and instrumenting the app. + +Follow these steps IN ORDER: + +STEP 1 — Detect the framework. +Examine the project to determine which framework is in use (Next.js, React, Vue, Svelte, Django, Flask, FastAPI, Rails, React Native, Angular, Astro, Laravel, Swift, Android, etc.). Check package.json, requirements.txt, build configs and directory structure. If you cannot detect a framework, pick the best language-level integration (JavaScript/Node, Python, Ruby). + +STEP 2 — Find and use the integration skill. +You have bundled PostHog skills available. Use the "instrument-integration" skill to integrate PostHog into this project. Read its SKILL.md and follow its workflow files in numbered order (e.g. 1.0-*, 1.1-*, 1.2-*). Each workflow file tells you what to do and which file comes next. + +STEP 3 — Set up environment variables. +Never hardcode PostHog API keys or tokens directly in source code. Create or update the appropriate .env file (.env.local, .env, etc.) with the PostHog public token and host using the environment variable naming convention for this framework. Reference these variables in code. + +STEP 4 — Install packages. +Use the project's package manager (npm, pnpm, yarn, bun, pip, poetry, etc.) to install the PostHog SDK. Start the install as a background task and continue with other work — do not block waiting for it. + +STEP 5 — Create a pull request. +Commit all changes with clear atomic commit messages. Then create a pull request with a descriptive title (e.g. "Add PostHog analytics integration") and a body summarizing what was instrumented. + +Rules: +- Before writing any file you MUST read it immediately beforehand, even if you read it earlier. +- Do not ask the user questions. Run autonomously with sensible defaults. +- Prefer minimal targeted edits. Do not refactor unrelated code. +- Focus on: product analytics, error tracking and session replay. +- Do not spawn subagents.`; + +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..5ce69e755 --- /dev/null +++ b/apps/code/src/renderer/features/setup/services/setupRunService.ts @@ -0,0 +1,655 @@ +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); + } + } +} + +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" | "worktree"> { + 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 worktree", { + error: err, + }); + } + return "worktree"; +} + +@injectable() +export class SetupRunService { + private discoverySubscription: { unsubscribe: () => void } | null = null; + private discoveryStartedAt: number | null = null; + // Synchronous guards to prevent duplicate kickoff between the early + // store-state check and the first await that flips the store to "running". + 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.discoverySubscription?.unsubscribe(); + this.discoverySubscription = 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; + } + + log.debug("Starting wizard task"); + try { + const client = await getAuthenticatedClient(); + 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)) { + 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); + 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 }) => { + 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); + }, + }); + + const result = await saga.run(sagaInput); + + if (!result.success) { + throw new Error( + `Wizard saga failed at step: ${result.failedStep ?? "unknown"}`, + ); + } + + const task = result.data.task; + useSetupStore.getState().setWizardTaskId(task.id); + queryClient.invalidateQueries({ queryKey: ["tasks", "list"] }); + this.subscribeToWizardEvents(task.id); + } catch (err) { + 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" }); + } + } + } + + private subscribeToWizardEvents(taskId: string): void { + const checkForRun = async () => { + const client = await getAuthenticatedClient(); + if (!client) return; + + for (let i = 0; i < 30; i++) { + await new Promise((r) => setTimeout(r, 2000)); + try { + const taskData = (await client.getTask(taskId)) as unknown as Task; + const runId = taskData.latest_run?.id; + if (runId) { + log.debug("Wizard run found, subscribing", { taskId, runId }); + 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 }); + }, + }, + ); + 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; + } + + try { + const authState = await fetchAuthState(); + 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 (!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; + + const taskRun = await client.createTaskRun(task.id); + 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, + }); + + 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) 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) 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, + }); + }; + + // The local agent's onStructuredOutput callback writes run.output to + // cloud in parallel with this session event, but the write often lags + // by a few seconds (it happens after the agent finishes its final + // prose). Exponential backoff catches it as soon as it lands without + // burning the 5s poll cycle. Falls through to pollForCompletion on + // timeout. + 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) { + await new Promise((r) => setTimeout(r, delay)); + if (completed) return; + try { + const run = await client.getTaskRun(task.id, taskRun.id); + if (completed) 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); + } + }; + + // Once StructuredOutput fires, the agent typically streams a final + // prose summary before completion. Surface that text in the activity + // feed so the user sees forward motion instead of a stuck spinner. + 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 pollForCompletion = async () => { + const maxAttempts = 120; + const intervalMs = 5000; + + for (let i = 0; i < maxAttempts; i++) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + if (completed) return; + + try { + const run = await client.getTaskRun(task.id, taskRun.id); + if (completed) 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; + } + + // Output may land before the cloud run's status flips terminal. + 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) => { + 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) { + 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" }); + } + } + } +} 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..a5f5a5889 --- /dev/null +++ b/apps/code/src/renderer/features/setup/stores/setupStore.ts @@ -0,0 +1,197 @@ +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", + // Persist only the discovered tasks and the "done" status so the + // suggestions survive restarts. Mid-flight orchestration state (run IDs, + // activity feeds, selection) is intentionally left in-memory. + 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..8e03a5dd6 --- /dev/null +++ b/apps/code/src/renderer/features/setup/types.ts @@ -0,0 +1,88 @@ +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; + /** Why this matters — concrete impact, blast radius, or risk. */ + impact?: string; + /** Suggested approach to fix, in plain prose. Not a complete patch. */ + 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/discoveredTaskToSignalReport.ts b/apps/code/src/renderer/features/setup/utils/discoveredTaskToSignalReport.ts new file mode 100644 index 000000000..663b84b7f --- /dev/null +++ b/apps/code/src/renderer/features/setup/utils/discoveredTaskToSignalReport.ts @@ -0,0 +1,34 @@ +import type { DiscoveredTask } from "@features/setup/types"; +import type { SignalReport } from "@shared/types"; + +/** + * Adapts a locally-discovered task into the `SignalReport` shape so we can + * reuse inbox row components (ReportListRow / ReportCardContent) verbatim. + * + * The shape is fake — these tasks are never persisted server-side and are + * not real signal reports. We pin status="ready" so no status badge renders, + * and leave priority/actionability null so those badges hide too. The + * title and summary slots carry the real content. + */ +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 47222ff98..f926d54c4 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -6,6 +6,7 @@ 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 { archiveTaskImperative, @@ -28,6 +29,7 @@ import { useSidebarData } from "../hooks/useSidebarData"; import { useTaskViewed } from "../hooks/useTaskViewed"; import { CommandCenterItem } from "./items/CommandCenterItem"; import { InboxItem, NewTaskItem } from "./items/HomeItem"; +import { SetupItem } from "./items/SetupItem"; import { SkillsItem } from "./items/SkillsItem"; import { SidebarItem } from "./SidebarItem"; import { TaskListView } from "./TaskListView"; @@ -40,6 +42,7 @@ function SidebarMenuComponent() { navigateToInbox, navigateToCommandCenter, navigateToSkills, + navigateToSetup, } = useNavigationStore(); const { data: allTasks = [] } = useTasks(); @@ -52,6 +55,10 @@ function SidebarMenuComponent() { const { archiveTask } = useArchiveTask(); const { togglePin } = usePinnedTasks(); + const hasCompletedSetup = useOnboardingStore( + (state) => state.hasCompletedSetup, + ); + const sidebarData = useSidebarData({ activeView: view, }); @@ -114,6 +121,10 @@ function SidebarMenuComponent() { navigateToSkills(); }; + const handleSetupClick = () => { + navigateToSetup(); + }; + const handleTaskClick = (taskId: string) => { const task = taskMap.get(taskId); if (task) { @@ -277,6 +288,15 @@ function SidebarMenuComponent() { /> + {!hasCompletedSetup && ( + + + + )} + void; +} + +export function SetupItem({ isActive, onClick }: SetupItemProps) { + return ( + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index 141049b25..073d9a7e9 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -40,6 +40,7 @@ export interface SidebarData { isInboxActive: boolean; isCommandCenterActive: boolean; isSkillsActive: boolean; + isSetupActive: boolean; isLoading: boolean; activeTaskId: string | null; pinnedTasks: TaskData[]; @@ -58,7 +59,8 @@ interface ViewState { | "inbox" | "archived" | "command-center" - | "skills"; + | "skills" + | "setup"; data?: Task; } @@ -123,6 +125,7 @@ export function useSidebarData({ const isInboxActive = activeView.type === "inbox"; const isCommandCenterActive = activeView.type === "command-center"; const isSkillsActive = activeView.type === "skills"; + const isSetupActive = activeView.type === "setup"; const activeTaskId = activeView.type === "task-detail" && activeView.data @@ -232,6 +235,7 @@ export function useSidebarData({ isInboxActive, isCommandCenterActive, isSkillsActive, + isSetupActive, isLoading, activeTaskId, pinnedTasks, diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 32232fd4f..b935b3d5c 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -19,7 +19,8 @@ type ViewType = | "inbox" | "archived" | "command-center" - | "skills"; + | "skills" + | "setup"; export interface TaskInputReportAssociation { reportId: string; @@ -60,6 +61,7 @@ interface NavigationStore { navigateToArchived: () => void; navigateToCommandCenter: () => void; navigateToSkills: () => void; + navigateToSetup: () => void; goBack: () => void; goForward: () => void; canGoBack: () => boolean; @@ -93,6 +95,9 @@ const isSameView = (view1: ViewState, view2: ViewState): boolean => { if (view1.type === "skills" && view2.type === "skills") { return true; } + if (view1.type === "setup" && view2.type === "setup") { + return true; + } return false; }; @@ -271,6 +276,10 @@ export const useNavigationStore = create()( navigate({ type: "skills" }); }, + 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..c44245bde 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,75 @@ 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; +} + +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 +365,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 +407,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 +443,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 +485,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.