diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index 726e6f177..0ef691851 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -33,9 +33,6 @@ import { } from "@features/inbox/utils/filterReports"; import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; import { setPendingInboxOpenMethod } from "@features/inbox/utils/pendingInboxOpenMethod"; -import { DiscoveredTaskDetailPane } from "@features/setup/components/DiscoveredTaskDetailPane"; -import { RecommendedSetupTasks } from "@features/setup/components/RecommendedSetupTasks"; -import { useSetupStore } from "@features/setup/stores/setupStore"; import { useIntegrations, useRepositoryIntegration, @@ -361,9 +358,6 @@ 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) { setPendingInboxOpenMethod("click_shift"); selectRange( @@ -444,28 +438,6 @@ 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 = @@ -473,10 +445,7 @@ export function InboxSignalsTab() { suggestedReviewerFilter.length > 0 || statusFilter.length < 5; const shouldShowTwoPane = - hasReports || - !!searchQuery.trim() || - hasActiveFilters || - hasDiscoveredTasks; + hasReports || !!searchQuery.trim() || hasActiveFilters; // Sticky: once we enter two-pane mode, stay there even if a refetch // momentarily empties the list (e.g. when sort order changes). @@ -759,9 +728,6 @@ export function InboxSignalsTab() { onReportAction={tracker.signalAction} /> - - ) : selectedDiscoveredTask ? ( - ) : ( )} diff --git a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts index af78b17a1..3c8932d97 100644 --- a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts +++ b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts @@ -5,6 +5,7 @@ import { ANALYTICS_EVENTS, type RepositoryProvider, } from "@shared/types/analytics"; +import { useActiveRepoStore } from "@stores/activeRepoStore"; import { track } from "@utils/analytics"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ONBOARDING_STEPS, type OnboardingStep } from "../types"; @@ -32,12 +33,8 @@ export interface DetectedRepo { export function useOnboardingFlow() { const currentStep = useOnboardingStore((state) => state.currentStep); const setCurrentStep = useOnboardingStore((state) => state.setCurrentStep); - const selectedDirectory = useOnboardingStore( - (state) => state.selectedDirectory, - ); - const setSelectedDirectory = useOnboardingStore( - (state) => state.setSelectedDirectory, - ); + const selectedDirectory = useActiveRepoStore((state) => state.path); + const setSelectedDirectory = useActiveRepoStore((state) => state.setPath); const directionRef = useRef<1 | -1>(1); const [detectedRepo, setDetectedRepo] = useState(null); diff --git a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts index 1835733ac..73eecc0ec 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts @@ -9,7 +9,6 @@ interface OnboardingStoreState { currentStep: OnboardingStep; hasCompletedOnboarding: boolean; selectedProjectId: number | null; - selectedDirectory: string; } interface OnboardingStoreActions { @@ -18,7 +17,6 @@ interface OnboardingStoreActions { resetOnboarding: () => void; resetSelections: () => void; selectProjectId: (projectId: number | null) => void; - setSelectedDirectory: (path: string) => void; } type OnboardingStore = OnboardingStoreState & OnboardingStoreActions; @@ -27,7 +25,6 @@ const initialState: OnboardingStoreState = { currentStep: "welcome", hasCompletedOnboarding: false, selectedProjectId: null, - selectedDirectory: "", }; export const useOnboardingStore = create()( @@ -47,7 +44,6 @@ export const useOnboardingStore = create()( selectedProjectId: null, }), selectProjectId: (selectedProjectId) => set({ selectedProjectId }), - setSelectedDirectory: (selectedDirectory) => set({ selectedDirectory }), }), { name: "onboarding-store", @@ -55,7 +51,6 @@ export const useOnboardingStore = create()( currentStep: state.currentStep, hasCompletedOnboarding: state.hasCompletedOnboarding, selectedProjectId: state.selectedProjectId, - selectedDirectory: state.selectedDirectory, }), }, ), diff --git a/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailPane.tsx b/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailDialog.tsx similarity index 64% rename from apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailPane.tsx rename to apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailDialog.tsx index 5c80b0c8e..ac94649dd 100644 --- a/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailPane.tsx +++ b/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailDialog.tsx @@ -1,7 +1,7 @@ import { Badge } from "@components/ui/Badge"; +import { Button } from "@components/ui/Button"; 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"; @@ -10,26 +10,58 @@ import { FALLBACK_CATEGORY_CONFIG, } from "@features/setup/utils/categoryConfig"; import { useDetectedCloudRepository } from "@hooks/useDetectedCloudRepository"; -import { PlusIcon, SparkleIcon, X as XIcon } from "@phosphor-icons/react"; -import { Box, Button, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { PlusIcon, SparkleIcon } from "@phosphor-icons/react"; +import { + Box, + Dialog, + Flex, + ScrollArea, + Text, + VisuallyHidden, +} from "@radix-ui/themes"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { useActiveRepoStore } from "@stores/activeRepoStore"; import { useNavigationStore } from "@stores/navigationStore"; import { track } from "@utils/analytics"; -interface DiscoveredTaskDetailPaneProps { - task: DiscoveredTask; +interface DiscoveredTaskDetailDialogProps { + task: DiscoveredTask | null; onClose: () => void; } -export function DiscoveredTaskDetailPane({ +export function DiscoveredTaskDetailDialog({ + task, + onClose, +}: DiscoveredTaskDetailDialogProps) { + return ( + { + if (!open) onClose(); + }} + > + + + {task?.title ?? "Suggestion"} + + {task && } + + + ); +} + +function DialogBody({ task, onClose, -}: DiscoveredTaskDetailPaneProps) { +}: { + task: DiscoveredTask; + onClose: () => void; +}) { const config = CATEGORY_CONFIG[task.category] ?? FALLBACK_CATEGORY_CONFIG; const CategoryIcon = config.icon; const tasks = useSetupStore((s) => s.discoveredTasks); - const selectedDirectory = useOnboardingStore((s) => s.selectedDirectory); + const selectedDirectory = useActiveRepoStore((s) => s.path); const navigateToTaskInput = useNavigationStore((s) => s.navigateToTaskInput); const { folders } = useFolders(); const detectedCloudRepository = useDetectedCloudRepository(selectedDirectory); @@ -45,7 +77,10 @@ export function DiscoveredTaskDetailPane({ const initialPrompt = buildDiscoveredTaskPrompt(task); const folderId = folders.find((f) => f.path === selectedDirectory)?.id; - useSetupStore.getState().removeDiscoveredTask(task.id); + useSetupStore + .getState() + .removeDiscoveredTask(task.id, task.repoPath ?? null); + onClose(); navigateToTaskInput({ initialPrompt, folderId, @@ -61,48 +96,33 @@ export function DiscoveredTaskDetailPane({ position: position >= 0 ? position : 0, total_discovered: tasks.length, }); - useSetupStore.getState().removeDiscoveredTask(task.id); + useSetupStore + .getState() + .removeDiscoveredTask(task.id, task.repoPath ?? null); + onClose(); }; return ( - <> - - - - - Suggested - - - {task.title} - - - - - + + + + + Suggested + + + {task.title} + - - + + @@ -158,32 +178,16 @@ export function DiscoveredTaskDetailPane({ - - - - + ); } diff --git a/apps/code/src/renderer/features/setup/components/RecommendedSetupTasks.tsx b/apps/code/src/renderer/features/setup/components/RecommendedSetupTasks.tsx deleted file mode 100644 index afcf0da53..000000000 --- a/apps/code/src/renderer/features/setup/components/RecommendedSetupTasks.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Badge } from "@components/ui/Badge"; -import { ReportListRow } from "@features/inbox/components/list/ReportListRow"; -import { useSetupStore } from "@features/setup/stores/setupStore"; -import { discoveredTaskToSignalReport } from "@features/setup/utils/discoveredTaskToSignalReport"; -import { SparkleIcon } from "@phosphor-icons/react"; -import { Flex, Text, Tooltip } from "@radix-ui/themes"; -import { useMemo } from "react"; - -interface RecommendedSetupTasksProps { - onSelectTask: (taskId: string) => void; -} - -export function RecommendedSetupTasks({ - onSelectTask, -}: RecommendedSetupTasksProps) { - const tasks = useSetupStore((s) => s.discoveredTasks); - const discoveryStatus = useSetupStore((s) => s.discoveryStatus); - const selectedDiscoveredTaskId = useSetupStore( - (s) => s.selectedDiscoveredTaskId, - ); - - const fakeReports = useMemo( - () => tasks.map(discoveredTaskToSignalReport), - [tasks], - ); - - if (tasks.length === 0) return null; - - return ( - - {discoveryStatus === "running" && ( - - - scanning for more… - - - )} - {fakeReports.map((report, index) => ( - onSelectTask(report.id)} - onToggleChecked={() => {}} - iconOverride={ - - - - - - } - prependBadges={ - - - Suggested - - } - /> - ))} - - ); -} diff --git a/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts b/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts index 7281f0bcc..c88d62bdf 100644 --- a/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts +++ b/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts @@ -1,27 +1,28 @@ -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"; +import { useActiveRepoStore } from "@stores/activeRepoStore"; +import { useEffect } from "react"; export function useSetupDiscovery() { - const selectedDirectory = useOnboardingStore((s) => s.selectedDirectory); - const discoveryStatus = useSetupStore((s) => s.discoveryStatus); - - const startedRef = useRef(false); + const selectedDirectory = useActiveRepoStore((s) => s.path); + // Discovery is a one-time-per-user agent run; once any repo has triggered + // it we never auto-launch another one from this hook. Errored/interrupted + // runs require explicit user retry (see setupStore partialize and #2257). + // Enricher runs per repo on every selection (gated on per-repo status + // inside the service). + const discoveryEverStarted = useSetupStore((s) => + Object.values(s.discoveryByRepo).some((d) => d.status !== "idle"), + ); useEffect(() => { - if (startedRef.current) return; - // Only auto-fire from a clean "idle" state. "done" needs no rerun, and - // "error" (which now includes interrupted runs persisted across boots — - // see setupStore partialize) requires an explicit user retry to recover. - if (discoveryStatus !== "idle") return; if (!selectedDirectory) return; - - startedRef.current = true; - get(RENDERER_TOKENS.SetupRunService).startSetup( - selectedDirectory, - ); - }, [discoveryStatus, selectedDirectory]); + const service = get(RENDERER_TOKENS.SetupRunService); + if (discoveryEverStarted) { + service.startEnricherForRepo(selectedDirectory); + } else { + service.startSetup(selectedDirectory); + } + }, [discoveryEverStarted, selectedDirectory]); } diff --git a/apps/code/src/renderer/features/setup/services/setupRunService.ts b/apps/code/src/renderer/features/setup/services/setupRunService.ts index 71f63f576..0f0dddb19 100644 --- a/apps/code/src/renderer/features/setup/services/setupRunService.ts +++ b/apps/code/src/renderer/features/setup/services/setupRunService.ts @@ -1,7 +1,11 @@ import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; import { fetchAuthState } from "@features/auth/hooks/authQueries"; import { buildDiscoveryPrompt } from "@features/setup/prompts"; -import { useSetupStore } from "@features/setup/stores/setupStore"; +import { + selectRepoDiscovery, + selectRepoEnricher, + useSetupStore, +} from "@features/setup/stores/setupStore"; import { buildTaskDiscoverySchema, type DiscoveredTask, @@ -236,8 +240,8 @@ function buildPosthogSetupSuggestion( @injectable() export class SetupRunService { - private discoveryStarting = false; - private enricherSuggestionsRunning = false; + private discoveryStartingByRepo = new Set(); + private enricherSuggestionsRunningByRepo = new Set(); startSetup(directory: string): void { // Defense in depth: never auto-run from a non-idle persisted state. @@ -245,31 +249,51 @@ export class SetupRunService { // path could otherwise re-enter the loop that wedged users on boot — // creating fresh cloud tasks and a tree-sitter parse storm against the // user's repo on every launch. - const status = useSetupStore.getState().discoveryStatus; + const status = selectRepoDiscovery( + useSetupStore.getState(), + directory, + ).status; if (status !== "idle") return; this.injectEnricherSuggestions(directory); this.startDiscovery(directory); } + startEnricherForRepo(directory: string): void { + this.injectEnricherSuggestions(directory); + } + startDiscovery(directory: string): void { - if (this.discoveryStarting) return; - const status = useSetupStore.getState().discoveryStatus; + if (!directory) return; + if (this.discoveryStartingByRepo.has(directory)) return; + const status = selectRepoDiscovery( + useSetupStore.getState(), + directory, + ).status; if (status === "running" || status === "done") return; - this.discoveryStarting = true; + this.discoveryStartingByRepo.add(directory); this.runDiscovery(directory) .catch((err) => { log.error("Discovery startup failed", { error: err }); }) .finally(() => { - this.discoveryStarting = false; + this.discoveryStartingByRepo.delete(directory); }); } injectEnricherSuggestions(directory: string): void { if (!directory) return; - if (this.enricherSuggestionsRunning) return; - this.enricherSuggestionsRunning = true; - useSetupStore.getState().startEnrichment(); + if (this.enricherSuggestionsRunningByRepo.has(directory)) return; + // Once per repo per success. "done" survives across boots via partialize + // so re-selecting a previously-enriched repo doesn't re-hit the PostHog + // install-state and stale-flag APIs. "error" and "idle" fall through so + // a transient failure can retry on the next selection. + const enricherStatus = selectRepoEnricher( + useSetupStore.getState(), + directory, + ).status; + if (enricherStatus === "done" || enricherStatus === "running") return; + this.enricherSuggestionsRunningByRepo.add(directory); + useSetupStore.getState().startEnrichment(directory); void (async () => { try { @@ -279,20 +303,24 @@ export class SetupRunService { }); if (installState === "initialized") { - useSetupStore - .getState() - .addEnricherSuggestionIfMissing(buildSdkHealthSuggestion()); + useSetupStore.getState().addEnricherSuggestionIfMissing({ + ...buildSdkHealthSuggestion(), + repoPath: directory, + }); await this.injectStaleFlagSuggestions(directory); } else { const suggestion = buildPosthogSetupSuggestion(installState); - useSetupStore.getState().addEnricherSuggestionIfMissing(suggestion); + useSetupStore.getState().addEnricherSuggestionIfMissing({ + ...suggestion, + repoPath: directory, + }); } - useSetupStore.getState().completeEnrichment(); + useSetupStore.getState().completeEnrichment(directory); } catch (err) { log.warn("Enricher run failed", { error: err }); - useSetupStore.getState().failEnrichment(); + useSetupStore.getState().failEnrichment(directory); } finally { - this.enricherSuggestionsRunning = false; + this.enricherSuggestionsRunningByRepo.delete(directory); } })(); } @@ -304,7 +332,10 @@ export class SetupRunService { }); const store = useSetupStore.getState(); for (const flag of flags) { - store.addEnricherSuggestionIfMissing(buildStaleFlagSuggestion(flag)); + store.addEnricherSuggestionIfMissing({ + ...buildStaleFlagSuggestion(flag), + repoPath: directory, + }); } } catch (err) { log.warn("Failed to find stale flag suggestions", { error: err }); @@ -312,11 +343,11 @@ export class SetupRunService { } private async runDiscovery(directory: string): Promise { - const state = useSetupStore.getState(); - if ( - state.discoveryStatus === "done" || - state.discoveryStatus === "running" - ) { + const status = selectRepoDiscovery( + useSetupStore.getState(), + directory, + ).status; + if (status === "done" || status === "running") { return; } @@ -333,7 +364,9 @@ export class SetupRunService { if (!apiHost || !projectId) { log.error("Missing auth for discovery", { apiHost, projectId }); - useSetupStore.getState().failDiscovery("Authentication required."); + useSetupStore + .getState() + .failDiscovery(directory, "Authentication required."); track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { reason: "startup_error", error_message: "missing_auth", @@ -344,7 +377,9 @@ export class SetupRunService { const client = await getAuthenticatedClient(); if (abort.signal.aborted) return; if (!client) { - useSetupStore.getState().failDiscovery("Authentication required."); + useSetupStore + .getState() + .failDiscovery(directory, "Authentication required."); track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { reason: "startup_error", error_message: "unauthenticated_client", @@ -353,7 +388,9 @@ export class SetupRunService { } if (!directory) { - useSetupStore.getState().failDiscovery("No directory selected."); + useSetupStore + .getState() + .failDiscovery(directory, "No directory selected."); track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { reason: "startup_error", error_message: "missing_directory", @@ -380,7 +417,7 @@ export class SetupRunService { throw new Error("Failed to create discovery task run"); } - useSetupStore.getState().startDiscovery(task.id, taskRun.id); + useSetupStore.getState().startDiscovery(directory, task.id, taskRun.id); track(ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED, { discovery_task_id: task.id, discovery_task_run_id: taskRun.id, @@ -430,7 +467,7 @@ export class SetupRunService { taskCount: tasks.length, signalSource, }); - useSetupStore.getState().completeDiscovery(tasks); + useSetupStore.getState().completeDiscovery(directory, tasks); track(ANALYTICS_EVENTS.SETUP_DISCOVERY_COMPLETED, { discovery_task_id: task.id, discovery_task_run_id: taskRun.id, @@ -449,7 +486,7 @@ export class SetupRunService { subscription?.unsubscribe(); log.error("Discovery failed", { reason }); - useSetupStore.getState().failDiscovery(message); + useSetupStore.getState().failDiscovery(directory, message); track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { discovery_task_id: task.id, discovery_task_run_id: taskRun.id, @@ -496,7 +533,7 @@ export class SetupRunService { if (!structuredOutputSeen) return; wrapupBuffer = (wrapupBuffer + text).slice(-200); activityIdCounter += 1; - useSetupStore.getState().pushDiscoveryActivity({ + useSetupStore.getState().pushDiscoveryActivity(directory, { id: activityIdCounter, toolCallId: WRAPUP_TOOL_CALL_ID, tool: "WrappingUp", @@ -512,7 +549,9 @@ export class SetupRunService { handleSessionUpdate( payload, (entry) => { - useSetupStore.getState().pushDiscoveryActivity(entry); + useSetupStore + .getState() + .pushDiscoveryActivity(directory, entry); if (entry.tool === "StructuredOutput") { structuredOutputSeen = true; handleStructuredOutputSignal().catch((err) => @@ -595,7 +634,7 @@ export class SetupRunService { subscription?.unsubscribe(); useSetupStore .getState() - .failDiscovery("Discovery failed unexpectedly."); + .failDiscovery(directory, "Discovery failed unexpectedly."); track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { discovery_task_id: task.id, discovery_task_run_id: taskRun.id, @@ -613,7 +652,7 @@ export class SetupRunService { log.error("Failed to start discovery", { error: err }); const message = err instanceof Error ? err.message : "Failed to start discovery."; - useSetupStore.getState().failDiscovery(message); + useSetupStore.getState().failDiscovery(directory, message); track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { reason: "startup_error", error_message: message, diff --git a/apps/code/src/renderer/features/setup/stores/setupStore.ts b/apps/code/src/renderer/features/setup/stores/setupStore.ts index 4a7ac7553..3a0a31079 100644 --- a/apps/code/src/renderer/features/setup/stores/setupStore.ts +++ b/apps/code/src/renderer/features/setup/stores/setupStore.ts @@ -22,55 +22,98 @@ export interface AgentFeedState { recentEntries: ActivityEntry[]; } +export interface RepoDiscoveryState { + status: DiscoveryStatus; + taskId: string | null; + taskRunId: string | null; + feed: AgentFeedState; + error: string | null; +} + +export interface RepoEnricherState { + status: EnricherStatus; +} + const EMPTY_FEED: AgentFeedState = { currentTool: null, currentFilePath: null, recentEntries: [], }; +const DEFAULT_DISCOVERY: RepoDiscoveryState = { + status: "idle", + taskId: null, + taskRunId: null, + feed: EMPTY_FEED, + error: null, +}; + +const DEFAULT_ENRICHER: RepoEnricherState = { status: "idle" }; + interface SetupStoreState { discoveredTasks: DiscoveredTask[]; - discoveryStatus: DiscoveryStatus; - discoveryTaskId: string | null; - discoveryTaskRunId: string | null; - discoveryFeed: AgentFeedState; - enricherStatus: EnricherStatus; - error: string | null; - selectedDiscoveredTaskId: string | null; + discoveryByRepo: Record; + enricherByRepo: Record; } interface SetupStoreActions { - startDiscovery: (taskId: string, taskRunId: string) => void; - completeDiscovery: (tasks: DiscoveredTask[]) => void; - failDiscovery: (message?: string) => void; - resetDiscovery: () => void; - startEnrichment: () => void; - completeEnrichment: () => void; - failEnrichment: () => void; - removeDiscoveredTask: (taskId: string) => void; - selectDiscoveredTask: (taskId: string | null) => void; + startDiscovery: (repoPath: string, taskId: string, taskRunId: string) => void; + completeDiscovery: (repoPath: string, tasks: DiscoveredTask[]) => void; + failDiscovery: (repoPath: string, message?: string) => void; + resetDiscovery: (repoPath: string) => void; + startEnrichment: (repoPath: string) => void; + completeEnrichment: (repoPath: string) => void; + failEnrichment: (repoPath: string) => void; + removeDiscoveredTask: (taskId: string, repoPath: string | null) => void; addEnricherSuggestionIfMissing: (task: DiscoveredTask) => void; - pushDiscoveryActivity: (entry: ActivityEntry) => void; + pushDiscoveryActivity: (repoPath: string, entry: ActivityEntry) => void; resetSetup: () => void; } type SetupStore = SetupStoreState & SetupStoreActions; +interface PersistedSetupState { + discoveredTasks: DiscoveredTask[]; + discoveryByRepo: Record; + enricherByRepo: Record; +} + const initialState: SetupStoreState = { discoveredTasks: [], - discoveryStatus: "idle", - discoveryTaskId: null, - discoveryTaskRunId: null, - discoveryFeed: EMPTY_FEED, - enricherStatus: "idle", - error: null, - selectedDiscoveredTaskId: null, + discoveryByRepo: {}, + enricherByRepo: {}, }; -// Discovery resets only clear agent-source suggestions; enricher-source -// suggestions are deterministic and survive across runs. -function keepEnricherSuggestions(tasks: DiscoveredTask[]): DiscoveredTask[] { - return tasks.filter((t) => t.source === "enricher"); +export function selectRepoDiscovery( + state: SetupStoreState, + repoPath: string | null, +): RepoDiscoveryState { + if (!repoPath) return DEFAULT_DISCOVERY; + return state.discoveryByRepo[repoPath] ?? DEFAULT_DISCOVERY; +} + +export function selectRepoEnricher( + state: SetupStoreState, + repoPath: string | null, +): RepoEnricherState { + if (!repoPath) return DEFAULT_ENRICHER; + return state.enricherByRepo[repoPath] ?? DEFAULT_ENRICHER; +} + +function isTaskForRepo(task: DiscoveredTask, repoPath: string | null): boolean { + if (!repoPath) return !task.repoPath; + return task.repoPath === repoPath; +} + +// Discovery resets only clear agent-source suggestions for the affected repo; +// enricher-source suggestions are deterministic and survive across runs. +function dropAgentTasksForRepo( + tasks: DiscoveredTask[], + repoPath: string, +): DiscoveredTask[] { + return tasks.filter( + (t) => !(t.source === "agent" && isTaskForRepo(t, repoPath)), + ); } function pushEntry(prev: AgentFeedState, entry: ActivityEntry): AgentFeedState { @@ -99,104 +142,158 @@ function pushEntry(prev: AgentFeedState, entry: ActivityEntry): AgentFeedState { }; } +function updateDiscovery( + state: SetupStoreState, + repoPath: string, + patch: Partial, +): Record { + const prev = state.discoveryByRepo[repoPath] ?? DEFAULT_DISCOVERY; + return { ...state.discoveryByRepo, [repoPath]: { ...prev, ...patch } }; +} + +function updateEnricher( + state: SetupStoreState, + repoPath: string, + patch: Partial, +): Record { + const prev = state.enricherByRepo[repoPath] ?? DEFAULT_ENRICHER; + return { ...state.enricherByRepo, [repoPath]: { ...prev, ...patch } }; +} + export const useSetupStore = create()( persist( (set) => ({ ...initialState, - // Starts a fresh agent run. Clears agent-source suggestions only — - // enricher-source suggestions persist across discovery runs. - startDiscovery: (taskId, taskRunId) => { - log.info("Discovery started", { taskId, taskRunId }); + // Starts a fresh agent run for `repoPath`. Clears agent-source + // suggestions only for that repo — enricher and other repos stay put. + startDiscovery: (repoPath, taskId, taskRunId) => { + log.info("Discovery started", { repoPath, taskId, taskRunId }); set((state) => ({ - discoveryStatus: "running", - discoveryTaskId: taskId, - discoveryTaskRunId: taskRunId, - discoveredTasks: keepEnricherSuggestions(state.discoveredTasks), - discoveryFeed: EMPTY_FEED, - error: null, + discoveredTasks: dropAgentTasksForRepo( + state.discoveredTasks, + repoPath, + ), + discoveryByRepo: updateDiscovery(state, repoPath, { + status: "running", + taskId, + taskRunId, + feed: EMPTY_FEED, + error: null, + }), })); }, - // Replaces only agent-source entries with the new findings; enricher - // entries stay put and continue to render first. - completeDiscovery: (tasks) => { - log.info("Discovery completed", { taskCount: tasks.length }); + // Replaces agent-source entries for `repoPath` with the new findings. + // Other repos' tasks and enricher entries are untouched. + completeDiscovery: (repoPath, tasks) => { + log.info("Discovery completed", { + repoPath, + taskCount: tasks.length, + }); set((state) => { - const enricher = keepEnricherSuggestions(state.discoveredTasks); - const agent = tasks.map((t) => ({ ...t, source: "agent" as const })); + const cleaned = dropAgentTasksForRepo( + state.discoveredTasks, + repoPath, + ); + const agent = tasks.map((t) => ({ + ...t, + source: "agent" as const, + repoPath: t.repoPath ?? repoPath, + })); return { - discoveryStatus: "done", - discoveredTasks: [...enricher, ...agent], - error: null, + discoveredTasks: [...cleaned, ...agent], + discoveryByRepo: updateDiscovery(state, repoPath, { + status: "done", + error: null, + }), }; }); }, - failDiscovery: (message) => { - log.warn("Discovery failed", { message }); - set({ discoveryStatus: "error", error: message ?? null }); - }, - - resetDiscovery: () => { - log.info("Discovery reset"); + failDiscovery: (repoPath, message) => { + log.warn("Discovery failed", { repoPath, message }); set((state) => ({ - discoveryStatus: "idle", - discoveryTaskId: null, - discoveryTaskRunId: null, - discoveredTasks: keepEnricherSuggestions(state.discoveredTasks), - discoveryFeed: EMPTY_FEED, - error: null, + discoveryByRepo: updateDiscovery(state, repoPath, { + status: "error", + error: message ?? null, + }), })); }, - startEnrichment: () => { - set({ enricherStatus: "running" }); + resetDiscovery: (repoPath) => { + log.info("Discovery reset", { repoPath }); + set((state) => ({ + discoveredTasks: dropAgentTasksForRepo( + state.discoveredTasks, + repoPath, + ), + discoveryByRepo: updateDiscovery(state, repoPath, { + status: "idle", + taskId: null, + taskRunId: null, + feed: EMPTY_FEED, + error: null, + }), + })); }, - completeEnrichment: () => { - set({ enricherStatus: "done" }); + startEnrichment: (repoPath) => { + set((state) => ({ + enricherByRepo: updateEnricher(state, repoPath, { + status: "running", + }), + })); }, - failEnrichment: () => { - set({ enricherStatus: "error" }); + completeEnrichment: (repoPath) => { + set((state) => ({ + enricherByRepo: updateEnricher(state, repoPath, { status: "done" }), + })); }, - removeDiscoveredTask: (taskId) => { + failEnrichment: (repoPath) => { set((state) => ({ - discoveredTasks: state.discoveredTasks.filter((t) => t.id !== taskId), - selectedDiscoveredTaskId: - state.selectedDiscoveredTaskId === taskId - ? null - : state.selectedDiscoveredTaskId, + enricherByRepo: updateEnricher(state, repoPath, { status: "error" }), })); }, - selectDiscoveredTask: (taskId) => { - set({ selectedDiscoveredTaskId: taskId }); + removeDiscoveredTask: (taskId, repoPath) => { + set((state) => ({ + discoveredTasks: state.discoveredTasks.filter( + (t) => !(t.id === taskId && isTaskForRepo(t, repoPath)), + ), + })); }, // Adds an enricher-source suggestion if there isn't already one with - // the same id. Idempotent — safe to call repeatedly on every detection - // run. Dismissed suggestions stay dismissed until `resetSetup`. + // the same id+repoPath. Idempotent — safe to call repeatedly on every + // detection run. Dismissed suggestions stay dismissed until `resetSetup`. addEnricherSuggestionIfMissing: (task) => { set((state) => { - if (state.discoveredTasks.some((t) => t.id === task.id)) { + const repoTask = { ...task, source: "enricher" as const }; + if ( + state.discoveredTasks.some( + (t) => t.id === repoTask.id && t.repoPath === repoTask.repoPath, + ) + ) { return state; } return { - discoveredTasks: [ - { ...task, source: "enricher" as const }, - ...state.discoveredTasks, - ], + discoveredTasks: [repoTask, ...state.discoveredTasks], }; }); }, - pushDiscoveryActivity: (entry) => { - set((state) => ({ - discoveryFeed: pushEntry(state.discoveryFeed, entry), - })); + pushDiscoveryActivity: (repoPath, entry) => { + set((state) => { + const prev = state.discoveryByRepo[repoPath] ?? DEFAULT_DISCOVERY; + return { + discoveryByRepo: updateDiscovery(state, repoPath, { + feed: pushEntry(prev.feed, entry), + }), + }; + }); }, resetSetup: () => { @@ -206,22 +303,87 @@ export const useSetupStore = create()( }), { name: "setup-store", - // Persist "running" as "error" so an interrupted run (crash, force-quit, - // freeze) doesn't auto-restart on next boot. Otherwise discovery loops - // forever, creating new cloud tasks and spawning agents on every launch. - partialize: (state) => ({ + version: 2, + migrate: (persistedState, version): PersistedSetupState => { + if (version < 2) { + // v1 stored a single global discoveryStatus, not a per-repo map. + // We can't recover which repo it belonged to, so for v1 users who + // had already finished (or interrupted) a discovery run we plant a + // sentinel entry under a synthetic key. That keeps + // `discoveryEverStarted` true on first boot post-upgrade, + // suppressing an automatic fresh agent launch — without it, every + // upgraded user would create a new cloud task and re-trigger the + // parse storm we fixed in #2257. + // + // Pre-v2 tasks are dropped: they have no repoPath, so the new + // per-repo filter would never render them anyway. + const oldState = (persistedState ?? {}) as { + discoveryStatus?: string; + error?: unknown; + }; + let sentinel: Record = {}; + if (oldState.discoveryStatus === "done") { + sentinel = { + __migrated_v1__: { ...DEFAULT_DISCOVERY, status: "done" }, + }; + } else if ( + oldState.discoveryStatus === "error" || + oldState.discoveryStatus === "running" + ) { + sentinel = { + __migrated_v1__: { + ...DEFAULT_DISCOVERY, + status: "error", + error: + typeof oldState.error === "string" + ? oldState.error + : "Discovery was interrupted. You can skip or retry.", + }, + }; + } + return { + discoveredTasks: [], + discoveryByRepo: sentinel, + enricherByRepo: {}, + }; + } + return persistedState as PersistedSetupState; + }, + // Persist non-idle discovery status per repo so a known-done repo + // doesn't trigger another full agent run on reload. Persist "running" + // as "error" so an interrupted run (crash, force-quit, freeze) doesn't + // auto-restart on next boot — otherwise discovery loops forever, + // creating new cloud tasks and spawning agents on every launch (#2257). + // + // Enricher only persists "done" — it's cheap to rerun on error/idle, + // and we never want to skip an in-flight "running" across boots. + partialize: (state): PersistedSetupState => ({ discoveredTasks: state.discoveredTasks, - discoveryStatus: - state.discoveryStatus === "done" - ? ("done" as const) - : state.discoveryStatus === "running" || - state.discoveryStatus === "error" - ? ("error" as const) - : ("idle" as const), - error: - state.discoveryStatus === "running" - ? "Discovery was interrupted. You can skip or retry." - : state.error, + discoveryByRepo: Object.fromEntries( + Object.entries(state.discoveryByRepo) + .filter(([, d]) => d.status !== "idle") + .map(([repo, d]) => { + if (d.status === "running") { + return [ + repo, + { + ...DEFAULT_DISCOVERY, + status: "error", + error: "Discovery was interrupted. You can skip or retry.", + }, + ]; + } + return [ + repo, + { ...DEFAULT_DISCOVERY, status: d.status, error: d.error }, + ]; + }), + ), + enricherByRepo: Object.fromEntries( + Object.entries(state.enricherByRepo).filter( + ([, e]) => e.status === "done", + ), + ), }), }, ), diff --git a/apps/code/src/renderer/features/setup/types.ts b/apps/code/src/renderer/features/setup/types.ts index 90bbe8e6f..643f6cc49 100644 --- a/apps/code/src/renderer/features/setup/types.ts +++ b/apps/code/src/renderer/features/setup/types.ts @@ -2,6 +2,7 @@ export type DiscoveredTaskSource = "agent" | "enricher"; export interface DiscoveredTask { id: string; + repoPath?: string; title: string; description: string; category: diff --git a/apps/code/src/renderer/features/setup/utils/discoveredTaskToSignalReport.ts b/apps/code/src/renderer/features/setup/utils/discoveredTaskToSignalReport.ts deleted file mode 100644 index e744a1e14..000000000 --- a/apps/code/src/renderer/features/setup/utils/discoveredTaskToSignalReport.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { DiscoveredTask } from "@features/setup/types"; -import type { SignalReport } from "@shared/types"; - -export function discoveredTaskToSignalReport( - task: DiscoveredTask, -): SignalReport { - const now = new Date().toISOString(); - return { - id: task.id, - title: task.title, - summary: task.description, - status: "ready", - total_weight: 0, - signal_count: 0, - created_at: now, - updated_at: now, - artefact_count: 0, - priority: null, - actionability: null, - already_addressed: null, - is_suggested_reviewer: false, - source_products: undefined, - implementation_pr_url: null, - }; -} diff --git a/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx b/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx index b88d2c296..c3785c6e0 100644 --- a/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx +++ b/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx @@ -3,7 +3,7 @@ import { CATEGORY_CONFIG, FALLBACK_CATEGORY_CONFIG, } from "@features/setup/utils/categoryConfig"; -import { ArrowSquareOut, X } from "@phosphor-icons/react"; +import { X } from "@phosphor-icons/react"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { motion } from "framer-motion"; @@ -12,7 +12,6 @@ export interface SuggestedTaskCardProps { index: number; onSelect: (task: DiscoveredTask) => void; onDismiss: (task: DiscoveredTask) => void; - onViewDetails: (task: DiscoveredTask) => void; } export function SuggestedTaskCard({ @@ -20,7 +19,6 @@ export function SuggestedTaskCard({ index, onSelect, onDismiss, - onViewDetails, }: SuggestedTaskCardProps) { const config = CATEGORY_CONFIG[task.category] ?? FALLBACK_CATEGORY_CONFIG; const TaskIcon = config.icon; @@ -85,19 +83,6 @@ export function SuggestedTaskCard({ gap="1" className="pointer-events-none absolute top-2 right-2 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100" > - - - + + - - )} - {isEnricherRunning && ( + )} + + )} + {hasTasks && ( +
+ + + {visibleTasks.map((task, index) => ( + + ))} + + +
+ )} + + {showEnricherFeed && ( )} - {isDiscoveryRunning && ( + {showDiscoveryFeed && ( )} + ); } diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 85253604a..bd760c166 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -23,9 +23,6 @@ import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModel import { getCurrentModeFromConfigOptions } from "@features/sessions/stores/sessionStore"; import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useSetupStore } from "@features/setup/stores/setupStore"; -import type { DiscoveredTask } from "@features/setup/types"; -import { buildDiscoveredTaskPrompt } from "@features/setup/utils/buildDiscoveredTaskPrompt"; import { useAutoFocusOnTyping } from "@hooks/useAutoFocusOnTyping"; import { useConnectivity } from "@hooks/useConnectivity"; import { @@ -40,13 +37,13 @@ import { useAuthStore } from "@renderer/features/auth/stores/authStore"; import { useDraftStore } from "@renderer/features/message-editor/stores/draftStore"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; +import { useActiveRepoStore } from "@stores/activeRepoStore"; import { type TaskInputReportAssociation, useNavigationStore, } from "@stores/navigationStore"; import { useQuery } from "@tanstack/react-query"; import { FOCUSABLE_SELECTOR } from "@utils/overlay"; -import { LayoutGroup, motion } from "framer-motion"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; @@ -78,6 +75,8 @@ export function TaskInput({ const setSelectedReportIds = useInboxReportSelectionStore( (s) => s.setSelectedReportIds, ); + const selectedDirectory = useActiveRepoStore((s) => s.path); + const setSelectedDirectory = useActiveRepoStore((s) => s.setPath); const { data: mostRecentRepo } = useQuery( trpcReact.folders.getMostRecentlyAccessedRepository.queryOptions(), ); @@ -120,7 +119,6 @@ export function TaskInput({ reportAssociation ?? null, ); - const [selectedDirectory, setSelectedDirectory] = useState(""); const adapter = lastUsedAdapter; const prefillRequestKey = initialPromptKey ?? initialPrompt; @@ -170,7 +168,7 @@ export function TaskInput({ if (!selectedDirectory && mostRecentRepo?.path) { setSelectedDirectory(mostRecentRepo.path); } - }, [mostRecentRepo?.path, selectedDirectory]); + }, [mostRecentRepo?.path, selectedDirectory, setSelectedDirectory]); const setAdapter = (newAdapter: AgentAdapter) => setLastUsedAdapter(newAdapter); @@ -396,7 +394,7 @@ export function TaskInput({ setSelectedDirectory(folder.path); } } - }, [view.folderId, folders]); + }, [view.folderId, folders, setSelectedDirectory]); useEffect(() => { setCloudBranchSearchQuery(""); @@ -524,15 +522,6 @@ export function TaskInput({ editorRef.current?.setContent(text); editorRef.current?.focus(); }, []); - const handleSelectSuggestion = useCallback( - async (task: DiscoveredTask) => { - const ok = await handleSubmit({ - segments: [{ type: "text", text: buildDiscoveredTaskPrompt(task) }], - }); - if (ok) useSetupStore.getState().removeDiscoveredTask(task.id); - }, - [handleSubmit], - ); const hasPendingDraft = useCallback( () => !(editorRef.current?.isEmpty() ?? true), [], @@ -615,220 +604,210 @@ export function TaskInput({ className="relative px-4 pt-[10vh]" > - - - - + + + {workspaceMode === "worktree" && ( + - {workspaceMode === "worktree" && ( - - )} - - {workspaceMode === "cloud" ? ( - - ) : ( - - )} - + {workspaceMode === "cloud" ? ( + + ) : ( + - - {cloudRegion === "dev" && ( - - - - Dev - - )} - - - - + currentBranch={currentBranch} + defaultBranch={ + workspaceMode === "cloud" ? cloudDefaultBranch : defaultBranch } - historyButton={ - + disabled={ + isCreatingTask || + (workspaceMode === "cloud" && !selectedCloudRepository) } - reasoningSelector={ - !isPreviewLoading && ( - - ) + loading={workspaceMode === "cloud" ? false : branchLoading} + workspaceMode={workspaceMode} + selectedBranch={selectedBranch} + onBranchSelect={setSelectedBranch} + busyState={busyState} + cloudBranches={cloudBranches} + cloudBranchesLoading={cloudBranchesLoading} + isRefreshing={cloudBranchesRefreshing} + cloudBranchesFetchingMore={cloudBranchesFetchingMore} + cloudBranchesHasMore={cloudBranchesHasMore} + cloudSearchQuery={cloudBranchSearchQuery} + onCloudPickerClose={handleCloudBranchPickerClose} + onCloudSearchChange={handleCloudBranchSearchChange} + onCloudBranchCommit={handleCloudBranchPickerClose} + onCloudLoadMore={handleLoadMoreCloudBranches} + onRefresh={ + workspaceMode === "cloud" ? handleRefreshBranches : undefined } - getPromptHistory={getPromptHistory} - onEmptyChange={handleEditorEmptyChange} - onSubmitClick={handleSubmit} - onSubmit={() => { - if (canSubmit) handleSubmit(); - }} + anchor={buttonGroupRef} /> - {activeReportAssociation && ( -
- - - This task will be associated with report - - + + {cloudRegion === "dev" && ( + + + + Dev + + + )} + + + + + } + historyButton={ + + } + reasoningSelector={ + !isPreviewLoading && ( + + ) + } + getPromptHistory={getPromptHistory} + onEmptyChange={handleEditorEmptyChange} + onSubmitClick={handleSubmit} + onSubmit={() => { + if (canSubmit) handleSubmit(); + }} + /> + {activeReportAssociation && ( +
+ + + This task will be associated with report - - - + + + + + +
+ )} + {effectiveWorkspaceMode === "cloud" && + !isLoadingRepos && + !hasGithubIntegration && ( +
+
)} - {effectiveWorkspaceMode === "cloud" && - !isLoadingRepos && - !hasGithubIntegration && ( -
- -
- )} - -
- - + + +
void; +} + +type ActiveRepoStore = ActiveRepoStoreState & ActiveRepoStoreActions; + +export const useActiveRepoStore = create()( + persist( + (set) => ({ + path: "", + setPath: (path) => set({ path }), + }), + { + name: "active-repo-store", + partialize: (state) => ({ path: state.path }), + }, + ), +);