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/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index 37cce59fe..b93c3e68b 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -11,6 +11,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 { SkillsView } from "@features/skills/components/SkillsView"; import { TaskDetail } from "@features/task-detail/components/TaskDetail"; @@ -99,6 +100,8 @@ export function MainLayout() { {view.type === "command-center" && } {view.type === "skills" && } + + {view.type === "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..5d90b2adc --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/context-collection/SuggestedTasks.tsx @@ -0,0 +1,151 @@ +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" }, +}; + +interface SuggestedTasksProps { + tasks: DiscoveredTask[]; + onSelectTask: (task: DiscoveredTask) => void; +} + +export function SuggestedTasks({ tasks, onSelectTask }: SuggestedTasksProps) { + if (tasks.length === 0) { + return ( + + No issues found. Your codebase looks clean! + + ); + } + + return ( + + {tasks.map((task, index) => { + const config = CATEGORY_CONFIG[task.category] ?? { + icon: Wrench, + color: "gray", + }; + const TaskIcon = config.icon; + return ( + onSelectTask(task)} + type="button" + style={{ + display: "flex", + alignItems: "flex-start", + gap: 14, + padding: "16px 18px", + backgroundColor: "var(--color-panel-solid)", + border: "1px solid var(--gray-a3)", + borderRadius: 12, + boxShadow: + "0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)", + cursor: "pointer", + textAlign: "left", + width: "100%", + transition: "border-color 0.15s ease, box-shadow 0.15s ease", + }} + 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/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/setup/components/SetupScanFeed.tsx b/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx new file mode 100644 index 000000000..7636cf890 --- /dev/null +++ b/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx @@ -0,0 +1,317 @@ +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...", + 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", +}; + +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..a60761be9 --- /dev/null +++ b/apps/code/src/renderer/features/setup/components/SetupView.tsx @@ -0,0 +1,241 @@ +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 { 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, + 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, + }); + completeSetup(); + navigateToTaskInput(); + }; + + 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 && ( + + + + )} + + + + + + + + + + + {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..0c1a0e83a --- /dev/null +++ b/apps/code/src/renderer/features/setup/hooks/useSetupRun.ts @@ -0,0 +1,479 @@ +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { fetchAuthState } from "@features/auth/hooks/authQueries"; +import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { + type ConnectParams, + getSessionService, +} from "@features/sessions/service/service"; +import { DISCOVERY_PROMPT, WIZARD_PROMPT } from "@features/setup/prompts"; +import { useSetupStore } from "@features/setup/stores/setupStore"; +import type { DiscoveredTask } from "@features/setup/types"; +import { TASK_DISCOVERY_JSON_SCHEMA } from "@features/setup/types"; +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 { useCallback, useEffect, useRef, useState } from "react"; + +const log = logger.scope("setup-run"); + +interface ActivityEntry { + id: number; + toolCallId: string; + tool: string; + filePath: string | null; + title: string; +} + +function handleSessionUpdate( + payload: unknown, + pushActivity: (entry: ActivityEntry) => 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); + } + } +} + +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 }; +} + +export function useSetupRun() { + const selectedDirectory = useOnboardingStore((s) => s.selectedDirectory); + const discoveryStatus = useSetupStore((s) => s.discoveryStatus); + const storedTasks = useSetupStore((s) => s.discoveredTasks); + const storedWizardTaskId = useSetupStore((s) => s.wizardTaskId); + const discoveryFeed = useSetupStore((s) => s.discoveryFeed); + const wizardFeed = useSetupStore((s) => s.wizardFeed); + + const [isDiscoveryDone, setIsDiscoveryDone] = useState( + discoveryStatus === "done", + ); + const [discoveredTasks, setDiscoveredTasks] = + useState(storedTasks); + const [isWizardStarted, setIsWizardStarted] = useState(!!storedWizardTaskId); + const [error, setError] = useState(null); + + const startedRef = useRef(false); + const discoveryStartedAtRef = useRef(null); + + const subscribeToWizardEvents = useCallback((taskId: string) => { + 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 }), + ); + }, []); + + const startWizardTask = useCallback(async () => { + const existingId = useSetupStore.getState().wizardTaskId; + if (existingId) { + log.debug("Wizard task already exists, skipping", { + wizardTaskId: existingId, + }); + setIsWizardStarted(true); + 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; + } + + const repoPath = selectedDirectory; + if (!repoPath) { + log.warn("No selectedDirectory for wizard task"); + track(ANALYTICS_EVENTS.SETUP_WIZARD_FAILED, { + reason: "missing_directory", + }); + return; + } + + const task = (await client.createTask({ + title: "Set up PostHog integration", + description: WIZARD_PROMPT, + })) as unknown as Task; + + useSetupStore.getState().setWizardTaskId(task.id); + setIsWizardStarted(true); + track(ANALYTICS_EVENTS.SETUP_WIZARD_STARTED, { + wizard_task_id: task.id, + }); + + queryClient.setQueryData(["tasks", "list"], (old) => + old ? [task, ...old] : [task], + ); + queryClient.invalidateQueries({ queryKey: ["tasks", "list"] }); + + const connectParams: ConnectParams = { + task, + repoPath, + executionMode: "auto", + initialPrompt: [{ type: "text", text: WIZARD_PROMPT }], + }; + + getSessionService().connectToTask(connectParams); + + 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" }); + } + } + }, [selectedDirectory, subscribeToWizardEvents]); + + const startDiscovery = useCallback(async () => { + 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 }); + setError("Authentication required."); + useSetupStore.getState().failDiscovery(); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + reason: "startup_error", + error_message: "missing_auth", + }); + return; + } + + const client = await getAuthenticatedClient(); + if (!client) { + setError("Authentication required."); + useSetupStore.getState().failDiscovery(); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + reason: "startup_error", + error_message: "unauthenticated_client", + }); + return; + } + + const repoPath = selectedDirectory; + if (!repoPath) { + setError("No directory selected."); + useSetupStore.getState().failDiscovery(); + 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); + discoveryStartedAtRef.current = 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, + 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 }); + }); + + const subscription = trpcClient.agent.onSessionEvent.subscribe( + { taskRunId: taskRun.id }, + { + onData: (payload: unknown) => { + handleSessionUpdate(payload, (entry) => { + useSetupStore.getState().pushDiscoveryActivity(entry); + }); + }, + onError: (err) => { + log.error("Discovery subscription error", { error: err }); + }, + }, + ); + + const pollForCompletion = async () => { + const maxAttempts = 120; + const intervalMs = 5000; + + for (let i = 0; i < maxAttempts; i++) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + + try { + const run = await client.getTaskRun(task.id, taskRun.id); + + if (isTerminalStatus(run.status)) { + subscription.unsubscribe(); + + const startedAt = discoveryStartedAtRef.current; + const durationSeconds = startedAt + ? Math.round((Date.now() - startedAt) / 1000) + : 0; + + if (run.status === "completed" && run.output) { + const output = run.output as { tasks?: DiscoveredTask[] }; + const tasks = output.tasks ?? []; + log.info("Discovery completed", { taskCount: tasks.length }); + useSetupStore.getState().completeDiscovery(tasks); + setDiscoveredTasks(tasks); + setIsDiscoveryDone(true); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_COMPLETED, { + discovery_task_id: task.id, + discovery_task_run_id: taskRun.id, + task_count: tasks.length, + duration_seconds: durationSeconds, + }); + } else if ( + run.status === "failed" || + run.status === "cancelled" + ) { + log.error("Discovery failed", { status: run.status }); + useSetupStore.getState().failDiscovery(); + setError("Discovery failed. You can skip or retry."); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + discovery_task_id: task.id, + discovery_task_run_id: taskRun.id, + reason: run.status, + }); + } else { + useSetupStore.getState().completeDiscovery([]); + setDiscoveredTasks([]); + setIsDiscoveryDone(true); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_COMPLETED, { + discovery_task_id: task.id, + discovery_task_run_id: taskRun.id, + task_count: 0, + duration_seconds: durationSeconds, + }); + } + return; + } + } catch (err) { + log.warn("Failed to poll discovery", { + attempt: i + 1, + error: err, + }); + } + } + + subscription.unsubscribe(); + useSetupStore.getState().failDiscovery(); + setError("Discovery timed out. You can skip or retry."); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + discovery_task_id: task.id, + discovery_task_run_id: taskRun.id, + reason: "timeout", + }); + }; + + pollForCompletion().catch((err) => { + log.error("Discovery poll failed", { error: err }); + useSetupStore.getState().failDiscovery(); + setError("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 }); + useSetupStore.getState().failDiscovery(); + const message = + err instanceof Error ? err.message : "Failed to start discovery."; + setError(message); + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + reason: "startup_error", + error_message: message, + }); + if (err instanceof Error) { + captureException(err, { scope: "setup.start_discovery" }); + } + } + }, [selectedDirectory]); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + if (discoveryStatus === "done") { + setDiscoveredTasks(storedTasks); + setIsDiscoveryDone(true); + return; + } + + startWizardTask().catch((err) => { + log.error("Wizard task startup failed", { error: err }); + }); + + startDiscovery().catch((err) => { + log.error("Discovery startup failed", { error: err }); + }); + }, [discoveryStatus, storedTasks, startWizardTask, startDiscovery]); + + return { + discoveryFeed, + wizardFeed, + isDiscoveryDone, + isWizardStarted, + discoveredTasks, + wizardTaskId: storedWizardTaskId, + 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..ddb93cf03 --- /dev/null +++ b/apps/code/src/renderer/features/setup/prompts.ts @@ -0,0 +1,39 @@ +export const WIZARD_PROMPT = `You are setting up PostHog analytics in this repository. + +Your job: +1. Look at the available skills (use the /skill slash command or browse .claude/skills/). +2. Detect which frameworks and tools this repo uses. +3. Pick the most relevant PostHog skills for this repo and execute them one by one. +4. After making all changes, create a pull request with a clear title and description summarizing what was instrumented. + +Focus on: product analytics, error tracking and session replay instrumentation. +Do not ask the user questions. Run autonomously and make sensible default choices. +Commit your work with clear commit messages as you go.`; + +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. +- Prioritize by impact. Lead with the findings that would 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/stores/setupStore.ts b/apps/code/src/renderer/features/setup/stores/setupStore.ts new file mode 100644 index 000000000..6ba298e5f --- /dev/null +++ b/apps/code/src/renderer/features/setup/stores/setupStore.ts @@ -0,0 +1,141 @@ +import type { DiscoveredTask } from "@features/setup/types"; +import { logger } from "@utils/logger"; +import { create } from "zustand"; + +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; + discoveryFeed: AgentFeedState; + wizardFeed: AgentFeedState; +} + +interface SetupStoreActions { + startDiscovery: (taskId: string, taskRunId: string) => void; + completeDiscovery: (tasks: DiscoveredTask[]) => void; + failDiscovery: () => void; + resetDiscovery: () => void; + setWizardTaskId: (taskId: string) => void; + pushDiscoveryActivity: (entry: ActivityEntry) => void; + pushWizardActivity: (entry: ActivityEntry) => void; +} + +type SetupStore = SetupStoreState & SetupStoreActions; + +const initialState: SetupStoreState = { + discoveredTasks: [], + discoveryStatus: "idle", + discoveryTaskId: null, + discoveryTaskRunId: null, + wizardTaskId: null, + discoveryFeed: EMPTY_FEED, + wizardFeed: EMPTY_FEED, +}; + +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()((set) => ({ + ...initialState, + + startDiscovery: (taskId, taskRunId) => { + log.info("Discovery started", { taskId, taskRunId }); + set({ + discoveryStatus: "running", + discoveryTaskId: taskId, + discoveryTaskRunId: taskRunId, + discoveredTasks: [], + discoveryFeed: EMPTY_FEED, + }); + }, + + completeDiscovery: (tasks) => { + log.info("Discovery completed", { taskCount: tasks.length }); + set({ + discoveryStatus: "done", + discoveredTasks: tasks, + }); + }, + + failDiscovery: () => { + log.warn("Discovery failed"); + set({ discoveryStatus: "error" }); + }, + + resetDiscovery: () => { + log.info("Discovery reset"); + set({ + discoveryStatus: "idle", + discoveryTaskId: null, + discoveryTaskRunId: null, + discoveredTasks: [], + discoveryFeed: EMPTY_FEED, + }); + }, + + setWizardTaskId: (taskId) => { + log.info("Wizard task created", { taskId }); + set({ wizardTaskId: taskId }); + }, + + pushDiscoveryActivity: (entry) => { + set((state) => ({ + discoveryFeed: pushEntry(state.discoveryFeed, entry), + })); + }, + + pushWizardActivity: (entry) => { + set((state) => ({ + wizardFeed: pushEntry(state.wizardFeed, entry), + })); + }, +})); 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..deab6d1fb --- /dev/null +++ b/apps/code/src/renderer/features/setup/types.ts @@ -0,0 +1,66 @@ +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; +} + +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: "One-line summary of the task", + }, + description: { + type: "string", + description: + "2-3 sentences explaining the problem and what to fix, including file path and line if known", + }, + 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", + }, + }, + required: ["id", "title", "description", "category"], + }, + maxItems: 4, + }, + }, + required: ["tasks"], +} as const; 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 cf8d21cab..44b8e6b8d 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -43,6 +43,7 @@ export interface SidebarData { isInboxActive: boolean; isCommandCenterActive: boolean; isSkillsActive: boolean; + isSetupActive: boolean; isLoading: boolean; activeTaskId: string | null; pinnedTasks: TaskData[]; @@ -61,7 +62,8 @@ interface ViewState { | "inbox" | "archived" | "command-center" - | "skills"; + | "skills" + | "setup"; data?: Task; } @@ -171,6 +173,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 @@ -280,6 +283,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 336c77a2b..e8c103dd7 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"; interface ViewState { type: ViewType; @@ -39,6 +40,7 @@ interface NavigationStore { navigateToArchived: () => void; navigateToCommandCenter: () => void; navigateToSkills: () => void; + navigateToSetup: () => void; goBack: () => void; goForward: () => void; canGoBack: () => boolean; @@ -69,6 +71,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; }; @@ -183,6 +188,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 1c612c4aa..8b425ac70 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -116,6 +116,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; @@ -236,6 +255,62 @@ 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; +} + +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 SetupSkippedProperties { + discovery_status: "idle" | "running" | "done" | "error"; + had_discovered_tasks: boolean; +} + +export interface SetupWizardStartedProperties { + wizard_task_id: string; +} + +export interface SetupWizardFailedProperties { + reason: "unauthenticated_client" | "missing_directory" | "startup_error"; + error_message?: string; +} + // Event names as constants export const ANALYTICS_EVENTS = { // App lifecycle @@ -263,6 +338,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", @@ -301,6 +379,16 @@ 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_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", @@ -326,6 +414,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; @@ -364,6 +455,16 @@ 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_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;