diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index b0867db06..8110437c1 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -14,7 +14,7 @@ import { McpServersView } from "@features/mcp-servers/components/McpServersView" import { FolderSettingsView } from "@features/settings/components/FolderSettingsView"; import { SettingsDialog } from "@features/settings/components/SettingsDialog"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { SetupView } from "@features/setup/components/SetupView"; +import { useSetupDiscovery } from "@features/setup/hooks/useSetupDiscovery"; import { MainSidebar } from "@features/sidebar/components/MainSidebar"; import { useSidebarData } from "@features/sidebar/hooks/useSidebarData"; import { useVisualTaskOrder } from "@features/sidebar/hooks/useVisualTaskOrder"; @@ -87,6 +87,7 @@ export function MainLayout() { useIntegrations(); useTaskDeepLink(); useInboxDeepLink(); + useSetupDiscovery(); useEffect(() => { if (tasks) { @@ -194,7 +195,6 @@ export function MainLayout() { {view.type === "skills" && } {view.type === "mcp-servers" && } - {view.type === "setup" && } diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx index 8c7fb2ca9..5664fb2bb 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx @@ -40,12 +40,7 @@ export function OnboardingFlow() { const completeOnboarding = useOnboardingStore( (state) => state.completeOnboarding, ); - const completeSetup = useOnboardingStore((state) => state.completeSetup); - const hasCompletedSetup = useOnboardingStore( - (state) => state.hasCompletedSetup, - ); const resetOnboarding = useOnboardingStore((state) => state.resetOnboarding); - const navigateToSetup = useNavigationStore((state) => state.navigateToSetup); const navigateToTaskInput = useNavigationStore( (state) => state.navigateToTaskInput, ); @@ -60,14 +55,11 @@ export function OnboardingFlow() { const handleComplete = () => { completeOnboarding(); - if (!hasCompletedSetup) { - navigateToSetup(); - } + navigateToTaskInput(); }; const handleSkip = () => { completeOnboarding(); - completeSetup(); navigateToTaskInput(); }; 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 deleted file mode 100644 index 11a82ff6c..000000000 --- a/apps/code/src/renderer/features/onboarding/components/context-collection/SuggestedTasks.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import type { DiscoveredTask } from "@features/setup/types"; -import { - CATEGORY_CONFIG, - FALLBACK_CATEGORY_CONFIG, -} from "@features/setup/utils/categoryConfig"; -import { ArrowRight } from "@phosphor-icons/react"; -import { Flex, Text } from "@radix-ui/themes"; -import { motion } from "framer-motion"; - -type Variant = "default" | "compact"; - -interface SuggestedTasksProps { - tasks: DiscoveredTask[]; - onSelectTask: (task: DiscoveredTask) => void; - variant?: Variant; - /** When set, uses CSS grid with the given column class instead of a vertical stack. */ - layoutClassName?: string; -} - -export function SuggestedTasks({ - tasks, - onSelectTask, - variant = "default", - layoutClassName, -}: SuggestedTasksProps) { - if (tasks.length === 0) { - return ( - - No issues found. Your codebase looks clean! - - ); - } - - const containerClass = layoutClassName ?? "flex w-full flex-col gap-3"; - - return ( -
- {tasks.map((task, index) => ( - - ))} -
- ); -} - -interface SuggestedTaskCardProps { - task: DiscoveredTask; - index: number; - variant: Variant; - onSelect: (task: DiscoveredTask) => void; -} - -function SuggestedTaskCard({ - task, - index, - variant, - onSelect, -}: SuggestedTaskCardProps) { - const config = CATEGORY_CONFIG[task.category] ?? FALLBACK_CATEGORY_CONFIG; - const TaskIcon = config.icon; - const isCompact = variant === "compact"; - const iconSize = isCompact ? 14 : 18; - const titleSize = isCompact ? "1" : "2"; - - return ( - onSelect(task)} - type="button" - className={`flex w-full cursor-pointer items-start rounded-xl border border-(--gray-a3) bg-(--color-panel-solid) text-left transition-[border-color,box-shadow] ${ - isCompact ? "gap-2.5 px-2.5 py-2" : "gap-3.5 px-[18px] py-4" - }`} - style={{ - boxShadow: "0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)", - }} - whileHover={{ - borderColor: `var(--${config.color}-6)`, - boxShadow: "0 2px 8px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04)", - }} - > - - - - - - - {task.title} - - - - - {task.description} - - {task.file && ( - - {task.file} - {task.lineHint ? `:${task.lineHint}` : ""} - - )} - - - ); -} diff --git a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts index 34af17e24..1835733ac 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts @@ -8,7 +8,6 @@ const log = logger.scope("onboarding-store"); interface OnboardingStoreState { currentStep: OnboardingStep; hasCompletedOnboarding: boolean; - hasCompletedSetup: boolean; selectedProjectId: number | null; selectedDirectory: string; } @@ -16,7 +15,6 @@ interface OnboardingStoreState { interface OnboardingStoreActions { setCurrentStep: (step: OnboardingStep) => void; completeOnboarding: () => void; - completeSetup: () => void; resetOnboarding: () => void; resetSelections: () => void; selectProjectId: (projectId: number | null) => void; @@ -28,7 +26,6 @@ type OnboardingStore = OnboardingStoreState & OnboardingStoreActions; const initialState: OnboardingStoreState = { currentStep: "welcome", hasCompletedOnboarding: false, - hasCompletedSetup: false, selectedProjectId: null, selectedDirectory: "", }; @@ -43,7 +40,6 @@ export const useOnboardingStore = create()( log.info("completeOnboarding"); set({ hasCompletedOnboarding: true }); }, - completeSetup: () => set({ hasCompletedSetup: true }), resetOnboarding: () => set({ ...initialState }), resetSelections: () => set({ @@ -58,7 +54,6 @@ 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/DiscoveredTaskDetailPane.tsx b/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailPane.tsx index a4efd6abd..5c80b0c8e 100644 --- a/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailPane.tsx +++ b/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailPane.tsx @@ -30,7 +30,6 @@ export function DiscoveredTaskDetailPane({ const tasks = useSetupStore((s) => s.discoveredTasks); const selectedDirectory = useOnboardingStore((s) => s.selectedDirectory); - const completeSetup = useOnboardingStore((s) => s.completeSetup); const navigateToTaskInput = useNavigationStore((s) => s.navigateToTaskInput); const { folders } = useFolders(); const detectedCloudRepository = useDetectedCloudRepository(selectedDirectory); @@ -46,7 +45,6 @@ export function DiscoveredTaskDetailPane({ const initialPrompt = buildDiscoveredTaskPrompt(task); const folderId = folders.find((f) => f.path === selectedDirectory)?.id; - completeSetup(); useSetupStore.getState().removeDiscoveredTask(task.id); navigateToTaskInput({ initialPrompt, diff --git a/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx b/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx index 32a7b62da..1310ed491 100644 --- a/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx +++ b/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx @@ -33,6 +33,7 @@ interface SetupScanFeedProps { recentEntries: ActivityEntry[]; isDone: boolean; doneLabel?: string; + maxEntries?: number; } const TOOL_VERBS: Record = { @@ -129,6 +130,7 @@ export function SetupScanFeed({ recentEntries, isDone, doneLabel = "Complete", + maxEntries = 4, }: SetupScanFeedProps) { const activeLabel = activeLabelOverride ?? @@ -210,63 +212,67 @@ export function SetupScanFeed({ - {!isDone && recentEntries.length > 0 && ( - - + {!isDone && recentEntries.length > 0 && maxEntries > 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} - - - - ); - })} - - - - )} + + + {recentEntries.slice(-maxEntries).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 deleted file mode 100644 index 78a5134bb..000000000 --- a/apps/code/src/renderer/features/setup/components/SetupView.tsx +++ /dev/null @@ -1,281 +0,0 @@ -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 { - ArrowRight, - Lightning, - MagnifyingGlass, - Rocket, -} from "@phosphor-icons/react"; -import { 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"; -import { useEffect, useMemo } from "react"; - -export function SetupView() { - const { - discoveryFeed, - isDiscoveryDone, - isEnricherRunning, - discoveredTasks, - error, - } = useSetupRun(); - const completeSetup = useOnboardingStore((state) => state.completeSetup); - const navigateToTaskInput = useNavigationStore( - (state) => state.navigateToTaskInput, - ); - - const { enricherTasks, agentTasks } = useMemo(() => { - const enricher: DiscoveredTask[] = []; - const agent: DiscoveredTask[] = []; - for (const task of discoveredTasks) { - if (task.source === "enricher") enricher.push(task); - else agent.push(task); - } - return { enricherTasks: enricher, agentTasks: agent }; - }, [discoveredTasks]); - - const showQuickWins = enricherTasks.length > 0 || isEnricherRunning; - const isEnricherDone = !isEnricherRunning; - - useSetHeaderContent( - - - - {isDiscoveryDone ? "Tasks ready" : "Finish setup"} - - , - ); - - useEffect(() => { - track(ANALYTICS_EVENTS.SETUP_VIEWED, { - discovery_status: useSetupStore.getState().discoveryStatus, - }); - }, []); - - const handleSelectTask = (task: DiscoveredTask) => { - const position = discoveredTasks.findIndex((t) => t.id === task.id); - track(ANALYTICS_EVENTS.SETUP_TASK_SELECTED, { - discovered_task_id: task.id, - category: task.category, - position: position >= 0 ? position : 0, - total_discovered: discoveredTasks.length, - }); - - const initialPrompt = buildDiscoveredTaskPrompt(task); - completeSetup(); - useSetupStore.getState().removeDiscoveredTask(task.id); - navigateToTaskInput({ initialPrompt }); - }; - - const handleStartFromScratch = () => { - track(ANALYTICS_EVENTS.SETUP_SKIPPED, { - discovery_status: useSetupStore.getState().discoveryStatus, - had_discovered_tasks: discoveredTasks.length > 0, - entry_point: isDiscoveryDone ? "after_done" : "during_scan", - }); - if (isDiscoveryDone) { - useSetupStore.getState().resetDiscovery(); - } - completeSetup(); - navigateToTaskInput(); - }; - - return ( - - - - - - - Set up your first task - - - Pick something to work on, or describe your own. - - - -
- {showQuickWins && ( - - )} - - -
- - - - {!isDiscoveryDone && ( - - Suggested tasks will appear in the sidebar as they're ready. - - )} - -
-
-
- ); -} - -interface QuickWinsColumnProps { - tasks: DiscoveredTask[]; - isDone: boolean; - onSelectTask: (task: DiscoveredTask) => void; -} - -function QuickWinsColumn({ - tasks, - isDone, - onSelectTask, -}: QuickWinsColumnProps) { - return ( - - - - Quick wins - - - Spotted in your PostHog setup - - - - {tasks.length > 0 && ( - - )} - - ); -} - -interface DeeperScanColumnProps { - hasSibling: boolean; - isDone: boolean; - tasks: DiscoveredTask[]; - feed: ReturnType["discoveryFeed"]; - error: string | null; - onSelectTask: (task: DiscoveredTask) => void; -} - -function DeeperScanColumn({ - hasSibling, - isDone, - tasks, - feed, - error, - onSelectTask, -}: DeeperScanColumnProps) { - const isEmpty = isDone && tasks.length === 0; - - return ( -
- - - - Deeper scan - - - {isDone - ? "We checked your code for bugs and improvements." - : "Bugs, dead code, and improvements (~1 min)."} - - - - - {isDone && tasks.length > 0 && ( - - )} - - {isEmpty && !error && ( - - No issues found — your code looks clean ✨ - - )} - - {error && ( - - {error} - - )} - -
- ); -} diff --git a/apps/code/src/renderer/features/setup/hooks/useSetupRun.ts b/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts similarity index 57% rename from apps/code/src/renderer/features/setup/hooks/useSetupRun.ts rename to apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts index fcc2b692f..a69530e1c 100644 --- a/apps/code/src/renderer/features/setup/hooks/useSetupRun.ts +++ b/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts @@ -5,32 +5,20 @@ import { get } from "@renderer/di/container"; import { RENDERER_TOKENS } from "@renderer/di/tokens"; import { useEffect, useRef } from "react"; -export function useSetupRun() { +export function useSetupDiscovery() { const selectedDirectory = useOnboardingStore((s) => s.selectedDirectory); const discoveryStatus = useSetupStore((s) => s.discoveryStatus); - const enricherStatus = useSetupStore((s) => s.enricherStatus); - const discoveredTasks = useSetupStore((s) => s.discoveredTasks); - const discoveryFeed = useSetupStore((s) => s.discoveryFeed); - 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.startSetup(selectedDirectory); + startedRef.current = true; + get(RENDERER_TOKENS.SetupRunService).startSetup( + selectedDirectory, + ); }, [discoveryStatus, selectedDirectory]); - - return { - discoveryFeed, - isDiscoveryDone: discoveryStatus === "done", - isEnricherRunning: enricherStatus === "running", - discoveredTasks, - error, - }; } diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index eb11dbdf4..7d9b26d49 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -6,9 +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 { useSetupStore } from "@features/setup/stores/setupStore"; import { archiveTaskImperative, useArchiveTask, @@ -35,7 +33,6 @@ import { CommandCenterItem } from "./items/CommandCenterItem"; import { InboxItem, NewTaskItem } from "./items/HomeItem"; import { McpServersItem } from "./items/McpServersItem"; import { SearchItem } from "./items/SearchItem"; -import { SetupItem } from "./items/SetupItem"; import { SkillsItem } from "./items/SkillsItem"; import { SidebarItem } from "./SidebarItem"; import { TaskListView } from "./TaskListView"; @@ -49,7 +46,6 @@ function SidebarMenuComponent() { navigateToCommandCenter, navigateToSkills, navigateToMcpServers, - navigateToSetup, } = useNavigationStore(); // Must mirror useSidebarData's filters so taskMap covers every rendered @@ -66,17 +62,6 @@ function SidebarMenuComponent() { const { archiveTask } = useArchiveTask(); const { togglePin } = usePinnedTasks(); - const hasCompletedSetup = useOnboardingStore( - (state) => state.hasCompletedSetup, - ); - const showSetupItem = useSetupStore((s) => { - if (!hasCompletedSetup) return true; - if (s.discoveryStatus === "running") return true; - if (s.discoveryStatus === "done" && s.discoveredTasks.length > 0) - return true; - return false; - }); - const sidebarData = useSidebarData({ activeView: view, }); @@ -143,10 +128,6 @@ function SidebarMenuComponent() { navigateToMcpServers(); }; - const handleSetupClick = () => { - navigateToSetup(); - }; - const openCommandMenu = useCommandMenuStore((s) => s.open); const handleSearchClick = () => { openCommandMenu(); @@ -323,15 +304,6 @@ function SidebarMenuComponent() { /> - {showSetupItem && ( - - - - )} - diff --git a/apps/code/src/renderer/features/sidebar/components/items/SetupItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/SetupItem.tsx deleted file mode 100644 index 841ec0aa2..000000000 --- a/apps/code/src/renderer/features/sidebar/components/items/SetupItem.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useSetupStore } from "@features/setup/stores/setupStore"; -import { Rocket } from "@phosphor-icons/react"; - -interface SetupItemProps { - isActive: boolean; - onClick: () => void; -} - -type ItemState = "running" | "ready" | "finish"; - -function selectItemState( - status: "idle" | "running" | "done" | "error", - taskCount: number, -): ItemState { - if (status === "running") return "running"; - if (status === "done" && taskCount > 0) return "ready"; - return "finish"; -} - -const LABELS: Record = { - running: "Scanning your code", - ready: "Tasks ready", - finish: "Finish setup", -}; - -export function SetupItem({ isActive, onClick }: SetupItemProps) { - const state = useSetupStore((s) => - selectItemState(s.discoveryStatus, s.discoveredTasks.length), - ); - - return ( - - ); -} diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index c0f166b02..06e8d4892 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -47,7 +47,6 @@ export interface SidebarData { isCommandCenterActive: boolean; isSkillsActive: boolean; isMcpServersActive: boolean; - isSetupActive: boolean; isLoading: boolean; activeTaskId: string | null; pinnedTasks: TaskData[]; @@ -188,7 +187,6 @@ export function useSidebarData({ const isCommandCenterActive = activeView.type === "command-center"; const isSkillsActive = activeView.type === "skills"; const isMcpServersActive = activeView.type === "mcp-servers"; - const isSetupActive = activeView.type === "setup"; const activeTaskId = activeView.type === "task-detail" && activeView.data @@ -309,7 +307,6 @@ export function useSidebarData({ isCommandCenterActive, isSkillsActive, isMcpServersActive, - isSetupActive, isLoading, activeTaskId, pinnedTasks, diff --git a/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx b/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx new file mode 100644 index 000000000..81cf2947e --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx @@ -0,0 +1,119 @@ +import type { DiscoveredTask } from "@features/setup/types"; +import { + CATEGORY_CONFIG, + FALLBACK_CATEGORY_CONFIG, +} from "@features/setup/utils/categoryConfig"; +import { ArrowSquareOut, X } from "@phosphor-icons/react"; +import { Flex, Text, Tooltip } from "@radix-ui/themes"; +import { motion } from "framer-motion"; + +export interface SuggestedTaskCardProps { + task: DiscoveredTask; + index: number; + onSelect: (task: DiscoveredTask) => void; + onDismiss: (task: DiscoveredTask) => void; + onViewDetails: (task: DiscoveredTask) => void; +} + +export function SuggestedTaskCard({ + task, + index, + onSelect, + onDismiss, + onViewDetails, +}: SuggestedTaskCardProps) { + const config = CATEGORY_CONFIG[task.category] ?? FALLBACK_CATEGORY_CONFIG; + const TaskIcon = config.icon; + + return ( + + onSelect(task)} + type="button" + className="flex w-full cursor-pointer items-start gap-2.5 rounded-xl border border-(--gray-a3) bg-(--color-panel-solid) px-2.5 py-2 text-left transition-[border-color,box-shadow]" + style={{ + boxShadow: "0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)", + }} + whileHover={{ + borderColor: `var(--${config.color}-6)`, + boxShadow: "0 2px 8px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04)", + }} + > + + + + + + {task.title} + + + {task.description} + + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/task-detail/components/SuggestedTasksPanel.tsx b/apps/code/src/renderer/features/task-detail/components/SuggestedTasksPanel.tsx new file mode 100644 index 000000000..ba0c90b02 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/SuggestedTasksPanel.tsx @@ -0,0 +1,185 @@ +import { SetupScanFeed } from "@features/setup/components/SetupScanFeed"; +import { useSetupStore } from "@features/setup/stores/setupStore"; +import type { DiscoveredTask } from "@features/setup/types"; +import { ArrowRight, Lightning, MagnifyingGlass } from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; +import { useNavigationStore } from "@stores/navigationStore"; +import { AnimatePresence, motion } from "framer-motion"; +import { useLayoutEffect, useRef, useState } from "react"; +import { SuggestedTaskCard } from "./SuggestedTaskCard"; + +const VISIBLE_LIMIT = 3; +const DEFAULT_LOG_LINES = 4; + +// Rough heights (px) used to drop cards / log lines that wouldn't fit below +// the editor. +const TOP_MARGIN = 12; +const GAP = 8; +const SCAN_PILL_HEIGHT = 48; +const CARD_HEIGHT = 56; +const SEE_MORE_HEIGHT = 24; +const BOTTOM_PADDING = 56; +const LOG_LINE_HEIGHT = 24; +const LOG_FEED_PADDING = 16; + +interface SuggestedTasksPanelProps { + onSelect: (task: DiscoveredTask) => void; +} + +export function SuggestedTasksPanel({ onSelect }: SuggestedTasksPanelProps) { + const discoveredTasks = useSetupStore((s) => s.discoveredTasks); + const discoveryStatus = useSetupStore((s) => s.discoveryStatus); + const enricherStatus = useSetupStore((s) => s.enricherStatus); + const discoveryFeed = useSetupStore((s) => s.discoveryFeed); + const removeDiscoveredTask = useSetupStore((s) => s.removeDiscoveredTask); + const selectDiscoveredTask = useSetupStore((s) => s.selectDiscoveredTask); + const navigateToInbox = useNavigationStore((s) => s.navigateToInbox); + + const containerRef = useRef(null); + const [availableHeight, setAvailableHeight] = useState(Infinity); + + useLayoutEffect(() => { + const el = containerRef.current; + if (!el) return; + + const measure = () => { + const rect = el.getBoundingClientRect(); + setAvailableHeight(window.innerHeight - rect.top); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(document.documentElement); + window.addEventListener("resize", measure); + return () => { + observer.disconnect(); + window.removeEventListener("resize", measure); + }; + }, []); + + const handleDismiss = (task: DiscoveredTask) => { + removeDiscoveredTask(task.id); + }; + + const handleViewDetails = (task: DiscoveredTask) => { + selectDiscoveredTask(task.id); + navigateToInbox(); + }; + + const isEnricherRunning = enricherStatus === "running"; + const isDiscoveryRunning = discoveryStatus === "running"; + + const hasTasks = discoveredTasks.length > 0; + + if (!hasTasks && !isEnricherRunning && !isDiscoveryRunning) return null; + + const totalTasks = discoveredTasks.length; + const desiredVisible = Math.min(totalTasks, VISIBLE_LIMIT); + const discoveryFeedHasEntries = discoveryFeed.recentEntries.length > 0; + + // Sum the sections that will be rendered, including inter-section gaps. + const measureTotalHeight = (cardCount: number, logLines: number): number => { + const sections: number[] = []; + if (cardCount > 0) { + sections.push(cardCount * CARD_HEIGHT + Math.max(0, cardCount - 1) * GAP); + } + if (totalTasks - cardCount > 0) { + sections.push(SEE_MORE_HEIGHT); + } + if (isEnricherRunning) { + sections.push(SCAN_PILL_HEIGHT); + } + if (isDiscoveryRunning) { + let h = SCAN_PILL_HEIGHT; + if (logLines > 0 && discoveryFeedHasEntries) { + h += LOG_FEED_PADDING + logLines * LOG_LINE_HEIGHT; + } + sections.push(h); + } + const sectionsTotal = sections.reduce((a, b) => a + b, 0); + const gapsTotal = Math.max(0, sections.length - 1) * GAP; + return TOP_MARGIN + sectionsTotal + gapsTotal + BOTTOM_PADDING; + }; + + // Drop log lines before cards: log lines are decorative, cards are the + // actionable items. + let visibleCount = desiredVisible; + let logLines = isDiscoveryRunning ? DEFAULT_LOG_LINES : 0; + if (Number.isFinite(availableHeight)) { + while (measureTotalHeight(visibleCount, logLines) > availableHeight) { + if (logLines > 0) logLines -= 1; + else if (visibleCount > 0) visibleCount -= 1; + else break; + } + } + + const visibleTasks = discoveredTasks.slice(0, visibleCount); + const hiddenCount = totalTasks - visibleTasks.length; + + const fadeMotion = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { duration: 0.15 }, + }; + + return ( +
+ + {visibleTasks.map((task, index) => ( + + ))} + {hiddenCount > 0 && ( + + + + See {hiddenCount} more in Inbox + + + + + )} + {isEnricherRunning && ( + + + + )} + {isDiscoveryRunning && ( + + + + )} + +
+ ); +} 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 0f7e3b571..95880a800 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -23,6 +23,8 @@ 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 { useAutoFocusOnTyping } from "@hooks/useAutoFocusOnTyping"; import { useConnectivity } from "@hooks/useConnectivity"; import { @@ -47,6 +49,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; import { CloudGithubMissingNotice } from "./CloudGithubMissingNotice"; +import { SuggestedTasksPanel } from "./SuggestedTasksPanel"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; interface TaskInputProps { @@ -531,6 +534,15 @@ export function TaskInput({ editorRef.current?.setContent(text); editorRef.current?.focus(); }, []); + const handleSelectSuggestion = useCallback( + async (task: DiscoveredTask) => { + const ok = await handleSubmit({ + segments: [{ type: "text", text: task.prompt ?? task.title }], + }); + if (ok) useSetupStore.getState().removeDiscoveredTask(task.id); + }, + [handleSubmit], + ); const hasPendingDraft = useCallback( () => !(editorRef.current?.isEmpty() ?? true), [], @@ -610,7 +622,7 @@ export function TaskInput({ align="center" justify="center" height="100%" - className="relative px-4" + className="relative px-4 pb-[10vh]" > )} +
+ +
diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts index 6169c42ce..52cf459a8 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -4,6 +4,7 @@ import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskIn import type { EditorHandle } from "@features/message-editor/types"; import { contentToXml, + type EditorContent, extractFilePaths, } from "@features/message-editor/utils/content"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; @@ -48,7 +49,7 @@ interface UseTaskCreationOptions { interface UseTaskCreationReturn { isCreatingTask: boolean; canSubmit: boolean; - handleSubmit: () => void; + handleSubmit: (contentOverride?: EditorContent) => Promise; } function prepareTaskInput( @@ -204,96 +205,114 @@ export function useTaskCreation({ !isCreatingTask && !editorIsEmpty; - const handleSubmit = useCallback(async () => { - const editor = editorRef.current; - if (!canSubmit || !editor) return; + const handleSubmit = useCallback( + async (contentOverride?: EditorContent): Promise => { + const editor = editorRef.current; + const allowSubmit = contentOverride + ? isAuthenticated && + isOnline && + hasRequiredPath && + !isCreatingTask && + !!editor + : canSubmit && !!editor; + if (!allowSubmit || !editor) return false; - setIsCreatingTask(true); + setIsCreatingTask(true); - try { - const content = editor.getContent(); - - const plainText = editor.getText()?.trim(); - if (plainText) { - useTaskInputHistoryStore.getState().addPrompt(plainText); - } + try { + const content = contentOverride ?? editor.getContent(); - const input = prepareTaskInput(content, { - selectedDirectory, - selectedRepository, - githubIntegrationId, - githubUserIntegrationId, - workspaceMode, - branch, - executionMode, - adapter, - model, - reasoningLevel, - environmentId, - sandboxEnvironmentId, - signalReportId, - }); + if (!contentOverride) { + const plainText = editor.getText()?.trim(); + if (plainText) { + useTaskInputHistoryStore.getState().addPrompt(plainText); + } + } - if (executionMode) { - useSettingsStore.getState().setLastUsedInitialTaskMode(executionMode); - } + const input = prepareTaskInput(content, { + selectedDirectory, + selectedRepository, + githubIntegrationId, + githubUserIntegrationId, + workspaceMode, + branch, + executionMode, + adapter, + model, + reasoningLevel, + environmentId, + sandboxEnvironmentId, + signalReportId, + }); - const taskService = get(RENDERER_TOKENS.TaskService); - const result = await taskService.createTask(input, (output) => { - invalidateTasks(output.task); - if (signalReportId) { - clearTaskInputReportAssociation(); - } - if (onTaskCreated) { - onTaskCreated(output.task); - } else { - navigateToTask(output.task); + if (executionMode) { + useSettingsStore.getState().setLastUsedInitialTaskMode(executionMode); } - useTourStore.getState().completeTour(createFirstTaskTour.id); - editor.clear(); - }); - - if (result.success) { - void trackTaskCreated(input, selectedDirectory); - } - if (!result.success) { - const title = getErrorTitle(result.failedStep); - toast.error(title, { description: result.error }); - log.error("Task creation failed", { - failedStep: result.failedStep, - error: result.error, + const taskService = get(RENDERER_TOKENS.TaskService); + const result = await taskService.createTask(input, (output) => { + invalidateTasks(output.task); + if (signalReportId) { + clearTaskInputReportAssociation(); + } + if (onTaskCreated) { + onTaskCreated(output.task); + } else { + navigateToTask(output.task); + } + useTourStore.getState().completeTour(createFirstTaskTour.id); + editor.clear(); }); + + if (result.success) { + void trackTaskCreated(input, selectedDirectory); + } + + if (!result.success) { + const title = getErrorTitle(result.failedStep); + toast.error(title, { description: result.error }); + log.error("Task creation failed", { + failedStep: result.failedStep, + error: result.error, + }); + } + return result.success; + } catch (error) { + const description = + error instanceof Error ? error.message : "Unknown error"; + toast.error("Failed to create task", { description }); + log.error("Unexpected error during task creation", { error }); + return false; + } finally { + setIsCreatingTask(false); } - } catch (error) { - const description = - error instanceof Error ? error.message : "Unknown error"; - toast.error("Failed to create task", { description }); - log.error("Unexpected error during task creation", { error }); - } finally { - setIsCreatingTask(false); - } - }, [ - canSubmit, - editorRef, - selectedDirectory, - selectedRepository, - githubIntegrationId, - githubUserIntegrationId, - workspaceMode, - branch, - executionMode, - adapter, - model, - reasoningLevel, - environmentId, - sandboxEnvironmentId, - signalReportId, - clearTaskInputReportAssociation, - invalidateTasks, - navigateToTask, - onTaskCreated, - ]); + }, + [ + canSubmit, + editorRef, + isAuthenticated, + isOnline, + hasRequiredPath, + isCreatingTask, + selectedDirectory, + selectedRepository, + githubIntegrationId, + githubUserIntegrationId, + workspaceMode, + branch, + executionMode, + adapter, + model, + reasoningLevel, + environmentId, + sandboxEnvironmentId, + signalReportId, + clearTaskInputReportAssociation, + invalidateTasks, + navigateToTask, + onTaskCreated, + ], + ); return { isCreatingTask, diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 93a9338c2..3edf3bcba 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -20,8 +20,7 @@ type ViewType = | "archived" | "command-center" | "skills" - | "mcp-servers" - | "setup"; + | "mcp-servers"; export interface TaskInputReportAssociation { reportId: string; @@ -63,7 +62,6 @@ interface NavigationStore { navigateToCommandCenter: () => void; navigateToSkills: () => void; navigateToMcpServers: () => void; - navigateToSetup: () => void; goBack: () => void; goForward: () => void; canGoBack: () => boolean; @@ -100,9 +98,6 @@ const isSameView = (view1: ViewState, view2: ViewState): boolean => { if (view1.type === "mcp-servers" && view2.type === "mcp-servers") { return true; } - if (view1.type === "setup" && view2.type === "setup") { - return true; - } return false; }; @@ -285,10 +280,6 @@ export const useNavigationStore = create()( navigate({ type: "mcp-servers" }); }, - navigateToSetup: () => { - navigate({ type: "setup" }); - }, - goBack: () => { const { history, historyIndex } = get(); if (historyIndex > 0) { diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 7250605aa..9e13a0873 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -301,10 +301,6 @@ type SetupDiscoveredTaskCategory = | "funnel" | "posthog_setup"; -export interface SetupViewedProperties { - discovery_status: "idle" | "running" | "done" | "error"; -} - export interface SetupDiscoveryStartedProperties { discovery_task_id: string; discovery_task_run_id: string; @@ -339,12 +335,6 @@ export interface SetupTaskDismissedProperties { total_discovered: number; } -export interface SetupSkippedProperties { - discovery_status: "idle" | "running" | "done" | "error"; - had_discovered_tasks: boolean; - entry_point: "during_scan" | "after_done"; -} - // Subscription / billing events export interface SubscriptionStartedProperties { plan_key: string; @@ -425,13 +415,11 @@ export const ANALYTICS_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", // Error events TASK_CREATION_FAILED: "Task creation failed", @@ -512,13 +500,11 @@ export type EventPropertyMap = { [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; // Error events [ANALYTICS_EVENTS.TASK_CREATION_FAILED]: TaskCreationFailedProperties;