diff --git a/.claude/automate.json b/.claude/automate.json new file mode 100644 index 00000000..31c45e8a --- /dev/null +++ b/.claude/automate.json @@ -0,0 +1,56 @@ +{ + "project": "Agent-Desktop-Command", + "version": "1.0.0", + "description": "Per-project automation rules. Read by SessionStart hook, on-edit dispatcher, and on-stop doc-sync. Add scripts to .claude/scripts/ and reference them in onEdit rules.", + + "docSync": { + "enabled": true, + "stateFile": ".claude/progress/adc-codebase-state-2026-04-13.html", + "pendingFile": ".claude/progress/PENDING_DOC_UPDATE.json", + "domainPatterns": [ + "src/renderer/features/([^/]+)/", + "src/main/features/([^/]+)/", + "src/shared/ipc/([^/]+)/" + ] + }, + + "onEdit": [ + { + "glob": "src/renderer/features/*/index.ts", + "scripts": ["check-barrel-exports"], + "description": "Validate barrel exports are complete and consistent" + }, + { + "glob": "src/shared/ipc/**/channels.ts", + "scripts": [], + "description": "IPC channel changes tracked for doc sync — no scripts yet" + }, + { + "glob": "src/main/features/**/schema.ts", + "scripts": [], + "description": "DB schema changes tracked for doc sync — no scripts yet" + } + ], + + "onCommit": { + "typecheck": true, + "eslint": { + "enabled": true, + "glob": "*.{ts,tsx,js,mjs}", + "flags": ["--fix", "--no-warn-ignored"] + }, + "prettier": { + "enabled": true, + "glob": "*.{ts,tsx,css,json,md,yaml,yml}" + }, + "customScripts": [] + }, + + "scriptsDir": ".claude/scripts", + + "notes": { + "addingScripts": "1. Create .claude/scripts/{name}.mjs 2. Add entry to onEdit[].scripts 3. Script receives FILE_PATH env var", + "importOrder": "Not yet configured — enable when eslint import/order rule is active", + "lefthook": "Run `npx lefthook install` once after cloning to activate git hooks" + } +} diff --git a/.claude/codex/lib.md b/.claude/codex/lib.md new file mode 100644 index 00000000..176a9ffe --- /dev/null +++ b/.claude/codex/lib.md @@ -0,0 +1,1333 @@ +# Library Exports (generated 2026-04-15) +# fn=function, class=class. Type-only files omitted. + +## src\main\agent-host +agent-host-client.ts fn createAgentHostClient + +## src\main\auth +token-store.ts + fn migrateFromJson + fn createTokenStore +oauth-manager.ts fn createOAuthManager + +## src\main\auth\providers +provider-config.ts + fn loadOAuthCredentials + fn saveOAuthCredentials + +## src\main\bootstrap +event-wiring.ts fn wireEventForwarding +ipc-wiring.ts fn wireIpcHandlers +lifecycle.ts fn setupLifecycle +service-registry.ts fn createServiceRegistry + +## src\main\bus +types.ts + fn isMutationVerb + fn parseChannel +command-bus.ts fn createCommandBus +mcp-bridge.ts fn createBusMcpBridge +session-manager.ts fn createBusSessionManager + +## src\main\db +connection.ts + fn initDatabase + fn getDatabase + fn closeDatabase + +## src\main\features\agent-dashboard +agent-dashboard-handlers.ts fn registerAgentDashboardHandlers + +## src\main\features\alerts +alert-handlers.ts fn registerAlertHandlers +alert-service.ts fn createAlertService + +## src\main\features\app +app-handlers.ts fn registerAppHandlers +app-update-handlers.ts fn registerAppUpdateHandlers +app-update-service.ts fn createAppUpdateService +window-handlers.ts fn registerWindowHandlers + +## src\main\features\app\docker +docker-handlers.ts fn registerDockerHandlers +docker-service.ts fn createDockerService + +## src\main\features\app\health +error-collector.ts fn createErrorCollector +error-handlers.ts fn registerErrorHandlers +health-registry.ts fn createHealthRegistry +health-service.ts fn createHealthService + +## src\main\features\assistant +tool-definitions.ts + fn getToolNames + fn getQueryKeysForTool + fn buildSystemPrompt +# 7 single-export files: +assistant-handlers:registerAssistantHandlers | assistant-service:createAssistantService | cross-device-query:createCrossDeviceQuery +history-store:createHistoryStore | tool-executor:createToolExecutor | watch-evaluator:createWatchEvaluator +watch-store:createWatchStore + +## src\main\features\assistant\tool-handlers +task-tools.ts + fn executeTasksCreate + fn executeTasksList + fn executeTasksUpdate + fn executeTasksDelete +git-tools.ts fn handleGitTool +project-tools.ts fn handleProjectTool + +## src\main\features\auth +auth-handlers.ts fn registerAuthHandlers +user-session-manager.ts fn createUserSessionManager + +## src\main\features\briefing +# 7 single-export files: +briefing-cache:createBriefingCache | briefing-config:createBriefingConfigManager | briefing-generator:createBriefingGenerator +briefing-handlers:registerBriefingHandlers | briefing-service:createBriefingService | briefing-summary:createBriefingSummarizer +suggestion-engine:createSuggestionEngine + +## src\main\features\bus +bus-handlers.ts fn registerBusHandlers + +## src\main\features\changelog +changelog-generator.ts + fn getGitTags + fn getCommitsBetweenTags + fn getCommitsSinceTag + fn categorizeCommit + +1 more +changelog-handlers.ts fn registerChangelogHandlers +changelog-service.ts fn createChangelogService + +## src\main\features\claude +claude-client.ts fn createClaudeClient +claude-config-scanner.ts fn scanClaudeConfig +claude-handlers.ts fn registerClaudeHandlers +conversation-store.ts fn createConversationStore + +## src\main\features\dashboard +dashboard-handlers.ts fn registerDashboardHandlers +dashboard-service.ts fn createDashboardService + +## src\main\features\file-tree +file-tree-service.ts fn createFileTreeService +files-handlers.ts fn registerFilesHandlers + +## src\main\features\fitness +fitness-handlers.ts fn registerFitnessHandlers +fitness-service.ts fn createFitnessService +stats-calculator.ts fn calculateStats + +## src\main\features\git +git-handlers.ts fn registerGitHandlers +git-service.ts fn createGitService +polyrepo-service.ts fn createPolyrepoService +worktree-service.ts fn createWorktreeService + +## src\main\features\hub\device +device-handlers.ts fn registerDeviceHandlers +device-service.ts fn createDeviceService +heartbeat.ts fn createHeartbeatService + +## src\main\features\hub +hub-config-store.ts + fn encryptApiKey + fn decryptApiKey + fn createHubConfigStore +hub-errors.ts + class HubApiError + class HubNotConfiguredError + class HubConnectionError +hub-event-mapper.ts + fn configToConnection + fn routeWebSocketEvent +# 8 single-export files: +hub-api-client:createHubApiClient | hub-auth-service:createHubAuthService | hub-client:createHubClient +hub-connection:createHubConnectionManager | hub-handlers:registerHubHandlers | hub-sync:createHubSyncService +hub-ws-client:createHubWsClient | webhook-relay:createWebhookRelay + +## src\main\features\ideas +ideas-handlers.ts fn registerIdeasHandlers +ideas-service.ts fn createIdeasService + +## src\main\features\insights +insights-handlers.ts fn registerInsightsHandlers +insights-service.ts fn createInsightsService + +## src\main\features\integrations +calendar.ts + fn createCalendarService + fn registerCalendarHandlers +github-integration.ts + fn createGitHubService + fn registerGitHubHandlers +spotify.ts + fn createSpotifyService + fn registerSpotifyHandlers +integrations-handlers.ts fn registerIntegrationsHandlers +integrations-service.ts fn createIntegrationsService + +## src\main\features\integrations\email +email-config.ts + fn getSmtpPreset + fn buildSmtpConfig + fn isValidEmail + fn validateEmailAddresses +email-encryption.ts + fn isEncryptedEntry + fn encryptSecret + fn decryptSecret + fn getDecryptedPassword +email-queue.ts + fn migrateEmailQueueFromJson + fn loadQueueFromDb + fn addToQueue + fn persistQueueToDb + +3 more +email-store.ts + fn migrateEmailConfigFromJson + fn loadEmailConfig + fn saveEmailConfig +smtp-transport.ts + fn createTransporter + fn validateFromAddress + fn sendEmailViaSmtp + fn verifySmtpConnection +email-handlers.ts fn registerEmailHandlers +email-service.ts fn createEmailService + +## src\main\features\integrations\notifications +github-watcher.ts fn createGitHubWatcher +notification-filter.ts fn matchesFilter +notification-handlers.ts fn registerNotificationHandlers +notification-manager.ts fn createNotificationManager +notification-store.ts fn createNotificationStore +slack-watcher.ts fn createSlackWatcher + +## src\main\features\mcp +mcp-handlers.ts fn registerMcpHandlers + +## src\main\features\merge +merge-handlers.ts fn registerMergeHandlers +merge-service.ts fn createMergeService + +## src\main\features\milestones +milestones-handlers.ts fn registerMilestonesHandlers +milestones-service.ts fn createMilestonesService + +## src\main\features\notes +notes-handlers.ts fn registerNotesHandlers +notes-service.ts fn createNotesService + +## src\main\features\oauth +oauth-handlers.ts fn registerOAuthHandlers + +## src\main\features\planner +planner-service.ts + fn migrateFromJson + fn createPlannerService +planner-handlers.ts fn registerPlannerHandlers + +## src\main\features\progress +task-file-io.ts + fn readFrontmatter + fn writeFrontmatter + fn detectRootFile +log-cleanup.ts fn runLogCleanup +progress-handlers.ts fn registerProgressHandlers +progress-service.ts fn createProgressService +session-writer.ts fn createSessionWriter + +## src\main\features\project +doc-detectors.ts + fn detectServices + fn detectDataPersistence + fn detectComponentPattern + fn detectServicePattern + +4 more +# 10 single-export files: +claudemd-generator:createClaudeMdGenerator | codebase-analyzer:createCodebaseAnalyzer | doc-generator:createDocGenerator +github-repo-creator:createGitHubRepoCreator | gitignore-manager:ensureGitignoreEntries | project-detector:detectRepoStructure +project-handlers:registerProjectHandlers | project-service:createProjectService | setup-pipeline:createSetupPipeline +skills-resolver:createSkillsResolver + +## src\main\features\qa +qa-report-parser.ts + fn parseQaReport + fn createFallbackReport +qa-agent-poller.ts fn waitForAgentCompletion +qa-handlers.ts fn registerQaHandlers +qa-prompt.ts fn buildQaPrompt +qa-runner.ts fn createQaRunner +qa-session-store.ts fn createQaSessionStore +qa-trigger.ts fn createQaTrigger + +## src\main\features\qa\recorder +exporter.ts fn createExporter +index.ts fn createQaRecorderService +recorder-handlers.ts fn registerQaRecorderHandlers +runner.ts fn createRunner +script-store.ts fn createScriptStore +selector-builder.ts fn buildSelector + +## src\main\features\settings\data-management +data-export.ts + fn exportData + fn importData +# 9 single-export files: +cleanup-service:createCleanupService | config-reader:createConfigReader | data-dir-handlers:registerDataDirHandlers +data-management-handlers:registerDataManagementHandlers | data-migrator:createDataMigrator | reinitializable-service:isReinitializable +storage-inspector:createStorageInspector | user-data-migrator:createUserDataMigrator | user-data-resolver:createUserDataResolver + +## src\main\features\settings +settings-encryption.ts + fn isEncryptedEntry + fn encryptSecret + fn decryptSecret + fn isWebhookSecretKey + +1 more +settings-store.ts + fn migrateFromJson + fn loadSettingsFile + fn saveSettingsFile +hotkeys.ts fn registerHotkeyHandlers +security-handlers.ts fn registerSecurityHandlers +settings-handlers.ts fn registerSettingsHandlers +settings-service.ts fn createSettingsService +webhook-settings-handlers.ts fn registerWebhookSettingsHandlers + +## src\main\features\settings\screen +screen-capture-service.ts fn createScreenCaptureService +screen-handlers.ts fn registerScreenHandlers + +## src\main\features\settings\voice +voice-handlers.ts fn registerVoiceHandlers +voice-service.ts fn createVoiceService + +## src\main\features\terminal +terminal-handlers.ts fn registerTerminalHandlers +terminal-service.ts fn createTerminalService + +## src\main\features\visualization +agent-teams.ts + fn agentNameToTaskNumber + fn extractFileScope + fn parseTaskFile + fn buildAgentTeamsData +codebase-graph.ts + fn detectFramework + fn getFileGroup + fn buildCodebaseGraph +import-parser.ts + fn extractImportSpecifiers + fn loadTsconfigPaths + fn resolveSpecifier + fn collectSourceFiles +session-log.ts + fn encodeProjectPath + fn findSessionFile + fn buildSessionLog +index.ts fn createVisualizationService +visualization-handlers.ts fn registerVisualizationHandlers + +## src\main\features\workflow +cost-tracker.ts fn createCostTracker +jsonl-watcher.ts fn createJsonlWatcher +progress-syncer.ts fn createProgressSyncer +progress-watcher.ts fn createProgressWatcher +workflow-handlers.ts fn registerWorkflowHandlers +workflow-service.ts fn createWorkflowService + +## src\main\features\workflow\engine +context-builder.ts fn buildAgentClaudeMd +index.ts fn createWorkflowEngineModule +spawn-validator.ts fn validateSpawnPrompt + +## src\main\features\workflow\engine\states +finalize.ts + fn archiveArtifact + fn runFinalizing +guardian.ts fn runGuardian +plan.ts fn runPlan +preflight.ts fn runPreflight +qa-gate.ts fn runQaGate +setup.ts fn runSetup +spawn.ts fn runSpawning + +## src\main\features\workflow\templates +workflow-template-handlers.ts fn registerWorkflowTemplateHandlers +workflow-template-service.ts fn createWorkflowTemplateService + +## src\main\features\workspace +workspace-handlers.ts fn registerWorkspaceHandlers +workspace-session-manager.ts fn createWorkspaceSessionManager + +## src\main\features\workspaces +workspaces-handlers.ts fn registerWorkspacesHandlers +workspaces-service.ts fn createWorkspacesService + +## src\main\ipc +index.ts fn registerAllHandlers +router.ts fn IpcRouter +throttle.ts fn createThrottle + +## src\main\lib +logger.ts + fn initLogger + fn setLogLevel + fn createScopedLogger + fn getLogFilePath +lazy-service.ts fn lazyService +safe-write-json.ts fn safeWriteJson + +## src\main\mcp-servers\browser +tools.ts fn executeBrowserTool + +## src\main\mcp-servers\calendar +calendar-client.ts fn createCalendarClient +tools.ts fn executeCalendarTool + +## src\main\mcp-servers\discord +discord-client.ts fn createDiscordClient +tools.ts fn executeDiscordTool + +## src\main\mcp-servers\github +github-client.ts + class GitHubCliNotInstalledError + class GitHubCliNotAuthenticatedError + class GitHubCliApiError + fn createGitHubCliClient +tools.ts fn executeGitHubTool + +## src\main\mcp-servers\slack +slack-client.ts fn createSlackClient +tools.ts fn executeSlackTool + +## src\main\mcp-servers\spotify +spotify-client.ts fn createSpotifyClient +tools.ts fn executeSpotifyTool + +## src\main\mcp +mcp-client.ts fn createMcpClient +mcp-manager.ts fn createMcpManager +mcp-registry.ts fn createMcpRegistry + +## src\main\services\agent-manager +stream-json-parser.ts + fn extractToolCalls + fn createStreamJsonParser +agent-manager-service.ts fn createAgentManagerService +process-manager.ts fn createProcessManager +subprocess-strategy.ts fn SubprocessStrategy + +## src\main\services\session-jsonl +jsonl-parser.ts fn createJsonlTailReader +session-jsonl-reader.ts fn createSessionJSONLReaderService + +## src\main\services\team-watcher +team-watcher-service.ts fn createTeamWatcherService + +## src\main\services\worktree-provisioner +worktree-provisioner.ts fn createWorktreeProvisioner + +## src\main\tray +hotkey-manager.ts fn createHotkeyManager +quick-input.ts fn createQuickInputWindow +tray-manager.ts fn createTrayManager + +## src\renderer\app +App.tsx fn App +providers.tsx fn Providers +router.tsx fn AppRouter + +## src\renderer\app\components +route-skeletons.tsx + fn DashboardSkeleton + fn ProjectSkeleton + fn SettingsSkeleton + fn GenericPageSkeleton + +## src\renderer\app\layouts +# 9 single-export files: +AppBreadcrumbs:AppBreadcrumbs | ContentHeader:ContentHeader | LayoutWrapper:LayoutWrapper +ProjectTabBar:ProjectTabBar | RootLayout:RootLayout | TitleBar:TitleBar +TitleBarScreenshot:TitleBarScreenshot | TopBar:TopBar | UserMenu:UserMenu + +## src\renderer\app\layouts\sidebar-layouts +AppSidebar.tsx fn AppSidebar +NavGroup.tsx fn NavGroup + +## src\renderer\app\routes +# 9 single-export files: +assistant.routes:createAssistantRoutes | auth.routes:createAuthRoutes | dashboard.routes:createDashboardRoutes +integrations.routes:createIntegrationsRoutes | misc.routes:createMiscRoutes | personal.routes:createPersonalRoutes +productivity.routes:createProductivityRoutes | project.routes:createProjectRoutes | settings.routes:createSettingsRoutes + +## src\renderer\features\agent-dashboard\api +useAgentMessages.ts + fn useAgentMessages + fn useAgentMessagePreviews +useAgentMutations.ts + fn useSpawnProjectOwner + fn useSpawnTeamLead + fn useSendMessage + fn useStopSession +useAgentSessions.ts + fn useAgentSessions + fn useAgentSession + fn useSessionsForTask + fn useSessionLog + +1 more +useQaSession.ts + fn useQaSession + fn useQaSessions +useTaskProgress.ts + fn useTasksForFeature + fn useTask +useWorkflowEngine.ts + fn useWorkflowRuns + fn useAgentDefinitions + fn useWorkflowRun + fn useApplyWorkflow + +1 more +useWorkflowTemplates.ts + fn useWorkflowTemplates + fn useWorkflowTemplate + fn useCreateTemplate + fn useUpdateTemplate + +2 more + +## src\renderer\features\agent-dashboard\components +AgentPanelTabs.tsx + fn FilesChangedTab + fn ErrorsTab +# 18 single-export files: +ActivityLine:ActivityLine | AgentChatPanel:AgentChatPanel | AgentDashboardPage:AgentDashboardPage +AgentLayoutGrid:AgentLayoutGrid | AgentLayoutSingle:AgentLayoutSingle | AgentLayoutToolbar:AgentLayoutToolbar +AgentPanelCompact:AgentPanelCompact | AgentPanelExpanded:AgentPanelExpanded | AgentPanelPopup:AgentPanelPopup +AgentStatusBar:AgentStatusBar | QaPanel:QaPanel | RunningWorkflowsPanel:RunningWorkflowsPanel +TasksTab:TasksTab | TemplateEditorPanel:TemplateEditorPanel | TemplateListPanel:TemplateListPanel +TextMessage:TextMessage | ToolCallCard:ToolCallCard | UserMessage:UserMessage + +## src\renderer\features\agent-dashboard\hooks +useAgentEvents.ts fn useAgentDashboardEvents +useAgentStream.ts fn useAgentStream +useProgressEvents.ts fn useProgressEvents +useQaEvents.ts fn useQaEvents + +## src\renderer\features\agent-dashboard\lib +buildChatItems.ts fn buildChatItems + +## src\renderer\features\agents\api +useAgents.ts + fn useAllAgents + fn useAgents + fn useStopAgent + +## src\renderer\features\agents\components +AgentDashboard.tsx fn AgentDashboard + +## src\renderer\features\agents\hooks +useAgentEvents.ts fn useAgentEvents + +## src\renderer\features\assistant\api +useAssistant.ts + fn useHistory + fn useSendCommand + fn useClearHistory + +## src\renderer\features\assistant\components +# 11 single-export files: +AssistantInputBar:AssistantInputBar | AssistantPage:AssistantPage | AssistantWidget:AssistantWidget +ProjectSelector:ProjectSelector | QuickActionChips:QuickActionChips | QuickActions:QuickActions +ResponseStream:ResponseStream | SidebarAssistantButton:SidebarAssistantButton | WidgetInput:WidgetInput +WidgetMessageArea:WidgetMessageArea | WidgetPanel:WidgetPanel + +## src\renderer\features\assistant\hooks +useAssistantEvents.ts + fn setLastCommand + fn useAssistantEvents +useAssistantVoice.ts fn useAssistantVoice + +## src\renderer\features\auth\api +useAuth.ts + fn useLogin + fn useRegister + fn useLogout + fn useRefreshToken + +2 more + +## src\renderer\features\auth\components +AuthGuard.tsx fn AuthGuard +LoginPage.tsx fn LoginPage +RegisterPage.tsx fn RegisterPage + +## src\renderer\features\auth\hooks +useAuthEvents.ts fn useAuthInit +useSavedLogins.ts fn useSavedLogins +useSessionEvents.ts fn useSessionEvents +useTokenRefresh.ts fn useTokenRefresh + +## src\renderer\features\dashboard\api +useCaptures.ts + fn useCaptures + fn useCaptureMutations + fn useUpdateCapture + +## src\renderer\features\dashboard\components +# 7 single-export files: +ActiveAgents:ActiveAgents | DailyStats:DailyStats | DashboardPage:DashboardPage +GreetingHeader:GreetingHeader | QuickCapture:QuickCapture | RecentProjects:RecentProjects +TodayView:TodayView + +## src\renderer\features\dashboard\hooks +useDashboardEvents.ts fn useDashboardEvents + +## src\renderer\features\diff-viewer\api +useDiff.ts + fn useDiffSummary + fn useFileDiffContent + +## src\renderer\features\diff-viewer\components +DiffFileList.tsx fn DiffFileList +DiffViewer.tsx fn DiffViewer + +## src\renderer\features\file-explorer\api +useFileTree.ts fn useFileTree + +## src\renderer\features\file-explorer\components +FileExplorer.tsx fn FileExplorer +FileNode.tsx fn FileNode + +## src\renderer\features\file-explorer\hooks +useFileTreeEvents.ts fn useFileTreeEvents + +## src\renderer\features\git-overview\api +useGit.ts + fn useGitStatus + fn useGitBranches + fn useListWorktrees + fn useCreateBranch + +6 more +useCommitHistory.ts fn useCommitHistory + +## src\renderer\features\git-overview\components +# 9 single-export files: +BranchDiffPanel:BranchDiffPanel | BranchList:BranchList | ChangelogSummary:ChangelogSummary +CommitHistory:CommitHistory | CommitPanel:CommitPanel | CreatePrDialog:CreatePrDialog +GitPage:GitPage | GitStatusCard:GitStatusCard | WorktreeList:WorktreeList + +## src\renderer\features\hub-setup\api +useDocker.ts + fn useDockerStatus + fn useDockerSetupHub + +## src\renderer\features\hub-setup\components +HubSetupPage.tsx fn HubSetupPage + +## src\renderer\features\hub-setup\lib +validateHubUrl.ts fn validateHubUrl + +## src\renderer\features\ideation\api +useIdeas.ts + fn useIdeas + fn useCreateIdea + fn useUpdateIdea + fn useDeleteIdea + +1 more + +## src\renderer\features\ideation\components +IdeaCard.tsx fn IdeaCard +IdeaEditForm.tsx fn IdeaEditForm +IdeationFilterRow.tsx fn IdeationFilterRow +IdeationPage.tsx fn IdeationPage + +## src\renderer\features\ideation\hooks +useIdeaEvents.ts fn useIdeaEvents + +## src\renderer\features\insights\api +useInsights.ts + fn useInsightMetrics + fn useInsightTimeSeries + fn useTaskDistribution + fn useProjectBreakdown + +## src\renderer\features\insights\components +InsightsPage.tsx fn InsightsPage + +## src\renderer\features\integrations\api +useEmail.ts + fn useEmailConfig + fn useEmailQueue + fn useUpdateEmailConfig + fn useTestEmailConnection + +3 more +useGitHub.ts + fn useGitHubAuthStatus + fn useGitHubRepos + fn useGitHubPrs + fn useGitHubPrDetail + +4 more +useMcpTool.ts + fn useMcpToolCall + fn useMcpConnectionState + fn useMcpConnectedServers +useNotifications.ts + fn useAllNotifications + fn useNotificationsConfig + fn useWatcherStatus + fn useMarkNotificationRead + +4 more + +## src\renderer\features\integrations\components +# 17 single-export files: +CalendarPanel:CalendarPanel | DiscordActionModal:DiscordActionModal | DiscordPanel:DiscordPanel +EmailPanel:EmailPanel | GitHubConnectionStatus:GitHubConnectionStatus | GitHubPanel:GitHubPanel +IntegrationsPage:IntegrationsPage | IssueCreateForm:IssueCreateForm | IssueList:IssueList +NotificationList:NotificationList | NotificationRules:NotificationRules | NotificationsPanel:NotificationsPanel +PrDetailModal:PrDetailModal | PrDiffView:PrDiffView | PrList:PrList +SlackActionModal:SlackActionModal | SlackPanel:SlackPanel + +## src\renderer\features\integrations\hooks +useGitHubEvents.ts fn useGitHubEvents +useGitHubProjectSync.ts fn useGitHubProjectSync +useIntegrationsEvents.ts fn useIntegrationsEvents + +## src\renderer\features\integrations +notification-rules-storage.ts + fn loadRules + fn saveRules + +## src\renderer\features\merge\api +useMerge.ts + fn useMergeDiff + fn useFileDiff + fn useMergeConflicts + fn useMergeBranch + +1 more + +## src\renderer\features\merge\components +ConflictResolver.tsx fn ConflictResolver +FileDiffViewer.tsx fn FileDiffViewer +MergeConfirmModal.tsx fn MergeConfirmModal +MergePreviewPanel.tsx fn MergePreviewPanel + +## src\renderer\features\my-work\api +useMyWork.ts fn useAllTasks + +## src\renderer\features\my-work\components +MyWorkPage.tsx fn MyWorkPage + +## src\renderer\features\onboarding\components +ClaudeCliStep.tsx fn ClaudeCliStep +CompleteStep.tsx fn CompleteStep +IntegrationsStep.tsx fn IntegrationsStep +OnboardingWizard.tsx fn OnboardingWizard +WelcomeStep.tsx fn WelcomeStep + +## src\renderer\features\onboarding +store.ts + fn getStepIndex + fn getTotalSteps + +## src\renderer\features\personal\alerts\api +useAlerts.ts + fn useAlerts + fn useCreateAlert + fn useDismissAlert + fn useDeleteAlert +useAlertMutations.ts fn useUpdateAlert + +## src\renderer\features\personal\alerts\components +AlertEditDialog.tsx + fn AlertEditDialog + fn LinkedToBadge +AlertNotification.tsx fn AlertNotification +AlertsPage.tsx fn AlertsPage +CreateAlertModal.tsx fn CreateAlertModal +RecurringAlerts.tsx fn RecurringAlerts + +## src\renderer\features\personal\alerts\hooks +useAlertEvents.ts fn useAlertEvents + +## src\renderer\features\personal\briefing\api +useBriefing.ts + fn useDailyBriefing + fn useGenerateBriefing + fn useBriefingConfig + fn useUpdateBriefingConfig + +1 more + +## src\renderer\features\personal\briefing\components +BriefingConfigPanel.tsx fn BriefingConfigPanel +BriefingPage.tsx fn BriefingPage +SuggestionCard.tsx fn SuggestionCard + +## src\renderer\features\personal\changelog\api +useChangelog.ts + fn useChangelog + fn useGenerateChangelog + fn useAddChangelogEntry + fn useUpdateChangelogEntry + +1 more + +## src\renderer\features\personal\changelog\components +# 7 single-export files: +CategorySection:CategorySection | ChangelogPage:ChangelogPage | EditableCategory:EditableCategory +EditEntryDialog:EditEntryDialog | EntryPreview:EntryPreview | GenerateForm:GenerateForm +VersionCard:VersionCard + +## src\renderer\features\personal\components +PersonalPage.tsx fn PersonalPage + +## src\renderer\features\personal\fitness\api +useFitness.ts + fn useWorkouts + fn useDeleteWorkout + fn useMeasurements + fn useFitnessStats + +3 more +useFitnessMutations.ts + fn useLogWorkout + fn useUpdateWorkout + fn useUpdateMeasurement + fn useDeleteMeasurement + +3 more + +## src\renderer\features\personal\fitness\components +# 10 single-export files: +BodyComposition:BodyComposition | FitnessPage:FitnessPage | GoalEditDialog:GoalEditDialog +GoalsPanel:GoalsPanel | MeasurementEditDialog:MeasurementEditDialog | StatsOverview:StatsOverview +WorkoutEditDialog:WorkoutEditDialog | WorkoutExerciseList:WorkoutExerciseList | WorkoutForm:WorkoutForm +WorkoutLog:WorkoutLog + +## src\renderer\features\personal\fitness\hooks +useFitnessEvents.ts fn useFitnessEvents + +## src\renderer\features\personal\notes\api +useNotes.ts + fn useNotes + fn useCreateNote + fn useUpdateNote + fn useDeleteNote + +1 more + +## src\renderer\features\personal\notes\components +NoteEditor.tsx fn NoteEditor +NotesList.tsx fn NotesList +NotesPage.tsx fn NotesPage +QuickNote.tsx fn QuickNote + +## src\renderer\features\personal\notes\hooks +useNoteEvents.ts fn useNoteEvents + +## src\renderer\features\personal\planner\api +usePlanner.ts + fn useDay + fn useUpdateDay + fn useAddTimeBlock + fn useUpdateTimeBlock + +1 more +useWeeklyReview.ts + fn useWeeklyReview + fn useGenerateWeeklyReview + fn useUpdateWeeklyReflection + +## src\renderer\features\personal\planner\components +weekly-review-utils.ts + fn getWeekMonday + fn formatWeekRange + fn formatDayCompact + fn isToday + +1 more +# 11 single-export files: +CalendarOverlay:CalendarOverlay | CategoryBar:CategoryBar | DayCompact:DayCompact +DayView:DayView | GoalsList:GoalsList | PlannerPage:PlannerPage +StatCard:StatCard | TimeBlockEditor:TimeBlockEditor | WeeklyReflectionSection:WeeklyReflectionSection +WeeklyReviewPage:WeeklyReviewPage | WeekOverview:WeekOverview + +## src\renderer\features\personal\planner\hooks +usePlannerEvents.ts fn usePlannerEvents + +## src\renderer\features\planning\components +PlanningPage.tsx fn PlanningPage + +## src\renderer\features\productivity\api +useCalendar.ts + fn useCalendarEvents + fn useCalendarCreateEvent + fn useCalendarDeleteEvent +useSpotify.ts + fn useSpotifyPlayback + fn useSpotifySearch + fn useSpotifyPlay + fn useSpotifyPause + +4 more + +## src\renderer\features\productivity\components +CalendarWidget.tsx fn CalendarWidget +ProductivityPage.tsx fn ProductivityPage +SpotifyWidget.tsx fn SpotifyWidget + +## src\renderer\features\projects\api +useGit.ts + fn useGitStatus + fn useGitBranches + fn useWorktrees + fn useRepoStructure + +7 more +useProjects.ts + fn useProjects + fn useAddProject + fn useRemoveProject + fn useSelectDirectory + +6 more + +## src\renderer\features\projects\components +# 12 single-export files: +BranchSelector:BranchSelector | CreateProjectWizard:CreateProjectWizard | ProjectEditDialog:ProjectEditDialog +ProjectInitWizard:ProjectInitWizard | ProjectList:ProjectList | ProjectListPage:ProjectListPage +RepoTypeSelector:RepoTypeSelector | SetupProgressModal:SetupProgressModal | SubprojectSelector:SubprojectSelector +SubRepoDetector:SubRepoDetector | SubRepoSelector:SubRepoSelector | WorktreeManager:WorktreeManager + +## src\renderer\features\projects\components\create-wizard-steps +StepDetails.tsx fn StepDetails +StepGitHub.tsx fn StepGitHub +StepReview.tsx fn StepReview +StepTechStack.tsx fn StepTechStack + +## src\renderer\features\projects\components\wizard-steps +StepConfigure.tsx fn StepConfigure +StepConfirm.tsx fn StepConfirm +StepDetection.tsx fn StepDetection +StepFolder.tsx fn StepFolder +StepSubRepos.tsx fn StepSubRepos + +## src\renderer\features\projects\hooks +useProjectEvents.ts fn useProjectEvents +useSetupProgress.ts fn useSetupProgress + +## src\renderer\features\qa-recorder\api +useRuns.ts + fn useRuns + fn useRun + fn useRunScript +useScriptMutations.ts + fn useSaveScript + fn useDeleteScript + fn useExportRun +useScripts.ts + fn useScripts + fn useScript + +## src\renderer\features\qa-recorder\components +QaRecorderPage.tsx fn QaRecorderPage +RunOutputPanel.tsx fn RunOutputPanel +ScriptSelector.tsx fn ScriptSelector +StepPanel.tsx fn StepPanel +WebviewPanel.tsx fn WebviewPanel + +## src\renderer\features\qa-recorder\hooks +useRecorderEvents.ts fn useRecorderEvents + +## src\renderer\features\roadmap\api +useMilestones.ts + fn useMilestones + fn useCreateMilestone + fn useUpdateMilestone + fn useDeleteMilestone + +2 more + +## src\renderer\features\roadmap\components +MilestoneEditDialog.tsx fn MilestoneEditDialog +MilestoneTaskListEditor.tsx fn MilestoneTaskListEditor +RoadmapPage.tsx fn RoadmapPage + +## src\renderer\features\roadmap\hooks +useMilestoneEvents.ts fn useMilestoneEvents + +## src\renderer\features\settings\api +useDataLocation.ts + fn useDataLocation + fn useValidateDataDir + fn useSetDataDir + fn useConfirmDataDir + +1 more +useDataManagement.ts + fn useDataRegistry + fn useDataUsage + fn useDataRetention + fn useUpdateRetention + +4 more +useDevices.ts + fn useDevices + fn useRegisterDevice + fn useUpdateDevice +useHealth.ts + fn useErrorLog + fn useErrorStats + fn useHealthStatus + fn useClearErrorLog + +1 more +useHub.ts + fn useHubStatus + fn useHubConfig + fn useHubConnect + fn useHubDisconnect + +2 more +useOAuth.ts + fn useOAuthStatus + fn useOAuthAuthorize + fn useOAuthRevoke +useScreenCapture.ts + fn useAvailableSources + fn useCaptureScreen + fn useScreenPermission +useSettings.ts + fn useSettings + fn useUpdateSettings + fn useProfiles + fn useCreateProfile + +5 more +useVoice.ts + fn useVoiceConfig + fn useUpdateVoiceConfig + fn useVoicePermission +useWebhookConfig.ts + fn useWebhookConfig + fn useUpdateWebhookConfig + +## src\renderer\features\settings\components +oauth-provider-constants.ts + fn validateCredentials + fn useOAuthProviders + fn useSaveOAuthProvider +# 35 single-export files: +AppBehaviorSection:AppBehaviorSection | AppearanceModeSection:AppearanceModeSection | BackgroundSettings:BackgroundSettings +ClaudeAuthSettings:ClaudeAuthSettings | CollapsibleInstructions:CollapsibleInstructions | ColorThemeSection:ColorThemeSection +CredentialInput:CredentialInput | DataLocationSection:DataLocationSection | DeviceCard:DeviceCard +DeviceSelector:DeviceSelector | GitHubAuthSettings:GitHubAuthSettings | GitHubSetupInstructions:GitHubSetupInstructions +HotkeySettings:HotkeySettings | HubSettings:HubSettings | LayoutSection:LayoutSection +OAuthConnectionStatus:OAuthConnectionStatus | OAuthProviderForm:OAuthProviderForm | OAuthProviderSettings:OAuthProviderSettings +ProfileCard:ProfileCard | ProfileFormModal:ProfileFormModal | ProfileSection:ProfileSection +ProviderConsoleInfo:ProviderConsoleInfo | RetentionControl:RetentionControl | SecretInput:SecretInput +SettingsPage:SettingsPage | SlackSetupInstructions:SlackSetupInstructions | StorageManagementSection:StorageManagementSection +StorageUsageBar:StorageUsageBar | TypographySection:TypographySection | UiScaleSection:UiScaleSection +WebhookSettings:WebhookSettings | WebhookUrlDisplay:WebhookUrlDisplay | WorkspaceCard:WorkspaceCard +WorkspaceEditor:WorkspaceEditor | WorkspacesTab:WorkspacesTab + +## src\renderer\features\settings\components\health +HealthIndicator.tsx fn HealthIndicator +HealthPanel.tsx fn HealthPanel + +## src\renderer\features\settings\components\screen +ScreenshotButton.tsx fn ScreenshotButton +ScreenshotViewer.tsx fn ScreenshotViewer + +## src\renderer\features\settings\components\theme-editor +css-exporter.ts + fn exportTokensToCss + fn copyToClipboard +# 7 single-export files: +ColorControl:ColorControl | ColorSection:ColorSection | css-parser:parseCssToTokens +CssImportDialog:CssImportDialog | SavedThemesBar:SavedThemesBar | ThemeEditorPage:ThemeEditorPage +ThemePreview:ThemePreview + +## src\renderer\features\settings\components\voice +VoiceButton.tsx fn VoiceButton +VoiceSettings.tsx fn VoiceSettings + +## src\renderer\features\settings\hooks +useSpeechSynthesis.ts + fn useSpeechSynthesis + fn findVoice + fn getDefaultVoice +useDataManagementEvents.ts fn useDataManagementEvents +useDeviceEvents.ts fn useDeviceEvents +useErrorEvents.ts fn useErrorEvents +useSpeechRecognition.ts fn useSpeechRecognition + +## src\renderer\features\tasks\api +useAgentMutations.ts + fn useStartPlanning + fn useStartExecution + fn useReplanWithFeedback + fn useKillAgent + +1 more +useProgress.ts + fn useProgressTasks + fn useProgressTask + fn useArchivedProgressTasks +useProgressMutations.ts + fn useCreateProgressTask + fn useUpdateProgressTask + fn useArchiveProgressTask + fn useDeleteProgressTask + +5 more +useQaMutations.ts + fn useQaReport + fn useQaSession + fn useStartQuietQa + fn useStartFullQa + +1 more +useTaskMutations.ts + fn useUpdateTaskStatus + fn useDeleteTask + fn useExecuteTask + fn useCancelTask +useTasks.ts + fn useTasks + fn useTask + fn useAllTasks + fn useCreateTask + +## src\renderer\features\tasks\components +# 8 single-export files: +BulkActionBar:BulkActionBar | CreatePrDialog:CreatePrDialog | CreateTaskDialog:CreateTaskDialog +EditProgressTaskDialog:EditProgressTaskDialog | LinkJiraDialog:LinkJiraDialog | LinkPrDialog:LinkPrDialog +TaskFiltersToolbar:TaskFiltersToolbar | TaskStatusBadge:TaskStatusBadge + +## src\renderer\features\tasks\components\cells +# 12 single-export files: +ActionsCell:ActionsCell | ActivitySparklineCell:ActivitySparklineCell | AgentCell:AgentCell +CostCell:CostCell | PriorityCell:PriorityCell | ProgressBarCell:ProgressBarCell +PrStatusCell:PrStatusCell | RelativeTimeCell:RelativeTimeCell | StatusBadgeCell:StatusBadgeCell +TitleCell:TitleCell | WatchdogDropdown:WatchdogDropdown | WorkspaceCell:WorkspaceCell + +## src\renderer\features\tasks\components\detail +# 13 single-export files: +AgentDetailExpander:AgentDetailExpander | ExecutionLog:ExecutionLog | PlanFeedbackDialog:PlanFeedbackDialog +PlanViewer:PlanViewer | ProgressTaskDetailRow:ProgressTaskDetailRow | PrStatusPanel:PRStatusPanel +QaReportViewer:QaReportViewer | SubtaskList:SubtaskList | summary-block-parser:extractSummaryBlock +TaskControls:TaskControls | TaskDetailRow:TaskDetailRow | TaskResultView:TaskResultView +TeamActivityPanel:TeamActivityPanel + +## src\renderer\features\tasks\components\grid +ProgressTaskGrid.tsx fn ProgressTaskGrid + +## src\renderer\features\tasks\hooks +useAgentEvents.ts fn useAgentEvents +useQaEvents.ts fn useQaEvents +useTaskEvents.ts fn useTaskEvents + +## src\renderer\features\terminals\api +useTerminals.ts + fn useTerminals + fn useCreateTerminal + fn useCloseTerminal + fn useSendTerminalInput + +1 more + +## src\renderer\features\terminals\components +TerminalGrid.tsx fn TerminalGrid +TerminalInstance.tsx fn TerminalInstance + +## src\renderer\features\terminals\hooks +useTerminalEvents.ts fn useTerminalEvents + +## src\renderer\features\tools\api +useWorkflowTemplates.ts + fn useWorkflowTemplates + fn useWorkflowTemplate + fn usePluginArtifacts + fn useCreateTemplate + +3 more +useClaudeConfig.ts fn useClaudeConfig + +## src\renderer\features\tools\components +PhaseSection.tsx fn PhaseSection +ToolsPage.tsx fn ToolsPage +WorkflowEditor.tsx fn WorkflowEditor +WorkflowSidebar.tsx fn WorkflowSidebar + +## src\renderer\features\visualization\api +useVisualization.ts + fn useCodebaseGraph + fn useAgentTeams + fn useSessionLog + +## src\renderer\features\visualization\components\canvas +VisualizationCanvas.tsx fn VisualizationCanvas + +## src\renderer\features\visualization\components\edges +AgentScopeEdge.tsx fn AgentScopeEdge +DataFlowEdge.tsx fn DataFlowEdge + +## src\renderer\features\visualization\components\nodes +AgentTaskNode.tsx fn AgentTaskNode +FeatureGroupNode.tsx fn FeatureGroupNode +FileGroupNode.tsx fn FileGroupNode +FileNode.tsx fn FileNode +GuardianNode.tsx fn GuardianNode + +## src\renderer\features\visualization\components\panels\node-detail +node-content.tsx + fn getPanelTitle + fn renderNodeContent +# 8 single-export files: +AgentDetail:AgentDetail | EventList:EventList | FeatureGroupDetail:FeatureGroupDetail +FileDetail:FileDetail | FileGroupDetail:FileGroupDetail | GuardianDetail:GuardianDetail +SessionLogSection:SessionLogSection | types:statusVariant + +## src\renderer\features\visualization\components\panels +NodeDetailPanel.tsx fn NodeDetailPanel + +## src\renderer\features\visualization\components\toolbar +LayerToggleToolbar.tsx fn LayerToggleToolbar + +## src\renderer\features\visualization\components +VisualizationPage.tsx fn VisualizationPage + +## src\renderer\features\visualization\lib +graph-builders.ts + fn buildCodebaseRFNodes + fn buildHierarchicalCodebaseNodes + fn buildAgentRFNodes + fn buildCodebaseGroupEdges + +1 more + +## src\renderer\features\workflow-pipeline\api +useUpdateTask.ts + fn useUpdateTaskDescription + fn useUpdateTaskPlan + +## src\renderer\features\workflow-pipeline\components +PipelineConnector.tsx fn PipelineConnector +PipelineDiagram.tsx fn PipelineDiagram +PipelineStepNode.tsx fn PipelineStepNode +TaskSelector.tsx fn TaskSelector +WorkflowPipelinePage.tsx fn WorkflowPipelinePage + +## src\renderer\features\workflow-pipeline\components\shared +MarkdownEditor.tsx fn MarkdownEditor +MarkdownRenderer.tsx fn MarkdownRenderer + +## src\renderer\features\workflow-pipeline\components\step-panels +# 8 single-export files: +BacklogPanel:BacklogPanel | DonePanel:DonePanel | ErrorPanel:ErrorPanel +PlanningPanel:PlanningPanel | PlanReadyPanel:PlanReadyPanel | QueuedPanel:QueuedPanel +ReviewPanel:ReviewPanel | RunningPanel:RunningPanel + +## src\renderer\features\workflow-pipeline\hooks +useWorkflowPipelineEvents.ts fn useWorkflowPipelineEvents + +## src\renderer\features\workflow\api +useWorkflow.ts + fn useStartProgressWatcher + fn useStopProgressWatcher + fn useLaunchTask + fn useSessionStatus + +1 more + +## src\renderer\features\workflow\components +WorkflowPermissionModal.tsx fn WorkflowPermissionModal +WorkflowStatusBar.tsx fn WorkflowStatusBar + +## src\renderer\features\workflow\hooks +useWorkflowContext.ts fn useWorkflowContext +useWorkflowEvents.ts fn useWorkflowEvents +useWorkflowMilestones.ts fn useWorkflowMilestones +useWorkflowStatus.ts fn useWorkflowStatus + +## src\renderer\features\workspace\api +useWorkspace.ts + fn useWorkspaceSessions + fn useWorkspaceInit + fn useWorkspaceSend + fn useSpawnTeamLead + +5 more + +## src\renderer\features\workspace\components +PrimarySessionPanel.tsx fn PrimarySessionPanel +TeamLeadPanel.tsx fn TeamLeadPanel +TeamLeadPanelList.tsx fn TeamLeadPanelList +WorkspacePage.tsx fn WorkspacePage + +## src\renderer\features\workspace\hooks +useSessionThinking.ts fn useSessionThinking + +## src\renderer\features\workspace\lib +chat-utils.ts + fn getStatusColor + fn contentBlocksToString + fn messagesToChatItems + +## src\renderer\features\workspaces\api +useWorkspaces.ts + fn useWorkspaces + fn useCreateWorkspace + fn useUpdateWorkspace + fn useDeleteWorkspace + +## src\renderer\features\workspaces\hooks +useWorkspaceEvents.ts fn useWorkspaceEvents + +## src\renderer\shared\components +# 11 single-export files: +AppUpdateNotification:AppUpdateNotification | AuthNotification:AuthNotification | ConfirmDialog:ConfirmDialog +EventBridge:EventBridge | HubConnectionIndicator:HubConnectionIndicator | HubNotification:HubNotification +HubStatus:HubStatus | IntegrationRequired:IntegrationRequired | MutationErrorToast:MutationErrorToast +RelativeTime:RelativeTime | WebhookNotification:WebhookNotification + +## src\renderer\shared\components\error-boundaries +FeatureErrorBoundary.tsx fn FeatureErrorBoundary +RootErrorBoundary.tsx fn RootErrorBoundary +RouteErrorBoundary.tsx fn RouteErrorBoundary +WidgetErrorBoundary.tsx fn WidgetErrorBoundary + +## src\renderer\shared\components\ui\composition +ActionBar.tsx fn ActionBar +DetailPanel.tsx fn DetailPanel +FilterBar.tsx fn FilterBar + +## src\renderer\shared\components\ui\data-display +DataGrid.tsx fn DataGrid +LiveIndicator.tsx fn LiveIndicator +StatusFlow.tsx fn StatusFlow + +## src\renderer\shared\components\ui +markdown-message.tsx fn MarkdownMessage + +## src\renderer\shared\hooks +useIpcQuery.ts + fn ipcInvoke + fn useIpcQuery + fn useIpcMutation +# 10 single-export files: +useAgentHostEvent:useAgentHostEvent | useClaudeAuth:useClaudeAuth | useDebounce:useDebounce +useHubEvents:useHubEvent | useIpcEvent:useIpcEvent | useLayoutSync:useLayoutSync +useLooseParams:useLooseParams | useMutationErrorToast:useMutationErrorToast | useOAuthStatus:useOAuthStatus +useThemeSync:useThemeSync + +## src\renderer\shared\lib +ipc.ts + class IpcError + fn ipc +utils.ts + fn cn + fn formatRelativeTime + fn formatDuration + fn truncate + +## src\shared\constants +routes.ts fn projectViewPath + +## src\shared\ipc +channel-builder.ts + fn domain + fn events + +## src\shared\lib +id.ts fn generateId + +## src\shared\types\hub +guards.ts + fn isWsTaskEvent + fn isWsDeviceEvent + fn isWsWorkspaceEvent + fn isWsProjectEvent + +2 more +transitions.ts + fn isValidStatusTransition + fn getValidNextStatuses diff --git a/.claude/progress/adc-codebase-state-2026-04-15.html b/.claude/progress/adc-codebase-state-2026-04-15.html new file mode 100644 index 00000000..b1d064c4 --- /dev/null +++ b/.claude/progress/adc-codebase-state-2026-04-15.html @@ -0,0 +1,657 @@ + + + + + +ADC Codebase State — 2026-04-15 + + + + +
+ +
+

ADC Codebase State

+
2026-04-15 · post full-gap-closure (T1-T14) · feature health dashboard
+
+ + +
+
+ 31 complete + 14 backend-only + 10 view-composite + 0 misc-bundled + 0 errors +
+
+ + + +
+ +

How to read this page

+

Each card shows one feature and its wiring across the four ADC layers: IPC (channels + contract), MAIN (schema + service + handlers), RENDERER (hooks + components), and ROUTE (registered in TanStack Router). A layer is green when implemented, dim when absent, and dim with line-through when intentionally not applicable.

+
+ Status meanings. complete = all four layers wired with canonical three-way name match (ipc / main / renderer). backend = main-process service called by other features (no renderer view by design). frontend = renderer composite view that aggregates live domains (no dedicated IPC). Zero misc-bundled and zero partial features as of 2026-04-15 — see changelog below. +
+ + +

What Changed — 2026-04-15 Full Gap Closure (T1-T14)

+

A 14-task gap-closure sprint landed on branch feature/full-gap-closure. Every previously-flagged debt item from the 2026-04-14 state is now resolved.

+
+ T1-T9 — IPC domain promotions. Moved notes, calendar, devices, hotkeys, mcp, screen, voice, webhook, workspaces out of src/shared/ipc/misc/ into dedicated folders. Each now has channels.ts, contract.ts, index.ts matching the Feature Slice layer convention. +
+
+ T10 — Deleted empty ipc/misc/. Commit 4a8eea7. No more bundle folder. The nine prior residents are all top-level domains. +
+
+ T11-T12 — Main feature extractions. spotify now has a dedicated src/main/features/spotify/ folder (service + handlers + index). github consolidated from a contract-only stub into src/main/features/github/ with proper service. Commits 689d762, fec358e. +
+
+ T13 — better-sqlite3 rebuild hooks. Added ABI rebuild hook scripts so Vitest no longer fails on node-version mismatch (e8d8264). Previously 10 unit test files failed; now they pass. +
+
+ T14 — Doc cleanup. Deleted 25 landed plans and stale feature docs (f2068bf). +
+
+ Also in this branch: 7d1acd1 removed the unused milestones and roadmap features entirely (no renderer view ever built, folded into progress). 0ca9708 dropped the milestone mention from the planner IPC barrel. +
+ + +

Core Domains — Fully Wired (31)

+

Features with a dedicated folder in src/shared/ipc/<domain>/, a matching main service in src/main/features/<domain>/, and a renderer feature in src/renderer/features/<domain>/.

+ +
+ +
+
agent-dashboardcomplete
+
IPCMAINRENDERERROUTE
+
/agents
+
Self-fetching session list. Fixed in c8f0f5d: page now queries sessions on mount.
+
+ +
+
assistantcomplete
+
IPCMAINRENDERERROUTE
+
/assistant
+
Persistent AI assistant with intent classification and command routing.
+
+ +
+
authcomplete
+
IPCMAINRENDERERROUTE
+
/login, /register
+
JWT session flow with Hub server.
+
+ +
+
briefingcomplete
+
IPCMAINRENDERERROUTE
+
/productivity?tab=briefing
+
Daily summary feed. Lives under Productivity tab.
+
+ +
+
buscomplete
+
IPCMAINRENDERERROUTE
+
Agent-host command/event bus. No route (infrastructure).
+
+ +
+
dashboardcomplete
+
IPCMAINRENDERERROUTE
+
/dashboard
+
Captures + widgets home. Store removed in wire-cleanup (orphan).
+
+ +
+
filescomplete
+
IPCMAINRENDERERROUTE
+
File watchers, tree, read/write. Consumed by other features (no route).
+
+ +
+
fitnesscomplete
+
IPCMAINRENDERERROUTE
+
/personal?tab=fitness
+
Withings sync, workouts, body composition.
+
+ +
+
gitcomplete
+
IPCMAINRENDERERROUTE
+
/projects/$id/git
+
Status, diff, commit, push. Per-project view.
+
+ +
+
hubcomplete
+
IPCMAINRENDERERROUTE
+
/hub-setup
+
Hub server pairing + sync client.
+
+ +
+
integrationscomplete
+
IPCMAINRENDERERROUTE
+
/integrations
+
Slack, Discord, Calendar, GitHub, Gmail. OAuth via oauth service.
+
+ +
+
plannercomplete
+
IPCMAINRENDERERROUTE
+
/productivity?tab=planner
+
Weekly planner view. Legacy /planner path redirects.
+
+ +
+
projectscomplete
+
IPCMAINRENDERERROUTE
+
/projects, /projects/$id/*
+
Project registry + per-project tabbed shell.
+
+ +
+
qa-recordercomplete
+
IPCMAINRENDERERROUTE
+
/projects/$id/qa-recorder
+
Per-project QA session recorder.
+
+ +
+
settingscomplete
+
IPCMAINRENDERERROUTE
+
/settings, /settings/themes
+
App + user settings. Store removed in wire-cleanup (orphan).
+
+ +
+
terminalscomplete
+
IPCMAINRENDERERROUTE
+
/projects/$id/terminals
+
PTY-backed terminals via node-pty.
+
+ +
+
visualizationcomplete
+
IPCMAINRENDERERROUTE
+
/projects/$id/visualization
+
Agent graph. Event-driven cache via EventBridge setQueryData.
+
+ +
+
workflowcomplete
+
IPCMAINRENDERERROUTE
+
/projects/$id/workflow
+
Workflow launcher. Depends on workflow-engine + workflow-templates.
+
+ +
+
workspacecomplete
+
IPCMAINRENDERERROUTE
+
Multi-device workspaces. Fixed edec904: no longer spawns sessions on cold start.
+
+ +
+
workspacescomplete
+
IPCMAINRENDERERROUTE
+
Promoted T9 (d3a05d1). Separate from workspace singular — this is the multi-workspace registry.
+
+ +
+
alertscomplete
+
IPCMAINRENDERERROUTE
+
/productivity?tab=alerts
+
Promoted from misc/ (92c21b1). Now at ipc/alerts/.
+
+ +
+
calendarcomplete
+
IPCMAINRENDERERROUTE
+
/productivity?tab=calendar
+
Promoted T2 (5ee742d).
+
+ +
+
changelogcomplete
+
IPCMAINRENDERERROUTE
+
/projects/$id/changelog (redirect)
+
Promoted from misc/ (7baff5a). AI changelog generator.
+
+ +
+
ideascomplete
+
IPCMAINRENDERERROUTE
+
/projects/$id/ideation (redirect)
+
Promoted from misc/ (6dc62e7).
+
+ +
+
insightscomplete
+
IPCMAINRENDERERROUTE
+
/projects/$id/insights (redirect)
+
Promoted from misc/ (7da220c).
+
+ +
+
mergecomplete
+
IPCMAINRENDERERROUTE
+
Promoted from misc/ (ffa5905). Consumed by workflow-pipeline view.
+
+ +
+
notescomplete
+
IPCMAINRENDERERROUTE
+
/productivity?tab=notes
+
Promoted T1 (e60613f).
+
+ +
+ + +

Backend-Only Services (14)

+

Main-process services with no renderer feature by design. They are either infrastructure (router/host), called by other features, or expose functionality via another feature's UI.

+ +
+ +
+
claudebackend
+
IPCMAINRENDERERROUTE
+
Claude CLI binary lookup + version. Consumed by assistant + bus.
+
+ +
+
data-managementbackend
+
IPCMAINRENDERERROUTE
+
DB backup / restore / export. Surfaced via settings page.
+
+ +
+
dockerbackend
+
IPCMAINRENDERERROUTE
+
Container introspection for hub deploys.
+
+ +
+
emailbackend
+
IPCMAINRENDERERROUTE
+
Gmail reader. Surfaced via integrations + briefing.
+
+ +
+
githubbackend
+
IPCMAINRENDERERROUTE
+
T12 (fec358e): consolidated into src/main/features/github/ with dedicated service + handlers. Surfaced via integrations UI.
+
+ +
+
mcpbackend
+
IPCMAINRENDERERROUTE
+
MCP client + registry + manager. Promoted T5 (c61834c) to ipc/mcp/.
+
+ +
+
devicesbackend
+
IPCMAINRENDERERROUTE
+
Device registry for multi-device workspaces. Promoted T3 (623f623); dedupe with hub/ in f40914f.
+
+ +
+
hotkeysbackend
+
IPCMAINRENDERERROUTE
+
Global hotkey registration. Promoted T4 (a977483).
+
+ +
+
screenbackend
+
IPCMAINRENDERERROUTE
+
Screen capture for QA recorder. Promoted T6 (defb086).
+
+ +
+
voicebackend
+
IPCMAINRENDERERROUTE
+
Voice capture for assistant dictation. Promoted T7 (7dcaefb).
+
+ +
+
webhookbackend
+
IPCMAINRENDERERROUTE
+
Inbound webhook listener for hub integrations. Promoted T8 (da8df65).
+
+ +
+
spotifybackend
+
IPCMAINRENDERERROUTE
+
T11 (689d762): extracted to dedicated src/main/features/spotify/. Widget at productivity?tab=spotify.
+
+ +
+
notificationsbackend
+
IPCMAINRENDERERROUTE
+
System tray notifications + watchers.
+
+ +
+
oauthbackend
+
IPCMAINRENDERERROUTE
+
OAuth2 flow + keytar token storage for integrations.
+
+ +
+
progressbackend
+
IPCMAINRENDERERROUTE
+
Task/wave progress tracker. Replaced old .adc/specs filesystem.
+
+ +
+
qabackend
+
IPCMAINRENDERERROUTE
+
QA trigger + gate. Invoked by workflow + coderabbit agents.
+
+ +
+ + +

Infrastructure IPC — No Service Expected

+

Shared IPC folders that are intentionally contract-only. Not counted as features.

+
+
+
appinfra
+
IPCMAINRENDERERROUTE
+
App lifecycle + window channels.
+
+
+
windowinfra
+
IPCMAINRENDERERROUTE
+
Window control channel bundle.
+
+
+
securityinfra
+
IPCMAINRENDERERROUTE
+
Extracted security handlers (keytar, clipboard). Created in wire-cleanup.
+
+
+
hub-tasksinfra
+
IPCMAINRENDERERROUTE
+
Hub-server task coordination (renamed from ipc/tasks in wire-cleanup).
+
+
+
workflow-engineinfra
+
IPCMAINRENDERERROUTE
+
Nested sub-namespace of workflow. 11+ callers.
+
+
+
workflow-templatesinfra
+
IPCMAINRENDERERROUTE
+
Nested sub-namespace of workflow. 20+ callers.
+
+
+ + +

Renderer View Composites (10)

+

Renderer features with no dedicated IPC or main service. Each one is a view that aggregates data from the wired domains above. They belong to the renderer tree only, are registered as routes, and call into sibling feature hooks.

+ +
+ +
+
agentsfrontend
+
IPCMAINRENDERERROUTE
+
/agents
+
Thin route wrapper around agent-dashboard feature (fixed b3cad08 — removed duplicate definition).
+
+ +
+
diff-viewerfrontend
+
IPCMAINRENDERERROUTE
+
Reusable diff component. Consumed by git + merge. No route.
+
+ +
+
my-workfrontend
+
IPCMAINRENDERERROUTE
+
/my-work
+
Personal task/session aggregator. Pulls from assistant + bus + projects.
+
+ +
+
onboardingfrontend
+
IPCMAINRENDERERROUTE
+
First-run walkthrough. Rendered conditionally by root layout.
+
+ +
+
personalfrontend
+
IPCMAINRENDERERROUTE
+
/personal
+
Composite page hosting fitness, notes, briefing tabs.
+
+ +
+
planningfrontend
+
IPCMAINRENDERERROUTE
+
/projects/$id/planning
+
Per-project planning view. Pulls from progress + workflow.
+
+ +
+
productivityfrontend
+
IPCMAINRENDERERROUTE
+
/productivity
+
Tabs: overview · calendar · spotify · briefing · notes · planner · alerts · comms.
+
+ +
+
roadmapfrontend
+
IPCMAINRENDERERROUTE
+
/projects/$id/roadmap (redirect)
+
Rolls up milestones + ideas + insights into a timeline.
+
+ +
+
tasksfrontend
+
IPCMAINRENDERERROUTE
+
/projects/$id/tasks
+
Per-project task table. Data from progress service.
+
+ +
+
toolsfrontend
+
IPCMAINRENDERERROUTE
+
/projects/$id/tools
+
Per-project tooling launcher (MCP servers, scripts).
+
+ +
+
workflow-pipelinefrontend
+
IPCMAINRENDERERROUTE
+
Visual pipeline component. Used by workflow + merge feature pages.
+
+ +
+ + +

Routes & Constants

+

All navigation paths flow through src/shared/constants/routes.ts. ESLint AST rules in eslint.config.js block literal route strings in navigate({ to }) and <Link to>.

+ + + + + + + + + + + + + + + + + + + +
ConstantPathKind
ROUTES.INDEX/top-level
ROUTES.LOGIN / REGISTER/login, /registerauth
ROUTES.HUB_SETUP/hub-setuptop-level
ROUTES.ASSISTANT/assistanttop-level
ROUTES.DASHBOARD/dashboardtop-level
ROUTES.MY_WORK/my-workcomposite
ROUTES.PERSONAL/personalcomposite
ROUTES.PRODUCTIVITY/productivitycomposite
ROUTES.INTEGRATIONS/integrationstop-level
ROUTES.PROJECTS/projectstop-level
ROUTES.SETTINGS / THEMES/settings, /settings/themestop-level
ROUTES.AGENTS/agentstop-level
ROUTE_PATTERNS.PROJECT_*/projects/$id/{tasks,terminals,agents,planning,git,github,tools,workflow,visualization,qa-recorder}project tabs
ROUTES.ALERTS / BRIEFING / FITNESS / NOTES / PLANNERlegacy top-levelredirect only
+ + +

Verification

+ + + + + + + + + + + +
CheckCommandResult
TypeScriptnpx tsc --noEmitPASS (0 errors)
ipc/misc/ folderls src/shared/ipc/miscdeleted (T10)
git grep "from './misc'"grep ts files0 references
Hardcoded routesESLint no-restricted-syntax AST rules0 violations
Naming mismatches (ipc/main/renderer)manual cross-check0 mismatches
better-sqlite3 rebuildrebuild hookauto-rebuilds via T13 hook
Lint / Buildnpm run lint && npm run buildnot re-run in T15 (docs-only change)
+ + +

Known Gaps & Debt

+

Zero outstanding items from the 2026-04-14 state document. All previously-listed gaps closed by T1-T14.

+ + +
+
+ + + + + diff --git a/.claude/scripts/README.md b/.claude/scripts/README.md new file mode 100644 index 00000000..5a71a33f --- /dev/null +++ b/.claude/scripts/README.md @@ -0,0 +1,38 @@ +# .claude/scripts/ + +Claude-generated automation scripts. Each script runs on specific file patterns +defined in `.claude/automate.json` → `onEdit[].scripts`. + +## How to add a new script + +1. Ask Claude to create the script here as `{name}.mjs` +2. Add an entry to `.claude/automate.json` `onEdit[]`: + ```json + { + "glob": "src/renderer/features/*/index.ts", + "scripts": ["your-script-name"], + "description": "What this checks" + } + ``` +3. Script receives `FILE_PATH`, `REL_PATH`, `PROJECT_DIR` as environment variables +4. Script must exit 0 (warnings only — never block edits) +5. Output is injected into Claude's context as a warning + +## Scripts in this directory + +| Script | Fires on | What it checks | +|--------|----------|----------------| +| `check-barrel-exports.mjs` | `src/renderer/features/*/index.ts` | Missing exports for components and hooks | + +## Script contract + +```javascript +// Receives via env: +const filePath = process.env.FILE_PATH; // absolute path +const relPath = process.env.REL_PATH; // project-relative path +const root = process.env.PROJECT_DIR; // project root + +// Output warnings to stdout (injected into Claude context) +// Always exit 0 — these are warnings, not blockers +process.exit(0); +``` diff --git a/.claude/scripts/check-barrel-exports.mjs b/.claude/scripts/check-barrel-exports.mjs new file mode 100644 index 00000000..5431da2c --- /dev/null +++ b/.claude/scripts/check-barrel-exports.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * check-barrel-exports — validates Feature Slice Design index.ts barrel exports + * + * Called by on-edit-dispatch.mjs when src/renderer/features/*/index.ts is edited. + * Receives: FILE_PATH (env) — the index.ts that was just edited. + * + * Checks: + * 1. All component .tsx files in the feature have a corresponding export + * 2. All api/use*.ts hooks have a corresponding export + * 3. No export points to a non-existent file + * + * Outputs warnings to stdout (injected into Claude context). + * Always exits 0 — warnings only, never blocks. + * + * To add this to more file patterns, update .claude/automate.json onEdit rules. + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { resolve, dirname, basename, extname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '../..'); + +const filePath = process.env.FILE_PATH ?? process.argv[2] ?? ''; +if (!filePath) process.exit(0); + +const absPath = existsSync(filePath) ? filePath : resolve(ROOT, filePath); +if (!existsSync(absPath)) process.exit(0); + +const featureDir = dirname(absPath); +const indexContent = readFileSync(absPath, 'utf-8'); + +const warnings = []; + +// ─── Check: exports for component files ─────────────────────────────────────── +const componentsDir = resolve(featureDir, 'components'); +if (existsSync(componentsDir)) { + const componentFiles = readdirSync(componentsDir, { withFileTypes: true }) + .filter((f) => f.isFile() && ['.tsx', '.ts'].includes(extname(f.name)) && !f.name.startsWith('.')) + .map((f) => f.name.replace(/\.(tsx|ts)$/, '')); + + for (const name of componentFiles) { + if (name === 'index') continue; + // Check if it's re-exported from the barrel + const isExported = + indexContent.includes(`'./components/${name}'`) || + indexContent.includes(`"./components/${name}"`) || + indexContent.includes(`'./components'`) || + indexContent.includes(`"./components"`); + + if (!isExported) { + warnings.push(`MISSING EXPORT: ${name} (components/${name}.tsx) not found in barrel`); + } + } +} + +// ─── Check: exports for api/use*.ts hooks ───────────────────────────────────── +const apiDir = resolve(featureDir, 'api'); +if (existsSync(apiDir)) { + const hookFiles = readdirSync(apiDir, { withFileTypes: true }) + .filter((f) => f.isFile() && f.name.startsWith('use') && f.name.endsWith('.ts')) + .map((f) => f.name.replace(/\.ts$/, '')); + + for (const name of hookFiles) { + const isExported = + indexContent.includes(`'./api/${name}'`) || + indexContent.includes(`"./api/${name}"`) || + indexContent.includes(`'./api'`) || + indexContent.includes(`"./api"`); + + if (!isExported) { + warnings.push(`MISSING EXPORT: ${name} (api/${name}.ts) not found in barrel`); + } + } +} + +// ─── Check: no broken export paths ──────────────────────────────────────────── +const exportMatches = indexContent.matchAll(/from ['"](\.[^'"]+)['"]/g); +for (const match of exportMatches) { + const importPath = match[1]; + const candidates = [ + resolve(featureDir, importPath), + resolve(featureDir, importPath + '.ts'), + resolve(featureDir, importPath + '.tsx'), + resolve(featureDir, importPath, 'index.ts'), + resolve(featureDir, importPath, 'index.tsx'), + ]; + if (!candidates.some(existsSync)) { + warnings.push(`BROKEN EXPORT: '${importPath}' does not resolve to an existing file`); + } +} + +// ─── Output ─────────────────────────────────────────────────────────────────── +if (warnings.length) { + const featureName = basename(featureDir); + process.stdout.write(`[check-barrel-exports] ${featureName}/index.ts has ${warnings.length} issue(s):\n`); + warnings.forEach((w) => process.stdout.write(` • ${w}\n`)); +} else { + // Silent on clean — no noise in Claude context +} + +process.exit(0); diff --git a/.claude/skills/codebase-nav/SKILL.md b/.claude/skills/codebase-nav/SKILL.md index 557f2f82..c2fabe2f 100644 --- a/.claude/skills/codebase-nav/SKILL.md +++ b/.claude/skills/codebase-nav/SKILL.md @@ -96,7 +96,7 @@ node scripts/scaffold-features.mjs --fix # create missing files ## Common Domains -agent-dashboard, alerts, assistant, auth, briefing, bus, calendar, changelog, claude, dashboard, data-management, device, docker, email, file-tree, fitness, git, github, health, hotkeys, hub, ideas, insights, mcp, merge, milestones, notes, notifications, oauth, planner, progress, project, qa, screen, security, settings, spotify, tasks, terminal, time-parser, tracker, visualization, voice, webhook-settings, window, workflow, workflow-engine, workflow-templates, workspace +agent-dashboard, alerts, assistant, auth, briefing, bus, calendar, changelog, claude, dashboard, data-management, device, docker, email, file-tree, fitness, git, github, health, hotkeys, hub, ideas, insights, mcp, merge, milestones, notes, notifications, oauth, planner, progress, project, qa, runners, screen, security, settings, spotify, tasks, terminal, time-parser, tracker, visualization, voice, webhook-settings, window, workflow, workflow-engine, workflow-templates, workspace ## Path Aliases diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..49312eaf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Shell scripts must be LF on all platforms — CRLF breaks bash's shebang +# parsing on macOS/Linux. +*.sh text eol=lf + +# Same for files run by bash (no extension, shebang-only). If added later, +# include them by path. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..91553966 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,131 @@ +name: Release + +on: + push: + branches: [production] + workflow_dispatch: + +permissions: + contents: write + +jobs: + read-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.pkg.outputs.version }} + tag: ${{ steps.pkg.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: pkg + shell: bash + run: | + VERSION=$(node -p "require('./package.json').version") + TAG="v$VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Building release for $TAG" + # Reject the release if this version was already published. + # Forces the dev to bump package.json version before merging to production. + if git tag --list | grep -qx "$TAG"; then + echo "::error::Tag $TAG already exists. Bump package.json version before pushing to production." + exit 1 + fi + if gh release view "$TAG" --json id >/dev/null 2>&1; then + echo "::error::Release $TAG already exists on GitHub. Bump package.json version." + exit 1 + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build: + needs: read-version + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + build-flag: --mac + - os: windows-latest + build-flag: --win + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Rebuild native modules for Electron + run: npm run rebuild:sqlite:electron + + - name: Typecheck + run: npm run typecheck + + - name: Build application + run: npm run build + + # Only populate signing env vars when the corresponding secrets are set. + # electron-builder treats an empty CSC_LINK as a path and errors with + # "not a file" if the variable is present but blank — so we omit it + # entirely for ad-hoc signed builds (mac.identity: "-" in package.json + # handles the signature without a cert). + - name: Configure signing env + shell: bash + env: + MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + APPLE_ID_SECRET: ${{ secrets.APPLE_ID }} + APPLE_APP_PW_SECRET: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_SECRET: ${{ secrets.APPLE_TEAM_ID }} + WIN_CSC_LINK_SECRET: ${{ secrets.WIN_CSC_LINK }} + WIN_CSC_KEY_PW_SECRET: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + run: | + if [ -n "$MAC_CSC_LINK" ]; then + echo "CSC_LINK=$MAC_CSC_LINK" >> "$GITHUB_ENV" + echo "CSC_KEY_PASSWORD=$MAC_CSC_KEY_PASSWORD" >> "$GITHUB_ENV" + fi + if [ -n "$APPLE_ID_SECRET" ]; then + echo "APPLE_ID=$APPLE_ID_SECRET" >> "$GITHUB_ENV" + echo "APPLE_APP_SPECIFIC_PASSWORD=$APPLE_APP_PW_SECRET" >> "$GITHUB_ENV" + echo "APPLE_TEAM_ID=$APPLE_TEAM_SECRET" >> "$GITHUB_ENV" + fi + if [ -n "$WIN_CSC_LINK_SECRET" ]; then + echo "WIN_CSC_LINK=$WIN_CSC_LINK_SECRET" >> "$GITHUB_ENV" + echo "WIN_CSC_KEY_PASSWORD=$WIN_CSC_KEY_PW_SECRET" >> "$GITHUB_ENV" + fi + + - name: Package and publish (${{ matrix.os }}) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Disable keychain discovery so ad-hoc signing (mac.identity: "-" in + # package.json) is used when no real CSC_LINK was exported above. + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + run: npx electron-builder ${{ matrix.build-flag }} --publish always + + finalize-release: + needs: [read-version, build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Attach macOS install script and promote release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ needs.read-version.outputs.tag }}" + # Attach the curl-based macOS installer so users can bypass the + # Gatekeeper "damaged" dialog without an Apple Developer subscription. + gh release upload "$TAG" scripts/install-mac.sh --clobber + # electron-builder creates a draft release — publish it now that all platforms succeeded + if gh release view "$TAG" --json isDraft --jq '.isDraft' | grep -q true; then + gh release edit "$TAG" --draft=false + echo "Published release $TAG" + else + echo "Release $TAG already published" + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08ba953f..dbceaf92 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,23 +58,3 @@ jobs: - run: npm rebuild better-sqlite3 - run: npm run test:integration - - hub-tests: - runs-on: ubuntu-latest - defaults: - run: - working-directory: hub - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: hub/package-lock.json - - - run: npm ci - - - run: npm run typecheck - - - run: npm run build diff --git a/.gitignore b/.gitignore index e17881ec..964e76a4 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,13 @@ docs/superpowers/ # One-off scripts scripts/cdp-screenshot.mjs + +# Ephemeral lint/typecheck output +tsc-out.txt +eslint-out.txt +entities.json + +# Local session memory (not committed) +.remember/ +playwright-report/ +test-results/ diff --git a/CLAUDE.md b/CLAUDE.md index 1ab7cb07..139dc7e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,8 @@ Feature Slice Design with React Query for server state, Zustand for UI-only stat - **Mutations** use `onSuccess` invalidation (`queryClient.invalidateQueries`) — NOT optimistic updates (IPC is <1ms) - **Event-driven cache updates** use `setQueryData` via EventBridge `append` handlers — this is distinct from mutation invalidation. When IPC events arrive (e.g., `BUS_EVENTS.SESSION.*`), the EventBridge patches the cache directly without a re-fetch. - `ProgressService` replaced old `.adc/specs/` filesystem task system +- `RunnersService` manages long-running project processes (dev servers, workers). Scoped by `ScopeRef` (project | worktree). Events stream over `event:runners.instance.*` — see `src/shared/ipc/runners/`. +- `TestSuiteService` — browser-based test recorder and Playwright runner. Records user interactions via WebContentsView preload, generates `.spec.ts` files with smart waits and Playwright-preferred locators (`getByTestId` > `getByLabel` > `getByRole` > `getByText` > CSS fallback), runs tests via `npx playwright test --reporter=json,html`, tracks per-step pass/fail results in SQLite. Supports scheduling, visual baselines (pixel-diff), data-driven testing (CSV/JSON `{{key}}` substitution), shared step groups, CI export (GitHub Actions YAML), batch test execution (run by selection or tag), and auth state persistence (`storageState`). Config: per-project `TestSuiteConfig` with `navigationTimeout`, `actionTimeout`, viewport, screenshot mode, `browsers` (chromium/firefox/webkit multi-select), `workers` (1-16 parallel), `environments` (named URL profiles with runtime switching via `BASE_URL` env var), `retries` (0-5, configurable), and `storageStatePath` (Playwright storageState for auth). Scripts have `tags` (string array, stored as JSON in SQLite) for categorization and filtering. Library panel supports tag-based intersection filtering and "Run Tagged" batch execution. UI: single Zustand store (`test-suite-store.ts`), 7-tab page. Analytics tab has test health score (letter grade A-F with pass rate/stability/speed breakdown). Library sparklines show flaky indicators (yellow). Save dialog auto-generates assertion suggestions from recorded context. Results tab has environment selector, HTML report viewer (`shell.openPath`), run completion toasts, full-output log dialog, Create Task + Start Workflow integration with progress pipeline. ## Feature Slice Design @@ -85,6 +87,16 @@ Use `codebase-nav` skill to locate any domain across layers. @ui -> src/renderer/shared/components/ui ``` +## Channels + +Three data-isolated channels run side-by-side. See `docs/architecture/channels.md`. + +- `npm run dev` → `ADC-Dev`, `%APPDATA%/ADC-Dev/` +- `npm run build:local` → `ADC-Local`, `%APPDATA%/ADC-Local/` (local prod smoke test) +- Installed release → `ADC`, `%APPDATA%/ADC/` + +Channel is resolved by `src/main/lib/channel.ts::resolveChannel`. Bake at build time via `ADC_CHANNEL= electron-vite build`. + ## Codebase Reference (read these FIRST before exploring files) Pre-built index files in `.claude/codex/` — auto-regenerated on every commit via lefthook: @@ -93,8 +105,7 @@ Pre-built index files in `.claude/codex/` — auto-regenerated on every commit v Codebase state document (manual doc, updated via doc-sync hooks): -- **`.claude/progress/adc-codebase-state-2026-04-13.html`** — wire status for all 66 domains, critical issues, migration history, agent host protocol. Open in browser or search with Grep. -- **`.claude/progress/sprint-6-7-finish-line/codebase-issues-breakdown.md`** — 40 issues categorized by severity (core broken, stubs, dead code, deprecated, overkill, mismatches, cleanup) with recommended sprint order. +- **`.claude/progress/adc-codebase-state-2026-04-15.html`** — canonical domain inventory after the 2026-04-15 wire-cleanup-naming pass. Zero naming mismatches, zero hardcoded routes, full verification results. Open in browser. Automation config: @@ -156,8 +167,8 @@ When context compresses (auto or via `/compact`), always preserve: ## Current Sprint Plan -Reference: `docs/superpowers/plans/2026-04-11-gap-closure-multi-sprint.md` -44 tasks across 7 sprints closing feature gaps identified by full-system data flow audit. +Reference: `docs/superpowers/plans/2026-04-15-full-gap-closure.md` +Closes every error and debt item from the 2026-04-15 codebase-state dashboard: promotes remaining `ipc/misc/` features, eliminates naming mismatches, extracts spotify/github main services, adds Vitest better-sqlite3 ABI rebuild hooks, and cleans up landed plans/docs. ## Communication Standards diff --git a/README.md b/README.md index 646395d2..49577493 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ ADC is a desktop application for orchestrating teams of autonomous coding agents | **Renderer** | React Components, TanStack Router, React Query, Zustand | React 19, TypeScript | | **Preload Bridge** | Typed IPC Context, window.api | Electron contextBridge | | **Main Process** | IPC Router, 29 Services, OAuth, MCP Servers, PTY | Node.js, Electron 39 | -| **External** | Hub Server, GitHub/Spotify/Calendar APIs, Anthropic SDK | REST, WebSocket, OAuth2 | -| **Storage** | SQLite (Hub), JSON Files (Local), Task Specs | File system, SQLite | +| **External** | GitHub/Spotify/Calendar APIs, Anthropic SDK | REST, WebSocket, OAuth2 | +| **Storage** | SQLite, JSON Files, Task Specs | File system, SQLite | **Data Flow**: React Query hooks call `ipc()` → Preload bridge → IPC Router → Services → External APIs/Storage @@ -57,7 +57,7 @@ ADC is a desktop application for orchestrating teams of autonomous coding agents | **Agent Team Management** | Spawn, pause, resume, and terminate multiple Claude CLI agents simultaneously | | **Workflow-Driven Task Table** | Sortable, filterable task table with customizable agent workflows and `/implement-feature` skill integration | | **Agent Queue** | Queue tasks for sequential agent execution with dependency management | -| **Progress Watching** | Real-time sync of `docs/progress/*.md` files to Hub for crash-safe tracking | +| **Progress Watching** | Real-time sync of progress events between paired devices via the P2P transport | | **Task Launcher** | Launch Claude CLI sessions directly from task rows with project context | ### Automated QA Loops & Testing @@ -75,7 +75,7 @@ ADC is a desktop application for orchestrating teams of autonomous coding agents |---------|-------------| | **Multi-Project Support** | Manage multiple codebases with instant project switching | | **Workspaces** | Group related projects with shared settings, max concurrency limits, and device assignment | -| **Device Sync** | Multi-device support via Hub — workspaces can be hosted on specific devices | +| **Device Sync** | Multi-device sync via direct peer-to-peer connections (TLS-pinned WebSocket); workspaces can be hosted on specific devices | | **Git Worktrees** | Parallel development with visual worktree management | | **Branch Merging** | Visual conflict resolution and merge preview | @@ -151,12 +151,44 @@ graph LR | Styling | Tailwind CSS 4, Radix UI | | Validation | Zod 4 | | Terminal | xterm.js 6, @lydell/node-pty | -| Backend | Fastify 5, SQLite (Hub) | +| Sync | P2P TLS WebSocket, Ed25519 identity, mDNS discovery | | Testing | Vitest, Playwright | --- -## Quick Start +## Install (Pre-built) + +Download the latest installer from [GitHub Releases](https://github.com/ParkerM2/Agentic-Desktop-Command/releases/latest). + +ADC is currently distributed unsigned. The first launch shows a security warning on both platforms — this is expected. Once bypassed, the OS remembers the choice for future launches. + +### Windows + +1. Download `ADC-Setup--x64.exe` +2. Double-click to run +3. SmartScreen will show **"Windows protected your PC"** → click **More info** → **Run anyway** +4. Walk through the installer wizard +5. Auto-updates work normally after install + +### macOS + +Run this single command in Terminal — it downloads the latest release, installs it to `/Applications`, and strips the Gatekeeper quarantine attribute so macOS doesn't falsely claim the app is "damaged": + +```sh +curl -sSL https://github.com/ParkerM2/Agentic-Desktop-Command/releases/latest/download/install-mac.sh | bash +``` + +The script auto-detects arm64 vs x64 and launches ADC when done. After this, double-clicking the app works normally. + +> **Why the script?** Browser downloads set `com.apple.quarantine` on the DMG, which on recent macOS (Sonoma 14.5+ / Sequoia) triggers a blanket "damaged" Gatekeeper rejection for any app that isn't Apple-notarized. Notarization requires a paid Apple Developer subscription. Using `curl` bypasses browser quarantine, and the final `xattr -cr` scrubs anything still attached — no paid subscription needed. +> +> **Manual install (if you prefer):** Download the DMG from [Releases](https://github.com/ParkerM2/Agentic-Desktop-Command/releases/latest), drag ADC.app to /Applications, then run `xattr -cr /Applications/ADC.app` in Terminal. + +Updates are **manual** on macOS — when a new version is available, ADC shows a notification with a Download button that opens the releases page. Run the install script again to update. Your data persists in `~/Library/Application Support/ADC/`. + +--- + +## Quick Start (Development) ```bash git clone https://github.com/ParkerM2/Agentic-Desktop-Command.git @@ -181,31 +213,21 @@ npm run dev src/ ├── main/ # Electron main process (29 services) ├── preload/ # IPC context bridge -├── renderer/ # React app (25 features) +├── renderer/ # React app (31 features) └── shared/ # Types + IPC contract (single source of truth) -hub/ # Optional sync server (Fastify + SQLite) -.claude/agents/ # 27 specialist agent definitions +.claude/agents/ # 30 specialist agent definitions ``` --- -## Hub Server (Optional) - -Multi-device sync and real-time collaboration: +## Multi-Device Sync -```mermaid -graph LR - Desktop1["Desktop 1"] <--> Hub["Hub Server"] - Desktop2["Desktop 2"] <--> Hub - Hub --> DB["SQLite"] - Hub --> WS["WebSocket"] -``` +Devices pair via PIN ritual (Settings → Peers) and sync over a direct +TLS-pinned WebSocket connection. No central server is involved — each +device discovers paired peers via mDNS and connects directly. -```bash -cd hub && npm install && npm run dev -# → http://localhost:3200 -``` +See `docs/peers/` for the protocol specification. --- diff --git a/ai-docs/LINTING.md b/ai-docs/LINTING.md new file mode 100644 index 00000000..e3c40f59 --- /dev/null +++ b/ai-docs/LINTING.md @@ -0,0 +1,139 @@ +# Linting Rules Reference + +> Rules enforced by `eslint.config.js`. Read this before writing any code. Violations are not auto-fixable and will cause QA FAIL. + +--- + +## Import Ordering (`import-x/order`) — NOT auto-fixable + +Imports must follow this exact group order, with a blank line between groups: + +``` +1. builtin — node:fs, node:path, etc. +2. external — npm packages (react first, then electron, then others) +3. internal — path alias imports: @shared/**, @main/**, @renderer/**, @features/**, @ui/** +4. parent — relative ../ +5. sibling — relative ./ +6. index — relative ./index +7. type — import type { ... } from '...' +``` + +Within each group, imports are alphabetized ascending (case-insensitive). + +### Internal group order + +`@shared/**` and `@main/**` come before `@renderer/**`, `@features/**`, `@ui/**`. + +Within `@shared/**`, sort alphabetically: +- `@shared/ipc/git/...` before `@shared/types` (`i` < `t`) +- `@shared/ipc/github` before `@shared/ipc/github/channels` (parent before subpath) + +### Type imports + +`import type { ... }` goes in the **`type` group (last)**. Do NOT mix type imports with regular imports from the same package unless they're on separate lines in the right group. + +**Correct:** +```ts +import { execFile } from 'node:child_process'; + +import { app } from 'electron'; + +import { GIT } from '@shared/ipc/git/channels'; +import type { GitCommit } from '@shared/ipc/git/schemas'; +import type { SomeType } from '@shared/types'; +``` + +**Wrong (type import mixed with regular imports in wrong position):** +```ts +import type { GitCommit } from '@shared/ipc/git/schemas'; // ✗ wrong group position +import { GIT } from '@shared/ipc/git/channels'; +import type { SomeType } from '@shared/types'; // ✗ wrong group position +``` + +### Quick rule: where do my imports go? + +| Import | Group | +|--------|-------| +| `import fs from 'node:fs'` | builtin | +| `import { app } from 'electron'` | external | +| `import { GIT } from '@shared/ipc/git/channels'` | internal | +| `import type { GitCommit } from '@shared/ipc/git/schemas'` | type (last) | +| `import { gitService } from '../git-service'` | parent | +| `import { helper } from './helper'` | sibling | + +--- + +## Type Import Rules (`@typescript-eslint/consistent-type-imports`) + +Always use `import type` for type-only imports. Use separate `import type` statements (not inline `type` specifiers). + +```ts +// Correct +import type { GitCommit } from '@shared/ipc/git/schemas'; +import { GIT } from '@shared/ipc/git/channels'; + +// Wrong +import { type GitCommit, GIT } from '@shared/ipc/git'; +``` + +--- + +## No Unnecessary Null Coalescing (`@typescript-eslint/prefer-nullish-coalescing` / strict checks) + +Do not add `?? fallback` when the value is already guaranteed non-null. + +```ts +// Wrong — String.split() always returns string[], never null/undefined +const [hash, shortHash] = line.split('\x1f'); +return { hash: hash ?? '', shortHash: shortHash ?? '' }; // ✗ + +// Correct +const [hash, shortHash] = line.split('\x1f'); +return { hash, shortHash }; // ✓ +``` + +--- + +## No `any` Types + +`@typescript-eslint/no-explicit-any` is an error. Use `unknown` + type narrowing, or define a proper interface. + +--- + +## Unused Variables + +`@typescript-eslint/no-unused-vars` is an error. Prefix intentionally unused params with `_`: + +```ts +function handler(_event: IpcMainEvent, args: unknown) { ... } +``` + +--- + +## Non-null Assertions + +`@typescript-eslint/no-non-null-assertion` is an error. Never use `!`. Use optional chaining or explicit checks. + +--- + +## Type Definitions + +Use `interface` not `type` for object shapes (`@typescript-eslint/consistent-type-definitions`). + +```ts +interface RawFile { filename: string; status: string; } // ✓ +type RawFile = { filename: string; status: string; }; // ✗ +``` + +--- + +## Verify Before Committing + +Run against only your changed files: + +```bash +npx eslint --no-warn-ignored +npx tsc --noEmit +``` + +Both must exit 0 before reporting done. diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index eb704f3d..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,40 +0,0 @@ -services: - hub: - build: - context: ./hub - dockerfile: Dockerfile - container_name: claude-ui-hub - restart: unless-stopped - volumes: - - hub-data:/app/data - - ./certs:/certs:ro - environment: - - NODE_ENV=production - - DB_PATH=/app/data/claude-ui.db - - PORT=3200 - networks: - - claude-ui-net - - nginx: - image: nginx:alpine - container_name: claude-ui-proxy - restart: unless-stopped - ports: - - "3443:443" - - "3080:80" - volumes: - - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - - ./nginx/conf.d:/etc/nginx/conf.d:ro - - ./certs:/certs:ro - depends_on: - - hub - networks: - - claude-ui-net - -volumes: - hub-data: - driver: local - -networks: - claude-ui-net: - driver: bridge diff --git a/docs/INDEX.md b/docs/INDEX.md index 7c337fa1..48a47b4b 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -39,16 +39,6 @@ - - - - - - - - - - diff --git a/docs/QA-Feature-Research.md b/docs/QA-Feature-Research.md index 72232efe..8618d41b 100644 --- a/docs/QA-Feature-Research.md +++ b/docs/QA-Feature-Research.md @@ -1,5 +1,9 @@ # QA Recorder — Feature Research & Integration Plan +> **Superseded:** The domain was renamed from `qa-recorder` to `test-suite` during implementation. +> All IPC channels, services, and routes now use the `test-suite` naming convention. +> This document is retained as historical research context only. + > **Status:** Research complete. Ready for implementation. > **Target:** New project-scoped tab in the Electron app for recording, saving, and running Playwright browser tests against localhost. diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 61234f2b..2471ca02 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -55,7 +55,7 @@ │ Auto-restart with exponential backoff (5 retries / 60s) │ │ │ │ ┌──────────────────────────────────────────────────────────────┐│ -│ │ IPC Contract: src/shared/ipc/ (28 domain folders) ││ +│ │ IPC Contract: src/shared/ipc/ (37 domain folders) ││ │ │ Each folder: contract.ts + schemas.ts + index.ts ││ │ │ Root barrel merges all into ipcInvokeContract/ipcEventContract││ │ └──────────────────────────────────────────────────────────────┘│ @@ -83,7 +83,7 @@ ## Domain-Based IPC Structure -The IPC contract was refactored from a single ~2600-line `ipc-contract.ts` into 28 domain-specific folders under `src/shared/ipc/`. Each domain folder contains: +The IPC contract was refactored from a single ~2600-line `ipc-contract.ts` into 37 domain-specific folders under `src/shared/ipc/`. Each domain folder contains: - `schemas.ts` — Zod schemas for the domain - `contract.ts` — Invoke and event contract entries using those schemas diff --git a/docs/architecture/DATA-FLOW.md b/docs/architecture/DATA-FLOW.md index d673b015..bfd1ba28 100644 --- a/docs/architecture/DATA-FLOW.md +++ b/docs/architecture/DATA-FLOW.md @@ -57,7 +57,7 @@ Component re-renders with new data |------|------|---------| | Domain contracts | `src/shared/ipc//contract.ts` | Domain-specific channel definitions with Zod schemas | | Domain schemas | `src/shared/ipc//schemas.ts` | Zod schemas for the domain | -| Root barrel | `src/shared/ipc/index.ts` | Merges all 28 domain contracts into unified objects | +| Root barrel | `src/shared/ipc/index.ts` | Merges all 37 domain contracts into unified objects | | Compat re-export | `src/shared/ipc-contract.ts` | Thin re-export from `src/shared/ipc/` (backward compat) | | Renderer helper | `src/renderer/shared/lib/ipc.ts` | Typed wrapper: `ipc(channel, input) -> Promise` | | Preload bridge | `src/preload/index.ts` | Context bridge: `api.invoke()`, `api.on()` | diff --git a/docs/architecture/TEST-SUITE.md b/docs/architecture/TEST-SUITE.md new file mode 100644 index 00000000..95c9f145 --- /dev/null +++ b/docs/architecture/TEST-SUITE.md @@ -0,0 +1,276 @@ +# Test Suite Feature — Technical Reference + +## Overview + +Record browser interactions, save them as Playwright test scripts, run them, and view results. The feature spans all three processes (main, preload, renderer). + +## DB Tables + +### `test_suite_scripts` (migration 0009, renamed from `qa_scripts` in 0016) + +| Column | Type | Source | +|--------|------|--------| +| id | text PK | `nanoid()` in `script-store.ts:save()` | +| project_id | text NOT NULL | from IPC input `SAVE.SCRIPT` | +| name | text NOT NULL | from IPC input | +| description | text | from IPC input (optional) | +| base_url | text NOT NULL | set to `config.targetUrl` via `index.ts:saveScript()` | +| steps | text NOT NULL | JSON-stringified step array, from recorded steps | +| file_path | text | absolute path from `writeSpecFile()` in `recorder-handlers.ts:165` | +| target_url | text NOT NULL | same as base_url (added in 0022) | +| step_count | integer NOT NULL | `steps.length` computed in `script-store.ts:save()` | +| last_status | text | **NEVER UPDATED** — always null. Dead column. | +| last_run_at | text | **NEVER UPDATED** — always null. Dead column. | +| created_at | text NOT NULL | ISO timestamp | +| updated_at | text NOT NULL | ISO timestamp | + +### `test_suite_runs` (migration 0009, renamed from `qa_runs` in 0016) + +| Column | Type | Written By | When | +|--------|------|------------|------| +| id | text PK | `runner.ts:run()` | on run start | +| script_id | text NOT NULL | `runner.ts:run()` | on run start | +| project_id | text NOT NULL | `runner.ts:run()` | on run start | +| status | text NOT NULL | `runner.ts:run()` sets `'running'`, `close` handler sets `'passed'`/`'failed'`, `cancel()` sets `'cancelled'` | start → completion | +| triggered_by | text NOT NULL | from `runScript()` input: `'manual'`, `'scheduled'`, `'ci'`, or `'auto-trigger'` | on run start | +| report | text | **NEVER WRITTEN** — always null | — | +| duration_ms | integer NOT NULL | `runner.ts` close/error handler: `endMs - startMs` | on completion | +| steps_passed | integer NOT NULL | **NEVER UPDATED** — always 0 | — | +| steps_failed | integer NOT NULL | **NEVER UPDATED** — always 0 | — | +| output | text | `runner.ts` close/error handler: `JSON.stringify({ outputLines, screenshots, error? })` | on completion | +| started_at | text NOT NULL | `runner.ts:run()` | on run start | +| completed_at | text | `runner.ts` close/error/cancel handler | on completion | +| task_id | text | `attachRunToTask` handler in `recorder-handlers.ts` | manual attachment | +| session_id | text | **NEVER WRITTEN** — always null | — | + +### `test_suite_screenshots` (migration 0016) + +Populated by `screenshot-capture.ts:index()` after run completion if `screenshotMode !== 'manual'`. + +### `test_suite_schedules` (schema-schedules.ts) + +Managed by `scheduler.ts`. Polls every 30s, fires `runScript({ triggeredBy: 'scheduled' })`. + +### `test_suite_shared_steps` (schema-shared-steps.ts) + +Managed by `shared-steps-store.ts`. Reusable step groups. + +### `test_suite_diffs` (schema-baselines.ts) + +Screenshot baseline comparisons via `diff-engine.ts`. + +### Config storage + +Configs are stored in `settings_kv` table with `category='test-suite'`, key=`${projectId}:${configId}`. Managed by `config-store.ts`. **Not** in a dedicated table. + +--- + +## Data Flows + +### Recording Flow + +``` +User clicks Record in RecordingPanel + → clearSteps(), setRecordingActive(true) [Zustand store] + → BrowserViewPanel renders with recorder preload + → User interacts with the app in the webview + → test-suite-recorder.ts (preload) captures click/fill/select/press/navigate + → ipcRenderer.sendToHost('adc.test-suite.step', step) + → BrowserViewPanel listens for 'ipc-message' event on + → addStep() writes to Zustand store + → StepList component renders live steps + +User clicks Stop + → setRecordingActive(false) + → SaveRecordingDialog opens + +User clicks Save + → useSaveScript mutation → IPC: TEST_SUITE.SAVE.SCRIPT + → recorder-handlers.ts:154: + 1. Gets projectPath from projectService + 2. Gets active config from configStore + 3. writeSpecFile() → writes .spec.ts to //scripts/.spec.ts + 4. ensurePlaywrightConfig() → writes playwright.config.ts if missing + 5. Calls testSuiteService.saveScript() → scriptStore.save() + 6. Script record saved to test_suite_scripts table +``` + +### Run Flow (clicking Run) + +``` +User clicks Run in ResultsPanel or LibraryPanel + → useRunScript mutation → IPC: TEST_SUITE.RUN.SCRIPT { scriptId, triggeredBy: 'manual' } + → recorder-handlers.ts:186: testSuiteService.runScript(input) + → index.ts:234: runScript() + 1. scriptStore.get(scriptId) → gets filePath, projectId + 2. deps.getProjectPath(projectId) → gets absolute project path + 3. configStore.getActive(projectId) → gets screenshotMode, testDir + 4. runner.run({ scriptId, projectId, filePath, projectPath, triggeredBy, screenshotDir, handlers }) + + → runner.ts:77: run() + 1. INSERT into test_suite_runs: status='running' + 2. spawn('npx', ['playwright', 'test', filePath, '--reporter=line'], { cwd: projectPath, shell: win32 }) + 3. stdout/stderr → emitLine() → handlers.onLine() → router.emit(OUTPUT.LINE) → useRunOutput hook + 4. On close(code): + - code === 0 → status = 'passed' + - code !== 0 → status = 'failed' + - UPDATE test_suite_runs: status, completedAt, durationMs, output (JSON with outputLines) + - handlers.onComplete() → router.emit(RUN.COMPLETED) + 5. On error(err): + - status = 'failed', error = err.message + - UPDATE test_suite_runs: status, completedAt, durationMs, output (JSON with error) + - handlers.onComplete() + + → recorder-handlers.ts:188: router.emit(RUN.STARTED) + → Returns { runId } to renderer + → ResultsPanel: setSelectedRunId(data.runId) via onSuccess callback +``` + +### Pass/Fail Determination + +**The ONLY determination is the Playwright process exit code.** `runner.ts:136`: +``` +code === 0 → 'passed' +code !== 0 (or null) → 'failed' +``` + +There is no step-level pass/fail tracking. `steps_passed` and `steps_failed` columns are always 0. + +### Results Display Flow + +``` +ResultsPanel renders: + 1. useTestSuiteScripts(projectId) → script list for dropdown + 2. useTestSuiteRuns(scriptId) → run list for dropdown + 3. useRunOutput(activeRunId) → live IPC event accumulation (keyed by runId) + 4. useRun(activeRunId) → stored run record from DB (has outputLines after completion) + 5. useMemo merges: live lines preferred if any, else stored outputLines + 6. useRunSteps(activeRunId) → live step events (keyed by runId) + 7. For completed runs: steps reconstructed from script.steps via stepToLabel() + +Status badge: runRecord?.status ?? (activeRunId ? 'running' : 'pending') +``` + +### Analytics Data Source + +All analytics queries are in `analytics.ts`. They query `test_suite_runs` directly via SQL. + +| Metric | Query | +|--------|-------| +| Pass rate | `sum(case when status = 'passed' then 1 else 0 end) / count(*)` | +| Trend | Same, grouped by `date(started_at)` | +| Top failures | Count of `status = 'failed'` grouped by script_id | +| Flaky detection | Last 10 runs per script, count status flips (pass↔fail), flakeRate >= 0.2 | +| Error patterns | Parse `output` JSON for failed runs, extract lines containing "error" | + +### Library (Listing) Status + +`LibraryPanel` fetches ALL runs via `useAllTestSuiteRuns()`, iterates to find the latest run per script (by `startedAt`), and uses that run's `status` as the script's current status. Does **not** use `last_status` column (which is always null). + +### Scheduled Runs + +``` +scheduler.ts polls every 30s: + → Finds schedules where nextRunAt <= now AND enabled = true + → Calls onTrigger callback (set in index.ts:318) + → index.ts:320: service.runScript({ scriptId, triggeredBy: 'scheduled' }) + → Same runner flow as manual runs +``` + +### Watch Mode + +``` +watcher.ts: fs.watch() on spec file path + → On file change (debounced 500ms): + → recorder-handlers.ts:394: runScript({ triggeredBy: 'auto-trigger' }) + → router.emit(WATCH.TRIGGERED) +``` + +--- + +## File Map + +### Main Process (`src/main/features/test-suite/`) + +| File | Purpose | +|------|---------| +| `index.ts` | Service factory. Creates all sub-services, wires event handlers, exposes facade interface | +| `recorder-handlers.ts` | IPC handler layer. 47 channels. Thin bridge between IPC and service | +| `runner.ts` | Spawns `npx playwright test`. Manages active processes. Writes run records to DB | +| `script-store.ts` | CRUD for test_suite_scripts. Stores steps as JSON | +| `config-store.ts` | CRUD for configs in settings_kv table | +| `script-writer.ts` | Generates `.spec.ts` files from step arrays | +| `playwright-config-writer.ts` | Writes `playwright.config.ts` if missing | +| `browser-view-manager.ts` | BrowserView lifecycle (create, navigate, destroy, bounds) | +| `analytics.ts` | SQL aggregation queries for dashboard metrics | +| `exporter.ts` | Generates exportable spec files from step JSON | +| `workflow-exporter.ts` | CI/CD YAML generation (GitHub Actions) | +| `scheduler.ts` | setInterval-based periodic run triggers | +| `watcher.ts` | fs.watch on spec files for auto-rerun | +| `screenshot-capture.ts` | Indexes screenshot files after run completion | +| `baseline-store.ts` | Screenshot baseline records for visual diff | +| `diff-engine.ts` | Pixel-level screenshot comparison | +| `shared-steps-store.ts` | Reusable step group CRUD | +| `schema.ts` | Drizzle table definitions: test_suite_scripts, test_suite_runs, test_suite_screenshots | + +### Preload (`src/preload/`) + +| File | Purpose | +|------|---------| +| `test-suite-recorder.ts` | Injected into webview. Captures DOM events → `ipcRenderer.sendToHost()` | +| `selector-builder.ts` | Builds CSS selectors: testid > id > name > aria-label > role > css-path | + +### Renderer (`src/renderer/features/test-suite/`) + +| File | Purpose | +|------|---------| +| `test-suite-store.ts` | Zustand store: activeTab, recordedSteps, selectedScriptId, selectedRunId, recordingActive | +| `api/useRuns.ts` | `useRun(id)`, `useRuns(scriptId)`, `useRunScript()` — query + mutation hooks | +| `api/useTestSuiteRuns.ts` | `useTestSuiteRuns(scriptId)`, `useAllTestSuiteRuns()` | +| `api/useTestSuiteScripts.ts` | `useTestSuiteScripts(projectId)` | +| `api/testSuiteKeys.ts` | React Query key factory | +| `hooks/useRunOutput.ts` | Accumulates live `OUTPUT.LINE` IPC events, keyed by runId | +| `hooks/useRunSteps.ts` | Accumulates live `RUN.STEP` IPC events, keyed by runId | +| `hooks/useTestSuiteEvents.ts` | Invalidates query cache on `RUN.COMPLETED` and `CONFIG.CHANGED` | +| `components/TestSuitePage.tsx` | Tab container: Recording, Library, Results, Screenshots, Analytics, Shared Steps, Export | +| `components/RecordingPanel.tsx` | Dev server control + webview + step list + save dialog | +| `components/BrowserViewPanel.tsx` | `` wrapper with address bar, nav buttons, recorder preload | +| `components/ResultsPanel.tsx` | Script/run selectors, run button, status badge, step timeline, output log | +| `components/LibraryPanel.tsx` | Script table with search, filters, sparklines, batch actions | +| `components/AnalyticsPanel.tsx` | Summary cards, trend chart, top failures, flaky tests | + +--- + +## Known Dead Columns + +| Table | Column | Status | +|-------|--------|--------| +| test_suite_scripts | last_status | Never written. LibraryPanel uses useAllTestSuiteRuns() instead | +| test_suite_scripts | last_run_at | Never written | +| test_suite_runs | report | Never written | +| test_suite_runs | session_id | Never written | +| test_suite_runs | steps_passed | Written as 0, never updated | +| test_suite_runs | steps_failed | Written as 0, never updated | + +## Known Dead Files + +| File | Reason | +|------|--------| +| `renderer/features/test-suite/store.ts` | Old Zustand store, replaced by `test-suite-store.ts` | +| `api/useStartRecording.ts` | IPC-based recording replaced by webview `ipc-message` events | +| `api/useStopRecording.ts` | Same | +| `hooks/useBrowserViewBounds.ts` | BrowserView replaced by inline `` | + +## IPC Events (emitted by main, consumed by renderer) + +| Event | Emitted By | Consumed By | +|-------|-----------|-------------| +| `OUTPUT.LINE` | `runner.ts` via `onLine` handler → `recorder-handlers.ts:114` | `useRunOutput.ts` | +| `RUN.STARTED` | `recorder-handlers.ts:188` (after runScript resolves) | — (not consumed) | +| `RUN.STEP` | **Never emitted** — no code emits this event | `useRunSteps.ts` (always empty) | +| `RUN.COMPLETED` | `runner.ts` via `onComplete` handler → `recorder-handlers.ts:136` | `useTestSuiteEvents.ts` → invalidates all queries | +| `RUN.SCREENSHOT` | `recorder-handlers.ts:127` | — (not consumed by hooks) | +| `RECORDER.STEP` | — (not used; webview uses `ipc-message` instead) | — | +| `RECORDER.STOPPED` | — (not used) | — | +| `CONFIG.CHANGED` | `recorder-handlers.ts` (config save/delete/set-active handlers) | `useTestSuiteEvents.ts` → invalidates config query | +| `WATCH.TRIGGERED` | `recorder-handlers.ts:397` | — (not consumed) | diff --git a/docs/architecture/channels.md b/docs/architecture/channels.md new file mode 100644 index 00000000..10e639e1 --- /dev/null +++ b/docs/architecture/channels.md @@ -0,0 +1,44 @@ +# Channel isolation + +ADC supports three data-isolated channels. They can all run simultaneously on the same machine. + +| Channel | How to run | `app.setName` | userData path | `AppUserModelID` | Auto-update | +|---|---|---|---|---|---| +| release | Installed from GitHub release | `ADC` | `%APPDATA%/ADC` | `com.adc.app` | Yes | +| local | `npm run build:local` → `release/win-unpacked/ADC-Local.exe` | `ADC-Local` | `%APPDATA%/ADC-Local` | `com.adc.app.local` | No | +| dev | `npm run dev` | `ADC-Dev` | `%APPDATA%/ADC-Dev` | `com.adc.app.dev` | No | + +## How channel is resolved + +`src/main/lib/channel.ts::resolveChannel` precedence: + +1. `ADC_CHANNEL` env var (if valid value) +2. Compile-time `__ADC_CHANNEL__` (baked by electron-vite `define`) +3. `ADC_DEV_MODE=true` → `dev` +4. `!app.isPackaged` → `dev` +5. default → `release` + +## What a channel isolates + +- `userData` (including `adc.db`, settings, auth tokens) — via `app.setName()` +- `cache`, `logs`, `crashDumps`, `sessionData` — inherit from `userData` +- Claude CLI state (`~/.claude/*` equivalents) — via `CLAUDE_CONFIG_DIR=/.claude` +- Single-instance lock — keyed off `app.getName()` +- Windows taskbar grouping and toast routing — via `setAppUserModelId()` +- NSIS registry entries and installer paths — via electron-builder `productName` / `appId` overrides +- Auto-updater — disabled on non-release channels so local binaries never update against the real release feed + +## What a channel does NOT isolate + +- Port 9222 (DevTools MCP remote-debugging port) — hardcoded, only one instance can bind it at a time. Run at most one debuggable instance simultaneously. +- The Windows `%TEMP%` dir — shared. Portable target extraction writes here. +- Git worktrees and project directories — those are shared by design (the app manages user projects outside itself). + +## Cleaning a channel + +```bash +npm run reset:local # wipes %APPDATA%/ADC-Local +npm run reset:dev # wipes %APPDATA%/ADC-Dev +# release reset: manual, prompts for confirmation +node scripts/reset-channel.mjs release +``` diff --git a/docs/archive/V2-REFACTOR.md b/docs/archive/V2-REFACTOR.md deleted file mode 100644 index a8882b08..00000000 --- a/docs/archive/V2-REFACTOR.md +++ /dev/null @@ -1,106 +0,0 @@ -# ADC v2 Refactor — Architecture Reference - -> Status: COMPLETE as of 2026-04-01. All 9 phases shipped across agent-dashboard-view + adc-fix-first merges. - -ADC is undergoing a major architectural shift from **xterm.js terminal grids** (raw ANSI output via node-pty) to **headless agents with structured JSON streaming** rendered as a custom React chat UI. - -## What's Changing - -| Before (v1) | After (v2) | -|-------------|------------| -| xterm.js + node-pty renders raw terminal output | Headless `stream-json` + session JSONL parsed into React components | -| Single terminal pane per agent | Chat-style panels with markdown messages, tool call cards, inline diffs | -| No structured data from agents | NDJSON events: `system`, `assistant`, `stream_event`, `result` | -| Terminal grid layout | Selectable layouts: Single, 2-Column, 3-Column, Grid, Multi-Project | -| `terminal-service` manages PTY | `AgentManager` manages headless Claude processes + tmux sessions | - -## Three-Layer Data Architecture - -Agents and workflow tracking are **independent data streams** — the UI joins them when they correlate but displays each independently. - -- **Layer 1 — Agent Visibility** (always on): Session JSONL watching, `~/.claude/teams/*/config.json`, tmux pane detection. Shows ALL agent activity regardless of workflow tracking. -- **Layer 2 — Workflow Tracking** (claude-workflow plugin): `progress/*/events.jsonl`, task files, QA verdicts, FSM state. Only exists when `/new-plan` → `/agent-team` is used. -- **Layer 3 — Dashboard** (consumer): Correlates agents to tickets when possible, displays both independently when they don't match. - -## Two-Session Model - -- **Project Owner**: Headless via `spawn('claude', ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', ...])`. Bidirectional stdin/stdout NDJSON. -- **Team Lead**: Interactive in tmux with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. Output captured via session JSONL file watching. Input via `tmux send-keys`. -- **Teammates**: Auto-detected via `fs.watch(~/.claude/teams/*/config.json)`. Each has its own session JSONL. - -## Services Being Replaced / Added - -| Action | Service | Notes | -|--------|---------|-------| -| **Replace** | `terminal-service` → `AgentManager` | Stream-json spawn + tmux session management | -| **Replace** | Terminal grid UI → `AgentChatPanel` | `@llm-ui/react` + `@assistant-ui/react` for chat rendering | -| **New** | `TeamWatcher` | Watch team config for teammate join/leave | -| **New** | `SessionJSONLReader` | Tail-follow session JSONL, parse events, emit via IPC | -| **New** | `TmuxBridge` | Create/manage tmux sessions, send-keys | -| **New** | `AgentTicketCorrelator` | Match agent teams to ticket IDs (Layer 3) | -| **Reuse** | `agent-orchestrator`, `git-service`, `worktree-service`, `merge-service`, `qa-runner`, `progress-watcher` | Adapt, don't rewrite | - -## New Packages (v2 stack additions) - -| Package | Purpose | -|---------|---------| -| `react-arborist` | Virtualized file tree (replaces custom tree) | -| `@git-diff-view/react` | GitHub-style diff viewer | -| `@llm-ui/react` + `@llm-ui/markdown` | Smooth LLM streaming output | -| `@assistant-ui/react` | Composable chat UI primitives | -| `ghostty-web` | Escape-hatch terminal tab (Ghostty→WASM) | -| `react-markdown` + `remark-gfm` | Markdown rendering | -| `react-syntax-highlighter` | Code block highlighting | - -## DO NOT - -- **Do NOT build new features on `terminal-service` or xterm.js** — these are being replaced. -- **Do NOT add new PTY/node-pty integrations** — agent output comes from stream-json / JSONL now. -- **Do NOT assume agent visibility requires workflow tracking** — Layer 1 works independently. -- **Do NOT assume workflow tracking requires agent teams** — Layer 2 works independently. - -## Key Documents - -| Document | What | -|----------|------| -| `docs/research/2026-03-30-headless-agent-architecture.md` | Full research: data flow, service design, component stack, implementation phases | -| `docs/features/agent-dashboard-view/plan.md` | UI spec: layout modes, panel states, chat components, interactions | -| `docs/tracker.json` → `agent-dashboard-view` | Tracker entry (status: DRAFT, P0) | - -## Implementation Phases - -| Phase | What | Status | Shipped In | -|-------|------|--------|-----------| -| 0 | Verify session JSONL format, test Agent Teams in tmux | COMPLETE | Pre-existing | -| 1 | AgentManager + stream-json parsing (Project Owner) | COMPLETE | agent-dashboard-view | -| 2 | TmuxBridge + TeamWatcher (Team Lead) | COMPLETE | agent-dashboard-view (built) + adc-fix-first (wired) | -| 3 | SessionJSONLReader (teammate output) | COMPLETE | agent-dashboard-view | -| 4 | AgentChatPanel renderer (replace terminal view) | COMPLETE | agent-dashboard-view | -| 5 | react-arborist file explorer | COMPLETE | agent-dashboard-view (built) + adc-fix-first (IPC wired) | -| 6 | @git-diff-view/react diff viewer | COMPLETE | agent-dashboard-view | -| 7 | Wire ProgressWatcher to dashboard | COMPLETE | agent-dashboard-view (ProgressWatcherV2) + adc-fix-first (IPC handlers) | -| 8 | Flesh out claude-workflow tracking events | NOT STARTED | — | -| 9 | QA pipeline integration | COMPLETE | adc-fix-first (QaRunner.listSessions + mapQaSessionToDashboard wired) | - -## Progress Tracking During the Refactor - -The slug `agent-dashboard-view` is the single key used consistently everywhere. All artifacts MUST use this exact slug. - -| Artifact | Path | -|----------|------| -| Tracker entry | `docs/tracker.json` → `plans.agent-dashboard-view` | -| Plan doc | `docs/features/agent-dashboard-view/plan.md` | -| Research doc | `docs/research/2026-03-30-headless-agent-architecture.md` | -| Progress events | `progress/agent-dashboard-view/events.jsonl` | -| Progress summary | `progress/agent-dashboard-view/current.md` | -| Workflow state | `progress/agent-dashboard-view/workflow-state.json` | -| Task files | `progress/agent-dashboard-view/tasks/task-*.md` | -| Feature branch | `feature/agent-dashboard-view` | -| Work branches | `work/agent-dashboard-view/` | -| Worktrees | `.worktrees/agent-dashboard-view/` | - -**Workflow config**: `.claude/workflow.json` defines branching prefixes, worktree settings, and bootstrap config. Read it before any `/agent-team` execution. - -**Event tracking**: The claude-workflow plugin writes JSONL events to `progress/agent-dashboard-view/events.jsonl` using the schema defined in `prompts/implementing-features/EVENT-SCHEMA.md`. Key event types: `session.start`, `plan.created`, `task.started`, `task.completed`, `qa.passed`, `branch.merged`, `checkpoint`, `session.end`. - -**Tracker sync**: When implementation starts, update `docs/tracker.json` status from `DRAFT` → `IN_PROGRESS`. When complete, update to `IMPLEMENTED`. The tracker is the persistent cross-session record; progress/ is the runtime crash-recovery artifact. diff --git a/docs/archive/session-persistence-plan.md b/docs/archive/session-persistence-plan.md deleted file mode 100644 index 4c632684..00000000 --- a/docs/archive/session-persistence-plan.md +++ /dev/null @@ -1,395 +0,0 @@ -# Plan: Session Persistence & Always-On Claude Sessions - -> **NOTE:** This plan predates the caching layer consolidation. References to `LayoutHydrator` and `ThemeHydrator` components are outdated -- these have been replaced by `useLayoutSync` and `useThemeSync` hooks in `src/renderer/shared/hooks/`. See `docs/patterns/CACHING-LAYER-QUICKGUIDE.md`. - -## Summary - -Persist open project tabs across app restarts, spawn per-project Claude sessions (Primary + Team Lead) eagerly at startup, and create a single global Assistant instance with IPC tool access for in-app actions (task CRUD, project management, git status). Tray quick commands route through the global assistant. The assistant uses app IPC channels exclusively — never Claude Code CLI features. - -## Impact Analysis - -| Layer | Impact | -|-------|--------| -| `src/shared/ipc/` | New `settings` fields for persisted layout; new assistant tool-execution event contract | -| `src/main/services/settings/` | Add `openProjectTabs` and `lastRoute` fields to settings | -| `src/main/services/assistant/` | **Rewrite** — single global instance, IPC tool system prompt, tool execution routing | -| `src/main/services/workspace/` | Add `initAllProjects()` for eager startup | -| `src/main/bootstrap/` | Wire eager workspace init after auth restore; wire assistant auto-start | -| `src/main/index.ts` | Route tray quick commands through assistant | -| `src/main/tray/` | Connect quick-input → assistant.sendCommand | -| `src/renderer/shared/stores/layout-store.ts` | Add localStorage persistence + hydration | -| `src/renderer/features/assistant/` | Update store/hooks to work with global assistant (no per-project session param) | -| `src/renderer/app/layouts/RootLayout.tsx` | Add layout hydration on auth success | - -## Data Model Changes - -### Settings Additions (persisted to `settings.json`) - -```typescript -// Added to AppSettings -interface AppSettings { - // ... existing fields - openProjectTabs: string[]; // project IDs in tab order - activeProjectId: string | null; // last active project - lastRoutePerProject: Record; // projectId → route path - sidebarCollapsed: boolean; - sidebarLayout: SidebarLayoutId; -} -``` - -### Assistant System Prompt (IPC Tool Definitions) - -The global assistant's system prompt will include structured tool definitions describing available IPC actions. When the assistant calls a tool, the main process intercepts the `tool_use` content block from the stream-json output and routes it to the appropriate IPC handler. - -```typescript -// New type for assistant tool routing -interface AssistantToolCall { - toolName: string; // e.g. 'tasks.create' - input: Record; -} - -interface AssistantToolResult { - toolName: string; - result: unknown; - success: boolean; -} -``` - -### New IPC Channels - -```typescript -// settings contract additions -'settings.getLayout': { - input: z.object({}), - output: z.object({ - openProjectTabs: z.array(z.string()), - activeProjectId: z.string().nullable(), - lastRoutePerProject: z.record(z.string(), z.string()), - sidebarCollapsed: z.boolean(), - sidebarLayout: z.string(), - }), -} - -'settings.saveLayout': { - input: z.object({ - openProjectTabs: z.array(z.string()).optional(), - activeProjectId: z.string().nullable().optional(), - lastRoutePerProject: z.record(z.string(), z.string()).optional(), - sidebarCollapsed: z.boolean().optional(), - sidebarLayout: z.string().optional(), - }), - output: z.object({ success: z.boolean() }), -} -``` - -## Service Layer Changes - -### 1. Settings Service — Layout Persistence - -Add `openProjectTabs`, `activeProjectId`, `lastRoutePerProject`, `sidebarCollapsed`, and `sidebarLayout` to the settings file defaults. Provide `getLayout()` / `saveLayout()` convenience methods that read/write just the layout subset. - -### 2. Assistant Service — Global Singleton Rewrite - -**Current:** Per-project sessions in a Map keyed by projectPath. -**New:** Single global session spawned at app startup. Lives until app quit. - -Key changes: -- Remove `projectSessions` Map → single `sessionId` -- System prompt includes all open projects' names/paths and IPC tool definitions -- On tool_use blocks in the stream-json output, intercept and route to IPC handlers -- Return tool results back to the Claude session via stdin message -- Tray quick-input sends commands to this same session - -Tool routing flow: -``` -User says "add task for ADC: clean up code" - → assistant Claude session receives message - → Claude outputs tool_use: { name: "tasks.create", input: { projectId: "...", title: "clean up code" } } - → stream-json parser detects tool_use block - → assistantService.handleToolCall() invokes tasks.create via the service layer - → result sent back to Claude session as tool_result - → Claude outputs text summary: "Created task 'clean up code' in ADC" - → text forwarded to renderer as assistant response -``` - -### 3. Workspace Session Manager — Eager Init - -Add `initAllProjects(projects: Array<{id: string, path: string}>)` method that calls `initProject()` for each open project tab. Called once after auth restore succeeds. - -### 4. Tray Quick Input — Route Through Assistant - -Change `onCommand` callback to send the command to `assistantService.sendCommand()` instead of a custom handler. The assistant processes it like any sidebar message. - -## UI Changes - -### Layout Store Persistence - -The layout store (`layout-store.ts`) will: -1. On every state change → debounce and call `ipc('settings.saveLayout', ...)` to persist -2. On app startup (after auth) → call `ipc('settings.getLayout')` and hydrate the store -3. Clear layout state on logout - -### Layout Hydrator Component - -New `LayoutHydrator` component (similar pattern to `ThemeHydrator`): -- Mounted inside `AuthGuard` -- On mount: fetches layout from settings, hydrates layout store -- Triggers workspace session init for all persisted project tabs -- Navigates to last active project + last route - -### Assistant Sidebar - -Remove `projectPath` parameter from `sendCommand` — the global assistant already knows all projects. The sidebar input just sends text; the assistant figures out which project from context. - -## Task Breakdown - -### Task 1: Settings — Layout Persistence Fields -- **Agent:** service-engineer -- **Files:** - - `src/shared/types/settings.ts` — add layout fields to AppSettings - - `src/main/services/settings/settings-service.ts` — add defaults, getLayout/saveLayout methods - - `src/shared/ipc/settings/contract.ts` — add getLayout/saveLayout channels - - `src/shared/ipc/settings/schemas.ts` — add layout schemas - - `src/main/ipc/handlers/settings-handlers.ts` — add handler wiring -- **Depends on:** none -- **Acceptance criteria:** - - [ ] `settings.getLayout` returns layout state from settings.json - - [ ] `settings.saveLayout` persists layout state to settings.json - - [ ] Default values work when no layout has been saved yet - - [ ] Layout fields merge cleanly with existing settings - -### Task 2: Layout Store — Persistence & Hydration -- **Agent:** store-engineer -- **Files:** - - `src/renderer/shared/stores/layout-store.ts` — add debounced persistence to settings - - `src/renderer/shared/stores/LayoutHydrator.tsx` — new component (mirrors ThemeHydrator pattern) - - `src/renderer/shared/stores/index.ts` — export LayoutHydrator - - `src/renderer/app/layouts/RootLayout.tsx` — mount LayoutHydrator -- **Depends on:** Task 1 -- **Acceptance criteria:** - - [ ] On app restart, project tabs restored from settings - - [ ] Active project and sidebar state restored - - [ ] Last route per project restored (navigates to correct view) - - [ ] Logout clears persisted layout - - [ ] Debounced save (not on every keystroke) - -### Task 3: Eager Workspace Session Init -- **Agent:** service-engineer -- **Files:** - - `src/main/services/workspace/workspace-session-manager.ts` — add `initAllProjects()` method - - `src/main/bootstrap/service-registry.ts` — wire eager init after service creation - - `src/main/ipc/handlers/workspace-handlers.ts` — add initAll handler - - `src/shared/ipc/workspace/contract.ts` — add `workspace.initAllProjects` channel -- **Depends on:** Task 1 (needs to read persisted project tabs) -- **Acceptance criteria:** - - [ ] On app startup, all persisted project tabs get Primary + Team Lead sessions - - [ ] Sessions spawn after auth restore succeeds (not before) - - [ ] Idempotent — re-calling doesn't duplicate sessions - - [ ] Sessions survive project tab switches (no teardown) - - [ ] Sessions only terminate on app quit - -### Task 4: Global Assistant — Single Instance Rewrite -- **Agent:** service-engineer -- **Files:** - - `src/main/services/assistant/assistant-service.ts` — rewrite to single global session - - `src/main/services/assistant/assistant-tools.ts` — new: tool definitions and routing - - `src/main/services/assistant/tool-definitions.ts` — new: system prompt tool descriptions - - `src/main/bootstrap/service-registry.ts` — start assistant at boot, not lazily -- **Depends on:** Task 1 (needs project list for system prompt) -- **Acceptance criteria:** - - [ ] Single Claude session spawned at app startup - - [ ] Session lives until app quit - - [ ] System prompt includes available IPC tools (tasks, projects, git) - - [ ] System prompt includes list of open projects with names/paths - - [ ] tool_use blocks in stream output are intercepted and routed to services - - [ ] tool_result returned to Claude session - - [ ] Text responses forwarded to renderer as before - -### Task 5: Assistant Tool Routing — Task CRUD -- **Agent:** service-engineer -- **Files:** - - `src/main/services/assistant/tool-handlers/task-tools.ts` — new: task tool implementations - - `src/main/services/assistant/assistant-tools.ts` — register task tools -- **Depends on:** Task 4 -- **Acceptance criteria:** - - [ ] "add task for ADC: clean up code" creates a task via `tasks.create` IPC - - [ ] "list tasks for ADC" returns task list - - [ ] "update task X status to done" calls `tasks.updateStatus` - - [ ] "delete task X" calls `tasks.delete` - - [ ] Created tasks appear in the Tasks page table without refresh - -### Task 6: Assistant Tool Routing — Project & Git Tools -- **Agent:** service-engineer -- **Files:** - - `src/main/services/assistant/tool-handlers/project-tools.ts` — new: project tool implementations - - `src/main/services/assistant/tool-handlers/git-tools.ts` — new: git tool implementations - - `src/main/services/assistant/assistant-tools.ts` — register project + git tools -- **Depends on:** Task 4 -- **Acceptance criteria:** - - [ ] "list projects" returns project list - - [ ] "switch to project X" sets active project - - [ ] "git status for ADC" returns branch + changed files - - [ ] "show open PRs" returns PR list from GitHub - -### Task 7: Tray Quick Command → Assistant -- **Agent:** service-engineer -- **Files:** - - `src/main/index.ts` — change tray onQuickCommand to route through assistant - - `src/main/tray/quick-input.ts` — send command to assistant, show response in popup -- **Depends on:** Task 4 -- **Acceptance criteria:** - - [ ] Tray quick command input sends text to global assistant - - [ ] Assistant response shown in quick input popup (or main window) - - [ ] Same tool routing works from tray as from sidebar - -### Task 8: Renderer Assistant Updates -- **Agent:** component-engineer -- **Files:** - - `src/renderer/features/assistant/components/SidebarAssistantButton.tsx` — remove projectPath from sendCommand - - `src/renderer/features/assistant/components/WidgetPanel.tsx` — remove project path logic - - `src/renderer/features/assistant/api/useAssistant.ts` — simplify sendCommand mutation (no projectPath) - - `src/renderer/features/assistant/hooks/useAssistantEvents.ts` — handle tool execution events (invalidate query caches) - - `src/shared/ipc/assistant/contract.ts` — update sendCommand input (remove projectPath), keep toolExecuted event -- **Depends on:** Task 4 -- **Acceptance criteria:** - - [ ] Sidebar assistant sends messages without specifying a project - - [ ] Tool execution events invalidate relevant React Query caches - - [ ] Creating a task via assistant → task list auto-refreshes - - [ ] No projectPath in assistant IPC contract - -### Task 9: Assistant Input UX — Project Selector & Quick Actions -- **Agent:** component-engineer -- **Files:** - - `src/renderer/features/assistant/components/AssistantInputBar.tsx` — new shared input component used by sidebar inline, popup panel, and tray - - `src/renderer/features/assistant/components/ProjectSelector.tsx` — new: dropdown showing active projects with colored dot indicators - - `src/renderer/features/assistant/components/QuickActionChips.tsx` — new: flex-wrap row of quick action buttons - - `src/renderer/features/assistant/components/SidebarAssistantButton.tsx` — use AssistantInputBar - - `src/renderer/features/assistant/components/WidgetPanel.tsx` — use AssistantInputBar - - `src/renderer/features/assistant/components/WidgetInput.tsx` — replace with AssistantInputBar - - `src/main/tray/quick-input.ts` — add project dropdown + action chips to inline HTML -- **Depends on:** Task 8 -- **Acceptance criteria:** - - [ ] Dropdown icon next to input shows list of active/open projects - - [ ] Selecting a project prefixes the command with project context (e.g. "@ADC") - - [ ] Default selection = currently active project in tab bar - - [ ] Quick action chips below input: `+ Task`, `+ Todo`, `Status`, `Git Status`, `PRs`, `Briefing` - - [ ] Clicking a chip pre-fills the input with a template (e.g. "+ Task" → "add task for {project}: ") - - [ ] Chips wrap to multiple rows on narrow sidebar - - [ ] Works in sidebar inline mode, popup panel, and tray quick-input - - [ ] Project dropdown uses StatusIndicator to show session health (green=live, yellow=starting) - -#### UI Design - -**Sidebar inline (expanded) layout:** -``` -┌─────────────────────────┐ -│ 🗨 Assistant [⤢] [✕] │ -├─────────────────────────┤ -│ (message area) │ -├─────────────────────────┤ -│ [▾ ADC] [Ask anything…] │ -│ +Task +Todo Status …│ -└─────────────────────────┘ -``` - -**Popup panel layout:** -``` -┌──────────────────────────────────┐ -│ Assistant [🔊] [🗑] [✕] │ -├──────────────────────────────────┤ -│ (message area) │ -├──────────────────────────────────┤ -│ +Task +Todo Status Git PRs │ -│ [▾ ADC] [Ask anything… ] ↑ │ -└──────────────────────────────────┘ -``` - -**Tray quick-input layout:** -``` -┌──────────────────────────────────────┐ -│ [▾ ADC] [Quick command… ] ↵ │ -│ +Task +Todo Status Git PRs │ -└──────────────────────────────────────┘ -``` - -#### Quick Action Chips - -| Chip | Prefill Template | Tool Routed To | -|------|-----------------|----------------| -| `+ Task` | `add task for {project}: ` | `tasks.create` | -| `+ Todo` | `add todo for {project}: ` | `tasks.create` (priority=low) | -| `Status` | `status of {project}` | `git.status` + `tasks.list` | -| `Git` | `git status for {project}` | `git.status` | -| `PRs` | `open PRs for {project}` | `github.listPrs` | -| `Briefing` | `briefing for {project}` | `briefing.generate` | - -### Task 10: Documentation -- **Agent:** docs -- **Files:** - - `docs/architecture/ARCHITECTURE.md` — update assistant section - - `docs/routing/FEATURES-INDEX.md` — update assistant, workspace entries - - `.claude/agents/` — update relevant agent definitions -- **Depends on:** Tasks 1–9 -- **Acceptance criteria:** - - [ ] Architecture doc reflects global assistant + tool routing - - [ ] Features index updated - - [ ] Agent definitions updated - -## Wave Plan - -``` -Wave 1 (parallel): - Task 1 — Settings layout persistence (no deps) - -Wave 2 (parallel after Wave 1): - Task 2 — Layout store persistence & hydration - Task 3 — Eager workspace session init - Task 4 — Global assistant rewrite - -Wave 3 (parallel after Wave 2): - Task 5 — Assistant task tools - Task 6 — Assistant project + git tools - Task 7 — Tray quick command routing - Task 8 — Renderer assistant updates - -Wave 4 (after Wave 3): - Task 9 — Assistant input UX (project selector + quick actions) - -Wave 5: - Task 10 — Documentation -``` - -## Testing Strategy - -### Unit Tests -- Settings getLayout/saveLayout round-trip -- Layout store persistence debouncing -- Assistant tool routing (mock IPC handlers) -- Tool definition schema validation - -### Integration Tests -- Settings IPC handler → settings file → read back -- Assistant tool_use → service call → tool_result flow - -### Manual Verification -- [ ] Close app → reopen → same project tabs, same active project, same route -- [ ] Open sidebar assistant → "add task for ADC: test task" → task appears in table -- [ ] Tray icon → Quick Command → "list tasks" → see response -- [ ] Kill app via tray Quit → all sessions terminate cleanly -- [ ] Logout → re-login → layout restored (same user) -- [ ] Switch users → layout is per-user (different tabs) -- [ ] Project dropdown in assistant input shows all open projects -- [ ] Selecting a project from dropdown changes command context -- [ ] Quick action chips pre-fill input with correct template -- [ ] "+ Task" chip → type title → creates task in selected project -- [ ] Chips work in sidebar inline, popup panel, and tray quick-input - -## Risks & Mitigations - -| Risk | Mitigation | -|------|------------| -| Claude CLI tool_use interception is complex | Start with a minimal tool set (3-4 tools), validate the stream-json parsing before expanding | -| Eager session spawn = many Claude processes at startup | Rate-limit spawning (100ms delay between projects), cap at 5 concurrent project sessions | -| Stale persisted state (deleted project) | Validate project IDs against `projects.list` during hydration, drop invalid ones | -| Settings file corruption | Use atomic write (write to .tmp then rename), same pattern as existing settings service | -| Assistant tool execution races with UI | Emit `event:assistant.toolExecuted` with query key hints so renderer can invalidate caches | diff --git a/docs/archive/task-pipeline-implementation-plan.md b/docs/archive/task-pipeline-implementation-plan.md deleted file mode 100644 index 6d5e4dae..00000000 --- a/docs/archive/task-pipeline-implementation-plan.md +++ /dev/null @@ -1,1110 +0,0 @@ -# Task Pipeline — Implementation Plan - -> **NOTE:** This plan predates the caching layer consolidation. References to `useProgressContext`, `useAgentContext`, `ProgressContextHydrator`, `AgentContextHydrator`, `progress-context-store`, and `agent-context-store` are outdated -- these have been replaced by React Query hooks (`useProgress`, `useProgressMutations`, `useAgentMessages`) and `EventBridge`. See `docs/patterns/CACHING-LAYER-QUICKGUIDE.md`. - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace Hub-based task management with a local `progress/` filesystem-backed pipeline. Tasks flow through Research → Plan → Team with per-agent session monitoring, disk-first persistence, and global stores. - -**Architecture:** Data fetching uses the 3-layer caching architecture (EventBridge → React Query → Components). The task list grid reads from React Query hooks (`useProgress`), and IPC events drive cache invalidation via EventBridge. Agent session data is append-only JSONL on disk with rolling window in memory. - -**Tech Stack:** TypeScript strict, Zod IPC contracts, Zustand 5, TanStack Table v8, React 19, Node.js FS watchers, JSONL append-only logs. - -**Spec:** `docs/features/task-pipeline/plan.md` - ---- - -## File Structure - -### New Files (by layer) - -**Shared Types:** -- `src/shared/types/progress.ts` — ProgressTask, ProgressStatus, ProgressPriority, SessionSummary -- `src/shared/types/agent-session-detail.ts` — AgentSessionDetail, ToolCallSummary, AgentError, FilteredLogEntry - -**IPC Contract:** -- `src/shared/ipc/progress/schemas.ts` — Zod schemas for all progress types -- `src/shared/ipc/progress/contract.ts` — invoke + event channel definitions -- `src/shared/ipc/progress/index.ts` — barrel - -**Main Process Service:** -- `src/main/services/progress/task-file-io.ts` — read/write frontmatter md files, scan directories -- `src/main/services/progress/session-writer.ts` — filtered JSONL append + summary file updates -- `src/main/services/progress/log-cleanup.ts` — weekly cleanup of old JSONL files -- `src/main/services/progress/progress-service.ts` — orchestrator: CRUD, FS watcher, action spawning, workflow state machine -- `src/main/services/progress/index.ts` — barrel - -**IPC Handlers:** -- `src/main/ipc/handlers/progress-handlers.ts` — thin handlers delegating to ProgressService - -**Renderer Stores:** -- `src/renderer/shared/stores/progress-context-store.ts` — global task pipeline store -- `src/renderer/shared/stores/ProgressContextHydrator.tsx` — sync from IPC + events - -**Renderer UI:** -- `src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx` — TanStack Table grid -- `src/renderer/features/tasks/components/grid/columns.tsx` — column definitions (status, title, priority, stage, ticket, PR, updated) -- `src/renderer/features/tasks/components/grid/StageIndicator.tsx` — three-dot pipeline indicator -- `src/renderer/features/tasks/components/grid/TicketBadge.tsx` — Jira clickable badge -- `src/renderer/features/tasks/components/grid/PrBadge.tsx` — GitHub PR clickable badge -- `src/renderer/features/tasks/components/detail/ProgressTaskDetailRow.tsx` — expanded row container with pipeline sections -- `src/renderer/features/tasks/components/detail/ResearchSection.tsx` — research view + button -- `src/renderer/features/tasks/components/detail/PlanSection.tsx` — plan view + buttons -- `src/renderer/features/tasks/components/detail/TeamActivityPanel.tsx` — agent list table -- `src/renderer/features/tasks/components/detail/AgentDetailExpander.tsx` — per-agent expandable (messages, tools, errors, diff) -- `src/renderer/features/tasks/components/detail/PipelineTopBar.tsx` — ticket/PR badges + Run Workflow + Archive -- `src/renderer/features/tasks/components/TaskToolbar.tsx` — search, filter, New Task, Run Workflow - -### Modified Files -- `src/shared/ipc/index.ts` — spread progressInvoke + progressEvents -- `src/shared/ipc/agent-dashboard/contract.ts` — add session log/detail/diff channels -- `src/main/services/agent-manager/agent-manager-service.ts` — add getSessionDetail, getSessionLog, getGitDiff methods + session writer wiring -- `src/main/ipc/handlers/agent-dashboard-handlers.ts` — register new session data handlers -- `src/main/bootstrap/service-registry.ts` — create ProgressService, wire to agent manager + lifecycle -- `src/main/services/visualization/agent-teams.ts` — refactor to read from progress/ + AgentManagerService -- `src/renderer/shared/stores/agent-context-store.ts` — expand to full data layer -- `src/renderer/shared/stores/AgentContextHydrator.tsx` — hydrate expanded data -- `src/renderer/shared/stores/index.ts` — export new stores -- `src/renderer/app/layouts/RootLayout.tsx` — mount ProgressContextHydrator -- `src/renderer/features/tasks/components/TasksPage.tsx` — swap to ProgressTaskGrid -- `docs/routing/FEATURES-INDEX.md` — add progress service -- `CLAUDE.md` — document progress channels - ---- - -## Wave 1: Shared Types + IPC Contract (no dependencies) - -These tasks have zero dependencies and can all run in parallel. - -### Task 1: Progress Types - -**Files:** -- Create: `src/shared/types/progress.ts` - -- [ ] **Step 1: Create ProgressStatus and ProgressPriority enums** - -```typescript -// src/shared/types/progress.ts - -export type ProgressStatus = - | 'backlog' - | 'researching' - | 'research_done' - | 'planning' - | 'plan_ready' - | 'executing' - | 'review' - | 'done' - | 'error' - | 'archived'; - -export type ProgressPriority = 'low' | 'normal' | 'high' | 'urgent'; - -export type PrStatus = 'draft' | 'open' | 'merged' | 'closed'; -``` - -- [ ] **Step 2: Create ProgressTask interface** - -```typescript -export interface ProgressTask { - slug: string; - rootFile: string; - title: string; - description: string; - status: ProgressStatus; - priority: ProgressPriority; - jiraTicket?: string; - jiraUrl?: string; - prNumber?: number; - prUrl?: string; - prStatus?: PrStatus; - createdAt: string; - updatedAt: string; - hasResearch: boolean; - hasPlan: boolean; - hasTeamTasks: boolean; - teamTaskCount: number; - researchContent?: string; - planContent?: string; -} -``` - -- [ ] **Step 3: Create SessionSummary interface** - -```typescript -export interface SessionSummary { - sessionId: string; - agentName: string; - agentRole: string; - taskSlug: string; - model: string; - provider: string; - startedAt: string; - endedAt: string | null; - durationMs: number; - inputTokens: number; - outputTokens: number; - totalTokens: number; - costUsd: number; - toolCallCount: number; - toolCallsByName: Record; - errorCount: number; - messageCount: number; - filesChanged: number; - status: 'completed' | 'failed' | 'killed'; - exitCode: number | null; -} -``` - -- [ ] **Step 4: Create FilteredLogEntry type** - -```typescript -export type FilteredLogEntryType = - | 'assistant_message' - | 'user_message' - | 'tool_use' - | 'tool_result' - | 'usage' - | 'error' - | 'system_init'; - -export interface FilteredLogEntry { - type: FilteredLogEntryType; - timestamp: string; - sessionId: string; - data: Record; -} -``` - -- [ ] **Step 5: Run typecheck** - -Run: `npm run typecheck` -Expected: PASS (no consumers yet, just type definitions) - -- [ ] **Step 6: Commit** - -```bash -git add src/shared/types/progress.ts -git commit -m "feat(types): add progress task pipeline types" -``` - ---- - -### Task 2: Agent Session Detail Types - -**Files:** -- Create: `src/shared/types/agent-session-detail.ts` - -- [ ] **Step 1: Create AgentSessionDetail interface** - -```typescript -// src/shared/types/agent-session-detail.ts - -import type { AgentStatus } from './agent-dashboard'; - -export interface AgentSessionDetail { - sessionId: string; - name: string; - role: string; - taskSlug: string; - taskNumber: number | null; - status: AgentStatus; - branch: string | null; - model: string; - tokenUsage: { input: number; output: number }; - startedAt: string; - lastActivityAt: string; - exitCode: number | null; - isTeamLead: boolean; -} -``` - -- [ ] **Step 2: Create ToolCallSummary and AgentError** - -```typescript -export interface ToolCallSummary { - id: string; - toolName: string; - inputSummary: string; - success: boolean; - timestamp: string; -} - -export interface AgentError { - message: string; - stack?: string; - timestamp: string; - sessionId: string; -} -``` - -- [ ] **Step 3: Run typecheck** - -Run: `npm run typecheck` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add src/shared/types/agent-session-detail.ts -git commit -m "feat(types): add agent session detail types" -``` - ---- - -### Task 3: Progress IPC Contract - -**Files:** -- Create: `src/shared/ipc/progress/schemas.ts` -- Create: `src/shared/ipc/progress/contract.ts` -- Create: `src/shared/ipc/progress/index.ts` -- Modify: `src/shared/ipc/index.ts` - -- [ ] **Step 1: Create Zod schemas** - -```typescript -// src/shared/ipc/progress/schemas.ts -import { z } from 'zod'; - -export const ProgressStatusSchema = z.enum([ - 'backlog', 'researching', 'research_done', 'planning', - 'plan_ready', 'executing', 'review', 'done', 'error', 'archived', -]); - -export const ProgressPrioritySchema = z.enum(['low', 'normal', 'high', 'urgent']); - -export const PrStatusSchema = z.enum(['draft', 'open', 'merged', 'closed']); - -export const ProgressTaskSchema = z.object({ - slug: z.string(), - rootFile: z.string(), - title: z.string(), - description: z.string(), - status: ProgressStatusSchema, - priority: ProgressPrioritySchema, - jiraTicket: z.string().optional(), - jiraUrl: z.string().optional(), - prNumber: z.number().optional(), - prUrl: z.string().optional(), - prStatus: PrStatusSchema.optional(), - createdAt: z.string(), - updatedAt: z.string(), - hasResearch: z.boolean(), - hasPlan: z.boolean(), - hasTeamTasks: z.boolean(), - teamTaskCount: z.number(), - researchContent: z.string().optional(), - planContent: z.string().optional(), -}); - -export const SessionSummarySchema = z.object({ - sessionId: z.string(), - agentName: z.string(), - agentRole: z.string(), - taskSlug: z.string(), - model: z.string(), - provider: z.string(), - startedAt: z.string(), - endedAt: z.string().nullable(), - durationMs: z.number(), - inputTokens: z.number(), - outputTokens: z.number(), - totalTokens: z.number(), - costUsd: z.number(), - toolCallCount: z.number(), - toolCallsByName: z.record(z.string(), z.number()), - errorCount: z.number(), - messageCount: z.number(), - filesChanged: z.number(), - status: z.enum(['completed', 'failed', 'killed']), - exitCode: z.number().nullable(), -}); -``` - -- [ ] **Step 2: Create invoke + event contract** - -```typescript -// src/shared/ipc/progress/contract.ts -import { z } from 'zod'; - -import { SuccessResponseSchema } from '../common/schemas'; - -import { ProgressTaskSchema } from './schemas'; - -export const progressInvoke = { - 'progress.listTasks': { - input: z.object({}), - output: z.array(ProgressTaskSchema), - }, - 'progress.getTask': { - input: z.object({ slug: z.string() }), - output: ProgressTaskSchema.nullable(), - }, - 'progress.createTask': { - input: z.object({ - slug: z.string(), - title: z.string(), - description: z.string(), - priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(), - }), - output: ProgressTaskSchema, - }, - 'progress.updateTask': { - input: z.object({ - slug: z.string(), - updates: z.record(z.string(), z.unknown()), - }), - output: ProgressTaskSchema, - }, - 'progress.archiveTask': { - input: z.object({ slug: z.string() }), - output: SuccessResponseSchema, - }, - 'progress.deleteTask': { - input: z.object({ slug: z.string() }), - output: SuccessResponseSchema, - }, - 'progress.listArchived': { - input: z.object({}), - output: z.array(ProgressTaskSchema), - }, - 'progress.startResearch': { - input: z.object({ slug: z.string() }), - output: z.object({ sessionId: z.string() }), - }, - 'progress.createPlan': { - input: z.object({ slug: z.string() }), - output: z.object({ sessionId: z.string() }), - }, - 'progress.spinUpTeam': { - input: z.object({ slug: z.string() }), - output: z.object({ - sessionId: z.string(), - teamLeadIndex: z.number(), - action: z.enum(['spawned', 'reused']), - }), - }, - 'progress.runWorkflow': { - input: z.object({ slug: z.string() }), - output: z.object({ started: z.literal(true) }), - }, - 'progress.cancelAction': { - input: z.object({ slug: z.string() }), - output: SuccessResponseSchema, - }, - 'progress.runLogCleanup': { - input: z.object({}), - output: z.object({ deletedFiles: z.number() }), - }, -} as const; - -export const progressEvents = { - 'event:progress.taskUpdated': { - payload: z.object({ slug: z.string(), task: ProgressTaskSchema }), - }, - 'event:progress.taskCreated': { - payload: z.object({ slug: z.string(), task: ProgressTaskSchema }), - }, - 'event:progress.taskArchived': { - payload: z.object({ slug: z.string() }), - }, - 'event:progress.actionStarted': { - payload: z.object({ - slug: z.string(), - action: z.enum(['research', 'plan', 'team']), - sessionId: z.string(), - }), - }, - 'event:progress.actionCompleted': { - payload: z.object({ - slug: z.string(), - action: z.enum(['research', 'plan', 'team']), - }), - }, - 'event:progress.actionFailed': { - payload: z.object({ - slug: z.string(), - action: z.enum(['research', 'plan', 'team']), - error: z.string(), - }), - }, - 'event:progress.workflowStep': { - payload: z.object({ - slug: z.string(), - step: z.enum(['research', 'plan', 'team']), - status: z.enum(['started', 'completed', 'failed']), - }), - }, -} as const; -``` - -- [ ] **Step 3: Create barrel** - -```typescript -// src/shared/ipc/progress/index.ts -export { progressEvents, progressInvoke } from './contract'; -export { - ProgressPrioritySchema, - ProgressStatusSchema, - ProgressTaskSchema, - PrStatusSchema, - SessionSummarySchema, -} from './schemas'; -``` - -- [ ] **Step 4: Add to root IPC barrel** - -In `src/shared/ipc/index.ts`, add import and spread: - -```typescript -import { progressEvents, progressInvoke } from './progress'; - -// In ipcInvokeContract: - ...progressInvoke, - -// In ipcEventContract: - ...progressEvents, -``` - -- [ ] **Step 5: Run typecheck + lint** - -Run: `npm run typecheck && npm run lint` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/shared/ipc/progress/ src/shared/ipc/index.ts -git commit -m "feat(ipc): add progress task pipeline contract" -``` - ---- - -## Wave 2: Main Process Services (depends on Wave 1 types) - -### Task 4: Task File I/O - -**Files:** -- Create: `src/main/services/progress/task-file-io.ts` - -Core utilities: read/write YAML frontmatter markdown files, scan `progress/` directory, detect root file name, check subdirectory presence. - -- [ ] **Step 1: Write the test** - -Create `tests/unit/services/progress/task-file-io.test.ts`: - -```typescript -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import { - readTaskFile, - writeTaskFile, - scanProgressDir, - detectRootFile, - checkSubdirectories, -} from '@main/services/progress/task-file-io'; - -const TEST_DIR = join(tmpdir(), 'progress-test-' + Date.now()); - -beforeEach(() => { - mkdirSync(join(TEST_DIR, 'progress', 'my-feature'), { recursive: true }); -}); - -afterEach(() => { - rmSync(TEST_DIR, { recursive: true, force: true }); -}); - -describe('detectRootFile', () => { - it('finds task.md', () => { - writeFileSync(join(TEST_DIR, 'progress', 'my-feature', 'task.md'), '---\ntitle: Test\n---'); - expect(detectRootFile(join(TEST_DIR, 'progress', 'my-feature'))).toBe('task.md'); - }); - - it('finds description.md', () => { - writeFileSync(join(TEST_DIR, 'progress', 'my-feature', 'description.md'), '---\ntitle: Test\n---'); - expect(detectRootFile(join(TEST_DIR, 'progress', 'my-feature'))).toBe('description.md'); - }); - - it('finds ticket.md', () => { - writeFileSync(join(TEST_DIR, 'progress', 'my-feature', 'ticket.md'), '---\ntitle: Test\n---'); - expect(detectRootFile(join(TEST_DIR, 'progress', 'my-feature'))).toBe('ticket.md'); - }); - - it('returns null if no root file', () => { - expect(detectRootFile(join(TEST_DIR, 'progress', 'my-feature'))).toBeNull(); - }); -}); - -describe('readTaskFile', () => { - it('parses frontmatter and body', () => { - const content = '---\ntitle: My Feature\nstatus: backlog\npriority: normal\ncreatedAt: 2026-04-07T00:00:00Z\nupdatedAt: 2026-04-07T00:00:00Z\n---\nDescription body here.'; - writeFileSync(join(TEST_DIR, 'progress', 'my-feature', 'task.md'), content); - const result = readTaskFile(join(TEST_DIR, 'progress', 'my-feature'), 'task.md'); - expect(result.title).toBe('My Feature'); - expect(result.status).toBe('backlog'); - expect(result.description).toContain('Description body here'); - }); -}); - -describe('checkSubdirectories', () => { - it('detects research, plans, tasks presence', () => { - mkdirSync(join(TEST_DIR, 'progress', 'my-feature', 'research'), { recursive: true }); - writeFileSync(join(TEST_DIR, 'progress', 'my-feature', 'research', 'research.md'), '# Research'); - const result = checkSubdirectories(join(TEST_DIR, 'progress', 'my-feature')); - expect(result.hasResearch).toBe(true); - expect(result.hasPlan).toBe(false); - expect(result.hasTeamTasks).toBe(false); - }); -}); - -describe('scanProgressDir', () => { - it('returns slugs excluding archived', () => { - mkdirSync(join(TEST_DIR, 'progress', 'archived', 'old'), { recursive: true }); - writeFileSync(join(TEST_DIR, 'progress', 'my-feature', 'task.md'), '---\ntitle: Active\nstatus: backlog\npriority: normal\ncreatedAt: 2026-04-07T00:00:00Z\nupdatedAt: 2026-04-07T00:00:00Z\n---'); - const slugs = scanProgressDir(join(TEST_DIR, 'progress')); - expect(slugs).toContain('my-feature'); - expect(slugs).not.toContain('archived'); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run tests/unit/services/progress/task-file-io.test.ts` -Expected: FAIL (module not found) - -- [ ] **Step 3: Implement task-file-io.ts** - -```typescript -// src/main/services/progress/task-file-io.ts -import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import type { ProgressPriority, ProgressStatus } from '@shared/types/progress'; - -const ROOT_FILE_NAMES = ['task.md', 'description.md', 'ticket.md'] as const; - -// ── Frontmatter Parsing ───────────────────────────────── - -interface ParsedFrontmatter { - title: string; - description: string; - status: ProgressStatus; - priority: ProgressPriority; - jiraTicket?: string; - jiraUrl?: string; - prNumber?: number; - prUrl?: string; - prStatus?: string; - createdAt: string; - updatedAt: string; -} - -export function parseFrontmatter(content: string): { frontmatter: ParsedFrontmatter; body: string } { - const trimmed = content.trimStart(); - if (!trimmed.startsWith('---')) { - return { - frontmatter: { - title: '', description: '', status: 'backlog', priority: 'normal', - createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - }, - body: content, - }; - } - const endIndex = trimmed.indexOf('---', 3); - if (endIndex === -1) { - return { - frontmatter: { - title: '', description: '', status: 'backlog', priority: 'normal', - createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - }, - body: content, - }; - } - - const yamlBlock = trimmed.slice(3, endIndex).trim(); - const body = trimmed.slice(endIndex + 3).trim(); - const fields: Record = {}; - - for (const line of yamlBlock.split('\n')) { - const colonIdx = line.indexOf(':'); - if (colonIdx === -1) continue; - const key = line.slice(0, colonIdx).trim(); - let value = line.slice(colonIdx + 1).trim(); - // Strip surrounding quotes - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - fields[key] = value; - } - - return { - frontmatter: { - title: fields.title ?? '', - description: fields.description ?? '', - status: (fields.status as ProgressStatus) ?? 'backlog', - priority: (fields.priority as ProgressPriority) ?? 'normal', - jiraTicket: fields.jiraTicket, - jiraUrl: fields.jiraUrl, - prNumber: fields.prNumber ? Number(fields.prNumber) : undefined, - prUrl: fields.prUrl, - prStatus: fields.prStatus, - createdAt: fields.createdAt ?? new Date().toISOString(), - updatedAt: fields.updatedAt ?? new Date().toISOString(), - }, - body, - }; -} - -export function serializeFrontmatter(fields: Record, body: string): string { - const lines = ['---']; - for (const [key, value] of Object.entries(fields)) { - if (value === undefined || value === null) continue; - if (typeof value === 'string' && (value.includes(':') || value.includes('#'))) { - lines.push(`${key}: "${value}"`); - } else { - lines.push(`${key}: ${String(value)}`); - } - } - lines.push('---'); - if (body.length > 0) { - lines.push('', body); - } - return lines.join('\n'); -} - -// ── Directory Operations ──────────────────────────────── - -export function detectRootFile(taskDir: string): string | null { - for (const name of ROOT_FILE_NAMES) { - if (existsSync(join(taskDir, name))) { - return name; - } - } - return null; -} - -export interface SubdirectoryCheck { - hasResearch: boolean; - hasPlan: boolean; - hasTeamTasks: boolean; - teamTaskCount: number; -} - -export function checkSubdirectories(taskDir: string): SubdirectoryCheck { - const hasResearch = existsSync(join(taskDir, 'research', 'research.md')); - const hasPlan = existsSync(join(taskDir, 'plans', 'plan.md')); - - const tasksDir = join(taskDir, 'tasks'); - let hasTeamTasks = false; - let teamTaskCount = 0; - if (existsSync(tasksDir)) { - const files = readdirSync(tasksDir).filter((f) => /^task-\d+\.md$/.test(f)); - hasTeamTasks = files.length > 0; - teamTaskCount = files.length; - } - - return { hasResearch, hasPlan, hasTeamTasks, teamTaskCount }; -} - -export function readTaskFile(taskDir: string, rootFile: string): ParsedFrontmatter & { body: string } { - const content = readFileSync(join(taskDir, rootFile), 'utf-8'); - const { frontmatter, body } = parseFrontmatter(content); - return { ...frontmatter, body, description: body.length > 0 ? body : frontmatter.description }; -} - -export function scanProgressDir(progressDir: string): string[] { - if (!existsSync(progressDir)) return []; - return readdirSync(progressDir, { withFileTypes: true }) - .filter((d) => d.isDirectory() && d.name !== 'archived') - .map((d) => d.name); -} - -export function createTaskOnDisk( - progressDir: string, - slug: string, - title: string, - description: string, - priority: ProgressPriority = 'normal', -): void { - const taskDir = join(progressDir, slug); - mkdirSync(taskDir, { recursive: true }); - const now = new Date().toISOString(); - const content = serializeFrontmatter( - { title, description: '', status: 'backlog', priority, createdAt: now, updatedAt: now }, - description, - ); - writeFileSync(join(taskDir, 'task.md'), content, 'utf-8'); -} - -export function archiveTaskOnDisk(progressDir: string, slug: string): void { - const source = join(progressDir, slug); - const archiveDir = join(progressDir, 'archived'); - mkdirSync(archiveDir, { recursive: true }); - renameSync(source, join(archiveDir, slug)); -} - -export function deleteTaskOnDisk(progressDir: string, slug: string): void { - rmSync(join(progressDir, slug), { recursive: true, force: true }); -} - -export function updateTaskFrontmatter( - taskDir: string, - rootFile: string, - updates: Record, -): void { - const filePath = join(taskDir, rootFile); - const content = readFileSync(filePath, 'utf-8'); - const { frontmatter, body } = parseFrontmatter(content); - const merged = { ...frontmatter, ...updates, updatedAt: new Date().toISOString() }; - writeFileSync(filePath, serializeFrontmatter(merged, body), 'utf-8'); -} - -export function readFileContent(filePath: string): string | null { - if (!existsSync(filePath)) return null; - return readFileSync(filePath, 'utf-8'); -} -``` - -- [ ] **Step 4: Run test** - -Run: `npx vitest run tests/unit/services/progress/task-file-io.test.ts` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/main/services/progress/task-file-io.ts tests/unit/services/progress/ -git commit -m "feat(progress): task file I/O with frontmatter parsing" -``` - ---- - -### Task 5: Session Writer (filtered JSONL + summary) - -**Files:** -- Create: `src/main/services/progress/session-writer.ts` - -- [ ] **Step 1: Write the test** - -Create `tests/unit/services/progress/session-writer.test.ts` — tests that the writer filters stream events, appends JSONL, and updates summary files. - -- [ ] **Step 2: Run test to verify it fails** - -- [ ] **Step 3: Implement session-writer.ts** - -The session writer: -- `createSessionWriter(sessionDir, agentName)` — returns a writer object -- `writer.appendEvent(streamJsonEvent)` — filters and appends to `.jsonl` -- `writer.updateSummary(partialSummary)` — merges into `.summary.json` -- `writer.finalize(exitCode)` — writes final summary with duration, flushes - -Filter rules: discard `stream_event` deltas, truncate tool_result to 200 chars, truncate tool_use input to 500 chars. - -- [ ] **Step 4: Run test** - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/main/services/progress/session-writer.ts tests/unit/services/progress/session-writer.test.ts -git commit -m "feat(progress): filtered JSONL session writer with summary" -``` - ---- - -### Task 6: Log Cleanup - -**Files:** -- Create: `src/main/services/progress/log-cleanup.ts` - -- [ ] **Step 1: Write the test** - -Test that `runLogCleanup(progressDir, maxAgeDays)` deletes `.jsonl` files older than threshold but keeps `.summary.json` files. - -- [ ] **Step 2: Implement log-cleanup.ts** - -```typescript -// Scan progress/*/sessions/*.jsonl -// For each: check mtime, if older than maxAgeDays and corresponding summary exists → delete -// Return count of deleted files -``` - -- [ ] **Step 3: Run test, commit** - ---- - -## Wave 3: Progress Service + IPC Handlers (depends on Wave 2) - -### Task 7: Progress Service - -**Files:** -- Create: `src/main/services/progress/progress-service.ts` -- Create: `src/main/services/progress/index.ts` - -The orchestrator service that composes task-file-io, session-writer, and log-cleanup. Implements full CRUD, FS watching, action spawning (research/plan/team), and workflow state machine. - -- [ ] **Step 1: Write integration test** — tests createTask, listTasks, getTask, archiveTask against a temp directory - -- [ ] **Step 2: Implement createProgressService factory** - -Interface: -```typescript -export interface ProgressService { - listTasks(): ProgressTask[]; - getTask(slug: string): ProgressTask | null; - createTask(slug: string, title: string, description: string, priority?: ProgressPriority): ProgressTask; - updateTask(slug: string, updates: Record): ProgressTask; - archiveTask(slug: string): void; - deleteTask(slug: string): void; - listArchived(): ProgressTask[]; - startResearch(slug: string): { sessionId: string }; - createPlan(slug: string): { sessionId: string }; - spinUpTeam(slug: string): { sessionId: string; teamLeadIndex: number; action: 'spawned' | 'reused' }; - runWorkflow(slug: string): void; - cancelAction(slug: string): void; - runLogCleanup(): { deletedFiles: number }; - onTaskUpdated(listener: (slug: string, task: ProgressTask) => void): void; - getSessionWriter(slug: string, agentName: string): SessionWriter; - dispose(): void; -} -``` - -- [ ] **Step 3: Implement FS watcher** — watches `progress/` for new directories, file changes. Debounced 500ms. Emits `onTaskUpdated`. - -- [ ] **Step 4: Implement action spawning** — `startResearch`, `createPlan` use `AgentManagerService.spawnProjectOwner()`. `spinUpTeam` calls `WorkspaceSessionManager.handOffPlan()`. Each updates frontmatter status before spawning, listens for session end to reconcile. - -- [ ] **Step 5: Implement runWorkflow** — sequential state machine: research → plan → team. Listens for session end events to advance. - -- [ ] **Step 6: Create barrel** `src/main/services/progress/index.ts` - -- [ ] **Step 7: Run tests, commit** - ---- - -### Task 8: Progress IPC Handlers - -**Files:** -- Create: `src/main/ipc/handlers/progress-handlers.ts` -- Modify: `src/main/bootstrap/service-registry.ts` - -- [ ] **Step 1: Create thin handlers** — one `router.handle` per IPC channel, delegating to ProgressService - -- [ ] **Step 2: Wire into service registry** — create ProgressService in bootstrap, register handlers, wire session archiver to app `before-quit` - -- [ ] **Step 3: Run typecheck + lint + build** - -- [ ] **Step 4: Commit** - ---- - -## Wave 4: Renderer Stores (depends on Wave 3 IPC) - -Can run in parallel: Task 9 and Task 10. - -### Task 9: Progress Context Store + Hydrator - -**Files:** -- Create: `src/renderer/shared/stores/progress-context-store.ts` -- Create: `src/renderer/shared/stores/ProgressContextHydrator.tsx` -- Modify: `src/renderer/shared/stores/index.ts` -- Modify: `src/renderer/app/layouts/RootLayout.tsx` - -- [ ] **Step 1: Create useProgressContext store** — Zustand store with task list, actions calling IPC, active sessions map - -- [ ] **Step 2: Create ProgressContextHydrator** — React Query poll + IPC event invalidation, syncs into store - -- [ ] **Step 3: Export from barrel, mount in RootLayout** - -- [ ] **Step 4: Commit** - ---- - -### Task 10: Expand Agent Context Store - -**Files:** -- Modify: `src/renderer/shared/stores/agent-context-store.ts` -- Modify: `src/renderer/shared/stores/AgentContextHydrator.tsx` - -- [ ] **Step 1: Add agentSessions, recentMessages, recentToolCalls, errors, taskAgentMap** to the store - -- [ ] **Step 2: Add fetchGitDiff and fetchSessionLog actions** - -- [ ] **Step 3: Update hydrator** to sync expanded data from new IPC channels - -- [ ] **Step 4: Commit** - ---- - -## Wave 5: Grid + Expanded Row UI (depends on Wave 4 stores) - -These can run in parallel — each touches different files. - -### Task 11: Grid Column Definitions + Helper Components - -**Files:** -- Create: `src/renderer/features/tasks/components/grid/columns.tsx` -- Create: `src/renderer/features/tasks/components/grid/StageIndicator.tsx` -- Create: `src/renderer/features/tasks/components/grid/TicketBadge.tsx` -- Create: `src/renderer/features/tasks/components/grid/PrBadge.tsx` - -- [ ] **Step 1: Create StageIndicator** — three circles (research, plan, team), filled/empty based on booleans - -- [ ] **Step 2: Create TicketBadge** — clickable Jira key badge with external link - -- [ ] **Step 3: Create PrBadge** — PR number + status badge with external link - -- [ ] **Step 4: Create column definitions** — TanStack Table `ColumnDef[]` using the components above - -- [ ] **Step 5: Commit** - ---- - -### Task 12: Progress Task Grid - -**Files:** -- Create: `src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx` -- Create: `src/renderer/features/tasks/components/TaskToolbar.tsx` -- Modify: `src/renderer/features/tasks/components/TasksPage.tsx` - -- [ ] **Step 1: Create TaskToolbar** — status filter, search, New Task button, Run Workflow button - -- [ ] **Step 2: Create ProgressTaskGrid** — TanStack Table reading from `useProgressContext`, column definitions from Task 11, expandable rows - -- [ ] **Step 3: Swap TasksPage** to use ProgressTaskGrid - -- [ ] **Step 4: Run typecheck + lint** - -- [ ] **Step 5: Commit** - ---- - -### Task 13: Expanded Row — Pipeline Sections - -**Files:** -- Create: `src/renderer/features/tasks/components/detail/PipelineTopBar.tsx` -- Create: `src/renderer/features/tasks/components/detail/ResearchSection.tsx` -- Create: `src/renderer/features/tasks/components/detail/PlanSection.tsx` -- Create: `src/renderer/features/tasks/components/detail/ProgressTaskDetailRow.tsx` - -- [ ] **Step 1: Create PipelineTopBar** — Jira badge, PR badge, Run Workflow button, Archive button - -- [ ] **Step 2: Create ResearchSection** — conditional: button / spinner / rendered markdown - -- [ ] **Step 3: Create PlanSection** — conditional: button / spinner / rendered markdown + Spin Up Team button - -- [ ] **Step 4: Create ProgressTaskDetailRow** — composes TopBar + Research + Plan + Team sections - -- [ ] **Step 5: Commit** - ---- - -### Task 14: Team Activity Panel + Agent Detail - -**Files:** -- Create: `src/renderer/features/tasks/components/detail/TeamActivityPanel.tsx` -- Create: `src/renderer/features/tasks/components/detail/AgentDetailExpander.tsx` - -- [ ] **Step 1: Create TeamActivityPanel** — table of agents for this task from `useAgentContext.taskAgentMap`, columns: name, role, status, tokens, duration - -- [ ] **Step 2: Create AgentDetailExpander** — expandable per-agent: message log (AgentChatPanel reuse), tool calls list, errors, git diff viewer, session info - -- [ ] **Step 3: Wire into ProgressTaskDetailRow** — show TeamActivityPanel when status is `executing` - -- [ ] **Step 4: Commit** - ---- - -## Wave 6: Visualization Refactor + Agent Dashboard Extensions (depends on Wave 4) - -### Task 15: Agent Dashboard IPC Extensions - -**Files:** -- Modify: `src/shared/ipc/agent-dashboard/contract.ts` -- Modify: `src/main/services/agent-manager/agent-manager-service.ts` -- Modify: `src/main/ipc/handlers/agent-dashboard-handlers.ts` - -- [ ] **Step 1: Add new channels** — `getSessionDetail`, `getSessionLog` (paginated), `getSessionsForTask`, `getGitDiff` - -- [ ] **Step 2: Implement in AgentManagerService** — getSessionLog reads from JSONL on disk with offset/limit; getGitDiff runs `git diff` on the session's branch - -- [ ] **Step 3: Register handlers** - -- [ ] **Step 4: Commit** - ---- - -### Task 16: Visualization Agent Teams Refactor - -**Files:** -- Modify: `src/main/services/visualization/agent-teams.ts` - -- [ ] **Step 1: Refactor buildAgentTeamsData** to read from `progress/` instead of `tracking/` - -- [ ] **Step 2: Get live agent status** from AgentManagerService sessions instead of JSONL events - -- [ ] **Step 3: Map single-agent sessions** (research, planning) to single nodes - -- [ ] **Step 4: Commit** - ---- - -## Wave 7: Integration + Documentation - -### Task 17: Documentation + CLAUDE.md Updates - -**Files:** -- Modify: `docs/routing/FEATURES-INDEX.md` -- Modify: `CLAUDE.md` -- Modify: `.claude/agents/team-leader.md` - -- [ ] **Step 1: Add progress service** to FEATURES-INDEX - -- [ ] **Step 2: Add progress IPC channels** and agent naming convention to CLAUDE.md - -- [ ] **Step 3: Verify team-leader.md** has correct agent naming instructions - -- [ ] **Step 4: Commit** - ---- - -### Task 18: Final Verification - -- [ ] **Step 1: Run full verification suite** - -```bash -npm run lint && npm run typecheck && npm run test && npm run build -``` - -- [ ] **Step 2: Manual smoke test** — start app, check task list loads from `progress/`, create a task, verify expanded row renders - -- [ ] **Step 3: Commit any fixes** - ---- - -## Parallelism Summary - -``` -Wave 1: Tasks 1, 2, 3 (types + IPC contract) ── all parallel -Wave 2: Tasks 4, 5, 6 (file I/O, writer, cleanup) ── all parallel -Wave 3: Tasks 7, 8 (service + handlers) ── sequential (8 depends on 7) -Wave 4: Tasks 9, 10 (stores) ── parallel -Wave 5: Tasks 11, 12, 13, 14 (UI components) ── 11 parallel with 13,14; 12 depends on 11 -Wave 6: Tasks 15, 16 (agent dashboard + viz) ── parallel -Wave 7: Tasks 17, 18 (docs + verification) ── sequential -``` - -**Maximum parallelism per wave:** -- Wave 1: 3 agents -- Wave 2: 3 agents -- Wave 3: 1-2 agents -- Wave 4: 2 agents -- Wave 5: 3-4 agents -- Wave 6: 2 agents -- Wave 7: 1 agent - -**Total: 18 tasks across 7 waves.** diff --git a/docs/archive/task-pipeline-plan.md b/docs/archive/task-pipeline-plan.md deleted file mode 100644 index 240c4474..00000000 --- a/docs/archive/task-pipeline-plan.md +++ /dev/null @@ -1,656 +0,0 @@ -# Progress-Driven Task Pipeline - -> **NOTE:** This plan predates the caching layer consolidation. References to `useProgressContext`, `useAgentContext`, `ProgressContextHydrator`, `AgentContextHydrator`, `progress-context-store`, and `agent-context-store` are outdated -- these have been replaced by React Query hooks (`useProgress`, `useProgressMutations`, `useAgentMessages`) and `EventBridge`. See `docs/patterns/CACHING-LAYER-QUICKGUIDE.md`. - -> Local-first task management backed by `progress/` filesystem. Tasks flow through Research → Plan → Team execution with per-step agent sessions and a single "Run Workflow" button for end-to-end automation. Agent context store is the single source of truth for all live agent data — sessions, messages, tool calls, errors, token usage — accessible globally and archived to disk on app close. - ---- - -## 1. Data Model - -### Directory Structure - -Every task is a directory under `progress/`. Archived tasks move to `progress/archived/`. - -``` -progress/ -├── my-feature/ -│ ├── task.md ← root file (or description.md / ticket.md) -│ ├── research/ -│ │ └── research.md ← deep research output -│ ├── plans/ -│ │ └── plan.md ← implementation plan -│ └── tasks/ -│ ├── task-1.md ← team subtasks (agent workflow files) -│ └── task-2.md -├── fix-sidebar-bug/ -│ └── ticket.md -└── archived/ - └── old-feature/ - └── task.md -``` - -### Root File Frontmatter - -The service scans for any of `task.md`, `description.md`, `ticket.md` in the directory root. Same schema regardless of filename. - -```yaml ---- -title: "Implement feature X" -description: "Short description of the task" -status: backlog -priority: normal -jiraTicket: PROJ-1234 -jiraUrl: https://company.atlassian.net/browse/PROJ-1234 -prNumber: 89 -prUrl: https://github.com/owner/repo/pull/89 -prStatus: open -createdAt: 2026-04-07T00:00:00Z -updatedAt: 2026-04-07T00:00:00Z ---- - -Optional long-form description, acceptance criteria, notes, etc. -``` - -### Status Enum - -``` -backlog → researching → research_done → planning → plan_ready → executing → review → done → archived - ↘ error -``` - -Status is both stored in frontmatter AND inferred from directory contents: -- `research/research.md` exists → at least `research_done` -- `plans/plan.md` exists → at least `plan_ready` -- `tasks/task-*.md` exist → at least `executing` - -Frontmatter is the manual override. Directory structure is structural truth. The service reconciles on read — if the directory has more progress than the status says, it bumps the status. - -### Priority - -`low | normal | high | urgent` - -### Ticket & PR Fields - -| Field | Type | Description | -|-------|------|-------------| -| `jiraTicket` | string | Jira issue key (e.g. `PROJ-1234`) | -| `jiraUrl` | string | Full Jira URL | -| `prNumber` | number | GitHub PR number | -| `prUrl` | string | Full GitHub PR URL | -| `prStatus` | string | `draft | open | merged | closed` | - -Set manually or populated by team-lead when it creates a PR. No live Jira/GitHub API polling. - ---- - -## 2. Progress Service - -**Location:** `src/main/services/progress/progress-service.ts` - -Factory: `createProgressService(projectPath)` returning `ProgressService` interface. - -### Reads - -- Scans `progress/` on startup (excluding `archived/`) -- For each subdirectory: reads root md file, checks for `research/`, `plans/`, `tasks/` presence -- Builds `ProgressTask[]` -- Watches for FS changes (new directories, file writes) and emits update events -- Also reads `progress/archived/` for archive count - -### Writes - -| Method | Description | -|--------|-------------| -| `createTask(slug, title, description, priority?)` | Creates `progress//task.md` with frontmatter | -| `updateTask(slug, updates)` | Rewrites frontmatter fields in the root md file | -| `archiveTask(slug)` | Moves `progress//` → `progress/archived//` | -| `deleteTask(slug)` | Removes directory entirely | -| `listTasks()` | Returns all non-archived ProgressTask[] | -| `getTask(slug)` | Returns single ProgressTask with full content | -| `listArchived()` | Returns archived ProgressTask[] | - -### ProgressTask Type - -```typescript -interface ProgressTask { - slug: string; - rootFile: string; // 'task.md' | 'description.md' | 'ticket.md' - title: string; - description: string; - status: ProgressStatus; - priority: ProgressPriority; - jiraTicket?: string; - jiraUrl?: string; - prNumber?: number; - prUrl?: string; - prStatus?: string; - createdAt: string; - updatedAt: string; - - // Derived from directory contents - hasResearch: boolean; - hasPlan: boolean; - hasTeamTasks: boolean; - teamTaskCount: number; - - // Content (populated on getTask, not listTasks) - researchContent?: string; - planContent?: string; -} -``` - -### Actions (Agent Session Spawning) - -Each action spawns a dedicated agent session via `AgentManagerService.spawnProjectOwner()`. - -| Action | Prompt | Output | -|--------|--------|--------| -| `startResearch(slug)` | "Deep research on ``. Read existing files. Write comprehensive research doc to `progress/<slug>/research/research.md`." | Writes research.md, updates status to `research_done` | -| `createPlan(slug)` | "Read research at `progress/<slug>/research/research.md`. Create implementation plan at `progress/<slug>/plans/plan.md`." | Writes plan.md, updates status to `plan_ready` | -| `spinUpTeam(slug)` | Calls `workspace.handOffPlan(projectId, 'progress/<slug>/plans/plan.md')` | Team-lead decomposes into tasks/, status → `executing` | - -Each action: -1. Updates frontmatter status before spawning (e.g., `researching`) -2. Spawns a headless session -3. Tracks sessionId in the active sessions map -4. On session end (exit event), re-reads the directory to reconcile status - -### Run Workflow (End-to-End) - -`runWorkflow(slug)` — sequential pipeline: - -1. If no research: `startResearch(slug)`, wait for session end -2. If no plan: `createPlan(slug)`, wait for session end -3. If no team: `spinUpTeam(slug)` - -On any step failure (non-zero exit): stops, sets status to `error`, surfaces error. User can retry from the failed step or restart. - -While running, the service tracks which step is active and emits progress events. - ---- - -## 3. IPC Contract - -**Domain:** `progress` - -### Invoke Channels - -```typescript -'progress.listTasks' → ProgressTask[] -'progress.getTask' → ProgressTask | null // { slug } -'progress.createTask' → ProgressTask // { slug, title, description, priority? } -'progress.updateTask' → ProgressTask // { slug, updates } -'progress.archiveTask' → { success } // { slug } -'progress.deleteTask' → { success } // { slug } -'progress.listArchived' → ProgressTask[] - -'progress.startResearch' → { sessionId } // { slug } -'progress.createPlan' → { sessionId } // { slug } -'progress.spinUpTeam' → { sessionId, teamLeadIndex, action } // { slug } -'progress.runWorkflow' → { started: true } // { slug } -'progress.cancelAction' → { success } // { slug } -``` - -### Event Channels - -```typescript -'event:progress.taskUpdated' → { slug, task: ProgressTask } -'event:progress.taskCreated' → { slug, task: ProgressTask } -'event:progress.taskArchived' → { slug } -'event:progress.actionStarted' → { slug, action: 'research' | 'plan' | 'team', sessionId } -'event:progress.actionCompleted' → { slug, action: 'research' | 'plan' | 'team' } -'event:progress.actionFailed' → { slug, action: 'research' | 'plan' | 'team', error } -'event:progress.workflowStep' → { slug, step: 'research' | 'plan' | 'team', status: 'started' | 'completed' | 'failed' } -``` - ---- - -## 4. Agent Context Store — Full Data Layer - -### Single Source of Truth - -`useAgentContext` (already created at `src/renderer/shared/stores/agent-context-store.ts`) becomes the **single source of truth** for all live agent data in the renderer. Every component reads from this store — no direct IPC calls for agent data. - -### Expanded Store Shape - -```typescript -interface AgentContextState { - // ── Live Sessions ────────────────────────────────────── - sessions: WorkspaceSession[]; // primary + team-leads - agentSessions: AgentSessionDetail[]; // ALL agent sessions (summaries only — from disk) - isLoading: boolean; - lastError: string | null; - - // ── Rolling Window (in-memory, last N per session) ───── - recentMessages: Map<string, AgentChatMessage[]>; // sessionId → last 50 messages - recentToolCalls: Map<string, ToolCallSummary[]>; // sessionId → last 20 tool calls - errors: Map<string, AgentError[]>; // sessionId → all errors (typically few) - - // ── Task ↔ Agent Mapping ─────────────────────────────── - taskAgentMap: Map<string, string[]>; // slug → sessionIds - - // ── Actions ──────────────────────────────────────────── - handOffPlan(...): Promise<HandoffResult>; - executeTask(...): Promise<HandoffResult>; - sendMessage(sessionId, message): Promise<boolean>; - fetchGitDiff(sessionId): Promise<string>; // on-demand, not cached - fetchSessionLog(sessionId, offset, limit): Promise<AgentChatMessage[]>; // paginated from disk - // ... existing actions -} -``` - -Token usage and session metadata come from the summary files, not from in-memory maps. Git diffs are computed on demand and never stored. - -### Agent Session Detail - -```typescript -interface AgentSessionDetail { - sessionId: string; - name: string; // meaningful name: "{agentRole}-{taskSlug}" e.g. "service-engineer-auth-refactor" - role: string; // from task-*.md agentRole field - taskSlug: string; // which progress task this agent works on - taskNumber: number | null; // from task-*.md - status: AgentStatus; // running | idle | completed | failed - branch: string | null; // git branch this agent works on - model: string; - tokenUsage: AgentTokenUsage; - startedAt: string; - lastActivityAt: string; - exitCode: number | null; - isTeamLead: boolean; -} -``` - -### Agent Naming Convention - -Agent names MUST be descriptive. The team-lead assigns names based on role + task: - -| Pattern | Example | -|---------|---------| -| Research session | `research-{slug}` → `research-auth-refactor` | -| Planning session | `planning-{slug}` → `planning-auth-refactor` | -| Team-lead | `team-lead-{slug}` → `team-lead-auth-refactor` | -| Teammate | `{agentRole}-{taskSlug}` → `service-engineer-auth-service` | - -The team-leader.md agent definition instructs the team-lead to use these names when calling `Agent(name: "service-engineer-auth-service", team_name: "auth-refactor", ...)`. - -### Data Flow - -``` -Main Process (AgentManagerService) - │ holds all live data: sessions, messages, tool calls, usage, errors - │ captures from stream-json parser per session - │ - ├─ IPC events → AgentContextHydrator (renderer) - │ │ - │ └─ syncs into useAgentContext (Zustand store) - │ │ - │ ├─ Task list expanded row reads agent data - │ ├─ Workspace panels read session data - │ ├─ Visualization reads agent data - │ └─ Assistant reads session data - │ - └─ On app close → serialize to disk - │ - └─ progress/<slug>/sessions/ - ├─ research-session.json (archived messages, usage, errors) - ├─ planning-session.json - └─ teammates/ - ├─ service-engineer.json - └─ component-engineer.json -``` - -### Disk-First Persistence (Continuous Append) - -Sessions can run for days or weeks. Holding full history in memory would bloat the store and degrade renderer performance. Instead: **append to disk continuously, keep a rolling window in memory.** - -#### Write Path (Main Process) - -As `AgentManagerService` receives stream-json events: - -1. **Append-only JSONL** — each message, tool call, and error is appended to `progress/<slug>/sessions/<agent-name>.jsonl` as it arrives. No batching, no buffering — one line per event. This is the persistent log. - -2. **Summary file** — `progress/<slug>/sessions/<agent-name>.summary.json` is updated periodically (every 30s or on session status change) with: - - Session metadata (name, role, status, model, branch) - - Token usage totals - - Error count - - Last activity timestamp - - Message count - - Exit code (when done) - -#### Read Path (Renderer) - -The `useAgentContext` store holds **only the last N messages/events per session** (default N=50). This is the rolling window. - -- The summary file provides the overview data (tokens, errors, status) without loading full history -- When the user expands an agent's detail view, the UI requests the full JSONL log on demand via IPC (`agent-dashboard.getSessionLog`) with pagination (offset + limit) -- The store never holds the full history — it pages from disk - -#### Memory Limits - -| Data | In-Memory | On Disk | -|------|-----------|---------| -| Messages per session | Last 50 | Full JSONL (unlimited) | -| Tool calls per session | Last 20 | Full JSONL | -| Errors per session | All (typically few) | Full JSONL | -| Token usage | Running totals only | Summary file | -| Git diffs | Fetched on demand, not cached | Not persisted (computed from git) | - -#### Lifecycle - -- **Session start**: create JSONL file + summary file -- **During session**: append events to JSONL, update summary periodically -- **Session end**: finalize summary with exit code + duration, flush any pending writes -- **Task archived**: session files move with the task directory to `progress/archived/` -- **App restart**: `ProgressService` reads summary files for the task list; full logs loaded on demand - -#### Write Filter (On Ingest) - -Not every stream-json event is worth persisting. The session writer applies a filter before appending to JSONL: - -| Event Type | Keep | Why | -|------------|------|-----| -| `assistant` text messages | Full text | Core output, needed for review | -| `user` messages | Full text | Context for the conversation | -| `tool_use` blocks | Name + input summary (first 500 chars) + success/fail | Tool analytics without storing huge inputs | -| `tool_result` blocks | Status + truncated output (first 200 chars) | Know what happened without full file contents | -| `stream_event` deltas | **Discard** | Partial tokens — the final `assistant` message has the complete text | -| `system` init | Model, session_id only | Metadata | -| `result` usage | Full token counts | Analytics | -| Errors | Full error message + stack | Debugging | - -This filter runs on the write path in the main process. Raw stream-json never hits disk — only the filtered version. This alone cuts log size by ~60-70% since streaming deltas are the bulk. - -#### Summary Metrics (Analytics-Ready) - -The summary file (`<agent-name>.summary.json`) accumulates metrics designed for later analysis: - -```typescript -interface SessionSummary { - // ── Identity ───────────────────────────── - sessionId: string; - agentName: string; - agentRole: string; - taskSlug: string; - model: string; - provider: 'claude' | 'local' | string; // for future LLM comparison - - // ── Timing ─────────────────────────────── - startedAt: string; - endedAt: string | null; - durationMs: number; - - // ── Token Usage ────────────────────────── - inputTokens: number; - outputTokens: number; - totalTokens: number; - costUsd: number; // estimated from model pricing - - // ── Efficiency ─────────────────────────── - toolCallCount: number; - toolCallsByName: Record<string, number>; // { "Edit": 14, "Read": 32, ... } - errorCount: number; - messageCount: number; - filesChanged: number; - - // ── Outcome ────────────────────────────── - status: 'completed' | 'failed' | 'killed'; - exitCode: number | null; -} -``` - -These summaries survive log cleanup and serve as the permanent analytical record per agent session. - -#### Weekly Log Cleanup - -A cleanup function runs on a 7-day schedule (configurable). It processes `progress/*/sessions/*.jsonl` files: - -1. **For completed sessions older than 7 days:** - - Delete the JSONL file (full message log) - - Keep the `.summary.json` file (analytics record) — this is permanent - - The summary has everything needed for reports, charts, comparisons - -2. **For archived tasks:** - - Same treatment — summaries kept, JSONL deleted after 7 days - - Summary files are small (~1KB each) and never cleaned - -3. **What survives cleanup permanently:** - - `*.summary.json` — agent name, role, task, model, provider, timing, tokens, cost, tool counts, error count, outcome - - `task.md` / `description.md` / `ticket.md` — task metadata - - `research/research.md` — research output - - `plans/plan.md` — implementation plan - -This means after cleanup, you can still answer: "How many tokens did the service-engineer use on the auth-refactor task?", "What's the average cost per agent across all tasks?", "How does claude-opus compare to local-llama on similar tasks?" — all from the summary files. You just can't replay the full conversation. - -The cleanup is implemented in `ProgressService` as `runLogCleanup()`, triggered by a `setInterval` on app start (check daily, clean if 7+ days old). Also callable manually via IPC for immediate cleanup. - -### IPC for Agent Data - -New channels on `agent-dashboard` domain (extends existing): - -```typescript -'agent-dashboard.getSessionDetail' → AgentSessionDetail // { sessionId } -'agent-dashboard.getSessionMessages' → AgentChatMessage[] // { sessionId } -'agent-dashboard.getSessionToolCalls' → ToolCallSummary[] // { sessionId } -'agent-dashboard.getSessionErrors' → AgentError[] // { sessionId } -'agent-dashboard.getGitDiff' → string // { sessionId } -'agent-dashboard.getSessionsForTask' → AgentSessionDetail[] // { slug } -``` - -These are consumed by the `AgentContextHydrator` — individual components do NOT call these directly. - ---- - -## 5. Global Store (`useProgressContext`) - -**Location:** `src/renderer/shared/stores/progress-context-store.ts` - -Zustand store, hydrated by `ProgressContextHydrator` in root layout. - -```typescript -interface ProgressContextState { - tasks: ProgressTask[]; - archivedCount: number; - activeSessions: Map<string, { sessionId: string; action: string }>; // slug → active action - isLoading: boolean; - - // Actions (call IPC) - createTask(slug: string, title: string, description: string, priority?: string): Promise<ProgressTask>; - updateTask(slug: string, updates: Partial<ProgressTask>): Promise<void>; - archiveTask(slug: string): Promise<void>; - startResearch(slug: string): Promise<void>; - createPlan(slug: string): Promise<void>; - spinUpTeam(slug: string): Promise<void>; - runWorkflow(slug: string): Promise<void>; - cancelAction(slug: string): Promise<void>; -} -``` - -Available globally via `useProgressContext()` — any component, any page, assistant widget, etc. - ---- - -## 5. Task List Grid - -Rewire `ProgressTaskGrid` to read from `useProgressContext` instead of Hub tasks. - -### Columns - -| # | Column | Source | Width | Sortable | -|---|--------|--------|-------|----------| -| 1 | Expand | toggle | 40px | no | -| 2 | Status | `status` | 120px | yes | -| 3 | Title | `title` | flex | yes | -| 4 | Priority | `priority` | 90px | yes | -| 5 | Stage | derived from `hasResearch`, `hasPlan`, `hasTeamTasks` | 160px | no | -| 6 | Ticket | `jiraTicket` → clickable badge | 110px | no | -| 7 | PR | `prNumber` + `prStatus` → clickable badge | 100px | no | -| 8 | Updated | `updatedAt` | 110px | yes | - -**Stage column** — three small step indicators: -- Research: filled circle if `hasResearch`, empty if not -- Plan: filled if `hasPlan`, empty if not -- Team: filled if `hasTeamTasks`, empty if not - -Visual pipeline indicator showing how far the task has progressed. - -### Toolbar - -- Status filter (multi-select dropdown) -- Search (title + description) -- "New Task" button (creates `progress/<slug>/task.md`) -- "Run Workflow" button (disabled unless a single task is selected) - ---- - -## 6. Expanded Row - -When a row is expanded, shows a vertical pipeline view: - -### Top Bar -- Jira badge + link (if set, else "Link Ticket" button) -- PR badge + link + status (if set, else "Link PR" button) -- **"Run Workflow"** button (kicks off full pipeline from current step) -- **"Archive"** button - -### Research Section -- **If no research:** "Deep Research" button -- **If researching:** spinner + "Researching..." with session link -- **If research done:** rendered markdown of `research/research.md` (collapsible, default collapsed showing first ~200 chars) - -### Plan Section -- **If no plan (and research done):** "Create Plan" button -- **If planning:** spinner + "Creating plan..." -- **If plan ready:** rendered markdown of `plans/plan.md` (collapsible) + "Spin Up Team" button - -### Team Section -- **If no team (and plan ready):** "Spin Up Team" button -- **If executing:** Team Activity panel (see below) -- **If done/review:** completion summary with final stats - -### Team Activity Panel (executing state) - -Reads from `useAgentContext` joined with `useProgressContext`: - -**Agent List** — table of all agents for this task: - -| Column | Source | -|--------|--------| -| Name | `AgentSessionDetail.name` (e.g., `service-engineer-auth-service`) | -| Role | `AgentSessionDetail.role` | -| Status | `AgentSessionDetail.status` badge (running/idle/completed/failed) | -| Tokens | `AgentSessionDetail.tokenUsage` (input + output) | -| Duration | derived from `startedAt` → `lastActivityAt` | - -**Per-Agent Expandable Detail** — click an agent row to expand: - -- **Message Log** — scrollable chat view from `useAgentContext.messages.get(sessionId)`, reuses `AgentChatPanel` -- **Tool Calls** — list of tool calls with name, truncated input, success/fail badge -- **Errors** — red-highlighted error entries with timestamps -- **Git Diff** — on-demand diff viewer (`fetchGitDiff(sessionId)`) showing changes on the agent's branch -- **Session Info** — model, branch, exit code, total tokens, duration - -### Error State -If any step failed: red banner with error message + "Retry" button for that step. - ---- - -## 7. Visualization Integration - -The Agents view in the visualization feature currently reads from `tracking/` (legacy plugin system). Refactor to read from `progress/` + live agent sessions. - -### Data Sources (New) - -| Source | What it provides | -|--------|-----------------| -| `progress/` directory scan | Feature list, task structure, status, file scope from task-*.md | -| `AgentManagerService.listSessions()` | Live session status (running/idle/completed) for research, plan, and team agents | -| `useAgentContext.sessions` | Workspace sessions (primary, team-leads) | -| `useProgressContext.activeSessions` | Which tasks have active actions (research/plan/team) | - -### What Changes - -**`src/main/services/visualization/agent-teams.ts`** — `buildAgentTeamsData()`: -- Reads features from `progress/` (non-archived directories) instead of `tracking/index.json` -- Reads agent task files from `progress/<slug>/tasks/task-*.md` (already does this) -- Gets live agent status from `AgentManagerService` session data instead of JSONL event parsing -- Single-agent sessions (research, planning) show as single nodes in the feature group -- Team execution shows the full agent tree (team-lead + teammates) - -### Node Mapping - -| Progress Stage | Visualization Node | -|---------------|-------------------| -| `researching` | Single AgentTask node: "Research Agent" (active) | -| `planning` | Single AgentTask node: "Planning Agent" (active) | -| `executing` | FeatureGroup with AgentTask children from task-*.md | -| `research_done` / `plan_ready` | Single completed node (dimmed) | -| `done` | FeatureGroup with all agents completed | - -### Removed Dependency - -- No longer reads `tracking/` directory at all -- No longer parses agent JSONL event files for status derivation -- Status comes from live `AgentManagerService` sessions + progress task status - ---- - -## 8. Files to Create / Modify - -### New Files -- `src/shared/types/progress.ts` — ProgressTask, ProgressStatus, ProgressPriority types -- `src/shared/types/agent-session-detail.ts` — AgentSessionDetail, ToolCallSummary, AgentError types -- `src/shared/ipc/progress/schemas.ts` — Zod schemas -- `src/shared/ipc/progress/contract.ts` — invoke + event channels -- `src/shared/ipc/progress/index.ts` — barrel -- `src/main/services/progress/progress-service.ts` — FS scanner, watcher, CRUD, actions -- `src/main/services/progress/task-file-io.ts` — read/write frontmatter md files -- `src/main/services/progress/session-archiver.ts` — serialize agent session data to `progress/<slug>/sessions/` on close/done -- `src/main/services/progress/index.ts` — barrel -- `src/main/ipc/handlers/progress-handlers.ts` — thin IPC handlers -- `src/renderer/shared/stores/progress-context-store.ts` — global Zustand store -- `src/renderer/shared/stores/ProgressContextHydrator.tsx` — sync component -- `src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx` — new grid reading from progress store -- `src/renderer/features/tasks/components/detail/ProgressTaskDetailRow.tsx` — new expanded row with pipeline UI -- `src/renderer/features/tasks/components/detail/TeamActivityPanel.tsx` — per-agent list + expandable detail (logs, tools, errors, diff) -- `src/renderer/features/tasks/components/detail/AgentDetailExpander.tsx` — single agent expandable row (messages, tool calls, errors, git diff) - -### Modified Files -- `src/shared/ipc/index.ts` — add progress contract to merged contracts -- `src/shared/ipc/agent-dashboard/contract.ts` — add per-session detail/messages/toolcalls/errors/diff channels -- `src/main/services/agent-manager/agent-manager-service.ts` — add methods for session detail, tool call log, error log, git diff -- `src/main/ipc/handlers/agent-dashboard-handlers.ts` — register new per-session data handlers -- `src/main/bootstrap/service-registry.ts` — create + wire progress service, wire session archiver to app lifecycle -- `src/main/ipc/index.ts` — register progress handlers (if separate from service-registry) -- `src/main/services/visualization/agent-teams.ts` — refactor to read from progress/ + AgentManagerService instead of tracking/ -- `src/renderer/shared/stores/agent-context-store.ts` — expand to full data layer (messages, tool calls, errors, git diffs, task-agent mapping) -- `src/renderer/shared/stores/AgentContextHydrator.tsx` — hydrate expanded agent data, handle session archive events -- `src/renderer/shared/stores/index.ts` — export new store + hydrator -- `src/renderer/app/layouts/RootLayout.tsx` — mount ProgressContextHydrator -- `src/renderer/features/visualization/api/visualization-api.ts` — update if data shape changes -- `src/renderer/features/tasks/components/TasksPage.tsx` — swap grid to ProgressTaskGrid -- `src/renderer/features/workspace/components/TeamLeadPanel.tsx` — ensure agent names propagate to context -- `.claude/agents/team-leader.md` — document agent naming convention requirement -- `docs/routing/FEATURES-INDEX.md` — add progress service entry -- `CLAUDE.md` — document progress IPC channels and agent naming convention - ---- - -## 8. What's NOT In Scope - -- Hub sync / mirror — local only for now -- Jira API live polling — manual metadata only -- GitHub PR status polling — manual metadata only -- Small task / PR review / bug fix workflows — team-only for now -- Agent pause/resume (Windows limitation) - -## 10. Deprecations - -- `tracking/` directory — no longer used for agent visualization. All data comes from `progress/` + live session state -- `ProgressWatcherV2` (progress-watcher-v2 service) — replaced by the new `ProgressService` which covers both task CRUD and file watching -- `hub.tasks.*` IPC channels — task list grid reads from `progress.*` channels instead. Hub task channels remain for backward compat but are not the primary data source - -## 11. Reserved / Hidden (Not In Use) - -- `workspace.provisionTeammate` / `workspace.teardownTeammate` IPC channels — reserved for future use with non-Claude LLM providers or local models where the harness (not the LLM) manages teammate process spawning. Currently hidden from UI. Claude Code's Experimental Agent Teams handle teammate spawning natively via the `Agent` tool with `team_name` parameter. These channels exist so the harness can orchestrate teammates directly if the provider doesn't support agent teams. diff --git a/docs/cleanup/codebase-cleanup-plan.md b/docs/cleanup/codebase-cleanup-plan.md new file mode 100644 index 00000000..35c9f980 --- /dev/null +++ b/docs/cleanup/codebase-cleanup-plan.md @@ -0,0 +1,442 @@ +# Codebase Cleanup — Parallel Execution Plan + +> Task: `codebase-cleanup` | Branch: `feature/codebase-cleanup` +> Generated: 2026-04-20 | Research: `docs/cleanup/item-{1,2,3,4}-*-research.md` + +## Execution Strategy + +3 waves, no worktrees. Agents within a wave touch **zero overlapping files**. +Wave 1 must complete before Wave 2 starts (infrastructure deps). +Wave 2 and Wave 3 are independent — can run concurrently after Wave 1. + +--- + +## Wave 1 — Infrastructure (3 agents, parallel) + +These agents create the foundation that Wave 2/3 agents depend on. + +### Agent 1A: Theme Store + CSS Tokens + +| Field | Value | +|-------|-------| +| **agent_definition** | `.claude/agents/store-engineer.md` | +| **agent_role** | Store Engineer | +| **scope** | Theme store extensions for spacing + icon shape | + +**Files owned (exclusive):** +- `src/renderer/shared/stores/theme-store.ts` — add `layoutGap: number`, `setLayoutGap()`, `applyLayoutGap()`, `iconButtonShape: IconButtonShape`, `setIconButtonShape()` +- `src/renderer/styles/globals.css` — add `--layout-gap`, `--layout-gap-sm`, `--layout-gap-lg`, `--layout-pad-x`, `--layout-pad-y` to `:root`, add `--spacing-layout` to `@theme` block, add `--btn-radius` CSS custom property +- `src/shared/ipc/settings/schemas.ts` — add `layoutGap: z.number().optional()`, `iconButtonShape: z.enum(['rounded','square','pill']).optional()` to settings schema + +**Acceptance criteria:** +- `setLayoutGap(8)` applies `--layout-gap: 0.5rem` to `<html>` + all derived props +- `setIconButtonShape('pill')` applies `--btn-radius: 9999px` to `<html>` +- Settings schema accepts both new fields +- Typecheck passes + +--- + +### Agent 1B: Chat UX Message Flow Fix + +| Field | Value | +|-------|-------| +| **agent_definition** | `.claude/agents/hook-engineer.md` | +| **agent_role** | Hook Engineer | +| **scope** | Fix optimistic user messages, thinking state, assistant message decoupling | + +**Files owned (exclusive):** +- `src/renderer/features/workspace/api/useWorkspace.ts` — add `onMutate` to `useWorkspaceSend()` that optimistically inserts user message into React Query cache +- `src/renderer/features/workspace/hooks/useSessionThinking.ts` — no changes needed (panels will combine `send.isPending || isThinking`) +- `src/renderer/features/workspace/components/PrimarySessionPanel.tsx` — derive `showThinking = send.isPending || isThinking` +- `src/renderer/features/workspace/components/TeamLeadPanel.tsx` — same `showThinking` derivation +- `src/renderer/features/assistant/store.ts` — refactor `ResponseEntry` to `ChatEntry` union type (`{ kind: 'user' } | { kind: 'response' }`) +- `src/renderer/features/assistant/api/useAssistant.ts` — add `addUserEntry()` in `onMutate` of `useSendCommand()` +- `src/renderer/features/assistant/hooks/useAssistantEvents.ts` — remove `_lastCommand` module variable, only add response entries (no input coupling) +- `src/renderer/features/assistant/components/WidgetMessageArea.tsx` — render `ChatEntry[]` with separate user/response bubbles +- `src/renderer/features/assistant/components/ResponseStream.tsx` — render `ChatEntry[]` + +**Acceptance criteria:** +- User message appears instantly in workspace chat on send +- ThinkingIndicator shows immediately on send (not waiting for stream event) +- Assistant user messages render independently of Claude responses +- No message duplication on continued conversation +- Typecheck passes + +--- + +### Agent 1C: Settings Persistence Wiring + +| Field | Value | +|-------|-------| +| **agent_definition** | `.claude/agents/service-engineer.md` | +| **agent_role** | Service Engineer | +| **scope** | Persist new settings fields (layoutGap, iconButtonShape) in main process | + +**Files owned (exclusive):** +- `src/main/features/settings/settings-service.ts` — accept `layoutGap` and `iconButtonShape` in update handler, persist to settings store +- `src/main/features/settings/schema.ts` — add columns/fields if needed +- `src/renderer/features/settings/api/useSettings.ts` — restore `setLayoutGap()` and `setIconButtonShape()` from loaded settings in queryFn + +**Acceptance criteria:** +- `layoutGap` and `iconButtonShape` round-trip through IPC (save + load) +- Default values applied on fresh install +- Typecheck passes + +--- + +## Wave 2 — UI Application (4 agents, parallel, after Wave 1) + +### Agent 2A: Spacing Slider + Layout Gap Application + +| Field | Value | +|-------|-------| +| **agent_definition** | `.claude/agents/component-engineer.md` | +| **agent_role** | Component Engineer | +| **scope** | Settings UI slider + convert layout components to use spacing tokens | + +**Files owned (exclusive):** +- `src/renderer/features/settings/components/SpacingSection.tsx` — **new file**, slider component (clone UiScaleSection pattern), range 0-16 step 2, labels: Flush/Compact/Default/Relaxed/Spacious +- `src/renderer/features/settings/components/SettingsPage.tsx` — wire SpacingSection into Display tab next to UiScaleSection +- `src/renderer/shared/components/ui/page-layout.tsx` — convert `px-6 py-4` to `px-[var(--layout-pad-x)] py-[var(--layout-pad-y)]` +- `src/renderer/shared/components/ui/composition/FilterBar.tsx` — convert `gap-3 px-4 py-3` to layout gap tokens +- `src/renderer/shared/components/ui/composition/ActionBar.tsx` — convert `gap-2 px-4 py-3` to layout gap tokens +- `src/renderer/shared/components/ui/composition/DetailPanel.tsx` — convert padding to layout gap tokens +- `src/renderer/app/layouts/ContentAreaContainer.tsx` — convert `p-2 gap-2` / `p-3 gap-3` to layout gap tokens +- `src/renderer/app/layouts/ContentHeader.tsx` — convert `gap-2 px-3` to layout gap tokens + +**Acceptance criteria:** +- Spacing slider renders in Settings > Display, value persists across restart +- Moving slider visibly changes gaps between all major layout regions +- Default (8) matches current appearance exactly +- Typecheck passes + +--- + +### Agent 2B: Icon Button Standardization + +| Field | Value | +|-------|-------| +| **agent_definition** | `.claude/agents/styling-engineer.md` | +| **agent_role** | Styling Engineer | +| **scope** | Fix raw icon buttons, add shape-awareness to Button CVA | + +**Files owned (exclusive):** +- `src/renderer/shared/components/ui/button.tsx` — replace hardcoded `rounded-md` in CVA base with `rounded-[var(--btn-radius,0.375rem)]`, update `icon-sm`/`icon-xs` similarly +- `src/renderer/shared/components/ui/icon.tsx` — no changes expected +- `src/renderer/shared/components/ui/search-input.tsx` — replace raw `<button>` clear with `Button size="icon-xs"` +- `src/renderer/shared/components/ui/sidebar.tsx` — replace raw SidebarRail `<button>` with `Button` +- `src/renderer/app/layouts/ProjectTabBar.tsx` — replace raw add-project `<button>` with `Button size="icon-xs"` +- `src/renderer/shared/components/WebhookNotification.tsx` — replace raw dismiss `<button>` with `Button size="icon-xs" variant="ghost"` +- `src/renderer/shared/components/HubNotification.tsx` — same dismiss fix +- `src/renderer/shared/components/MutationErrorToast.tsx` — same dismiss fix +- `src/renderer/shared/components/AppUpdateNotification.tsx` — replace raw dismiss + action buttons with `Button` +- `src/renderer/shared/components/AuthNotification.tsx` — replace raw dismiss + action buttons with `Button` +- `src/renderer/shared/components/ConfirmDialog.tsx` — replace raw cancel/confirm `<button>` with `Button` +- `src/renderer/shared/components/IntegrationRequired.tsx` — replace raw `<button>` with `Button` +- `src/renderer/app/router.tsx` — replace error page raw `<button>` with `Button` + +**NOT touching** (defensible — crash-safety): +- `error-boundaries/FeatureErrorBoundary.tsx` +- `error-boundaries/RouteErrorBoundary.tsx` +- `error-boundaries/RootErrorBoundary.tsx` + +**Acceptance criteria:** +- Zero raw `<button>` for icon-only buttons outside error boundaries +- All icon buttons respect `--btn-radius` (changing shape in settings changes hover shape) +- Typecheck passes + +--- + +### Agent 2C: Icon Shape Settings UI + +| Field | Value | +|-------|-------| +| **agent_definition** | `.claude/agents/component-engineer.md` | +| **agent_role** | Component Engineer | +| **scope** | Add icon button shape selector to Settings > Display | + +**Files owned (exclusive):** +- `src/renderer/features/settings/components/LayoutSection.tsx` — add IconButtonShape selector (rounded / square / pill) below existing toolbar style selector, wire to `setIconButtonShape()` + `updateSettings.mutate()` + +**Acceptance criteria:** +- Shape selector renders in Settings > Display > Layout section +- Changing shape immediately updates all icon button hover states +- Preference persists across restart +- Typecheck passes + +--- + +## Wave 3 — Component Folder Refactors (10 agents, parallel, after Wave 1) + +All agents use the same pattern per component: +1. Create `ComponentName/` directory inside `components/` +2. Create `use{ComponentName}.ts` — extract ALL hooks, state, handlers, derived state +3. Refactor `ComponentName.tsx` — import hook, pure render only +4. Create `index.ts` — `export { ComponentName } from './ComponentName'` +5. Update parent barrel `components/index.ts` if it exists +6. Verify no broken imports + +Each agent gets ~16 components across 3-4 non-overlapping domains. + +| Field | Value | +|-------|-------| +| **agent_definition** | `.claude/agents/component-engineer.md` + `.claude/agents/hook-engineer.md` (dual role) | +| **agent_role** | Component + Hook Engineer | + +--- + +### Agent 3A: agent-dashboard + agents + +**Domains:** agent-dashboard (17 violations), agents (1 violation) +**Component count:** 18 + +| Component | Lines | Action | +|-----------|-------|--------| +| AgentDashboardPage | 603 | folder + useAgentDashboardPage.ts | +| TemplateEditorPanel | 547 | folder + useTemplateEditorPanel.ts | +| TemplateListPanel | 317 | folder + useTemplateListPanel.ts | +| AgentPanelPopup | 295 | folder (pure render) | +| ToolCallCard | 264 | folder (minimal hook) | +| TextMessage | 261 | folder + useTextMessage.ts | +| AgentPanelExpanded | 215 | folder (pure render) | +| RunningWorkflowsPanel | 194 | folder + useRunningWorkflowsPanel.ts | +| QaPanel | 172 | folder + useQaPanel.ts | +| TasksTab | 166 | folder + useTasksTab.ts | +| AgentLayoutToolbar | 134 | folder | +| AgentPanelTabs | 117 | folder | +| AgentStatusBar | 104 | folder | +| AgentPanelCompact | 102 | folder | +| AgentLayoutSingle | 100 | folder | +| AgentChatPanel | 91 | folder + useAgentChatPanel.ts | +| AgentLayoutGrid | 86 | folder | +| AgentDashboard (agents) | 107 | folder + useAgentDashboard.ts | + +--- + +### Agent 3B: alerts + auth + briefing + +**Domains:** alerts (5), auth (3), briefing (3) +**Component count:** 11 + +| Component | Lines | Action | +|-----------|-------|--------| +| AlertsPage | 315 | folder + useAlertsPage.ts | +| AlertEditDialog | 232 | folder + useAlertEditDialog.ts | +| CreateAlertModal | 228 | folder + useCreateAlertModal.ts | +| RecurringAlerts | 82 | folder + useRecurringAlerts.ts | +| AlertNotification | 55 | folder + useAlertNotification.ts | +| LoginPage | 262 | folder + useLoginPage.ts | +| RegisterPage | 188 | folder + useRegisterPage.ts | +| AuthGuard | 57 | folder + useAuthGuard.ts | +| BriefingPage | 233 | folder + useBriefingPage.ts | +| BriefingConfigPanel | 173 | folder + useBriefingConfigPanel.ts | +| SuggestionCard | 83 | folder + useSuggestionCard.ts | + +--- + +### Agent 3C: changelog + dashboard + diff-viewer + +**Domains:** changelog (3), dashboard (7), diff-viewer (2) +**Component count:** 12 + +| Component | Lines | Action | +|-----------|-------|--------| +| EditEntryDialog | 277 | folder + useEditEntryDialog.ts | +| ChangelogPage | 180 | folder + useChangelogPage.ts | +| VersionCard | 126 | folder + useVersionCard.ts | +| QuickCapture | 288 | folder + useQuickCapture.ts | +| ActiveAgents | 157 | folder + useActiveAgents.ts | +| RecentProjects | 134 | folder + useRecentProjects.ts | +| TodayView | 124 | folder + useTodayView.ts | +| DashboardPage | 53 | folder | +| GreetingHeader | 44 | folder + useGreetingHeader.ts | +| DailyStats | 44 | folder + useDailyStats.ts | +| DiffViewer | 302 | folder + useDiffViewer.ts | +| DiffFileList | 207 | folder + useDiffFileList.ts | + +--- + +### Agent 3D: files + fitness + +**Domains:** files (1), fitness (7) +**Component count:** 8 + +| Component | Lines | Action | +|-----------|-------|--------| +| FileExplorer | 175 | folder + useFileExplorer.ts | +| BodyComposition | 351 | folder + useBodyComposition.ts | +| WorkoutForm | 337 | folder + useWorkoutForm.ts; extract ExerciseInput | +| GoalsPanel | 296 | folder + useGoalsPanel.ts; extract GoalCard | +| WorkoutEditDialog | 256 | folder + useWorkoutEditDialog.ts | +| MeasurementEditDialog | 221 | folder + useMeasurementEditDialog.ts | +| WorkoutLog | 150 | folder + useWorkoutLog.ts | +| GoalEditDialog | 181 | folder + useGoalEditDialog.ts | + +--- + +### Agent 3E: git + hub + ideas + +**Domains:** git (6), hub (1), ideas (2) +**Component count:** 9 + +| Component | Lines | Action | +|-----------|-------|--------| +| WorktreeList | 207 | folder + useWorktreeList.ts | +| CommitPanel | 187 | folder + useCommitPanel.ts | +| BranchList | 162 | folder + useBranchList.ts | +| CreatePrDialog (git) | 149 | folder + useCreatePrDialog.ts | +| ChangelogSummary | 136 | folder + useChangelogSummary.ts | +| CommitHistory | 120 | folder + useCommitHistory.ts | +| HubSetupPage | 451 | folder + useHubSetupPage.ts | +| IdeasPage | 303 | folder + useIdeasPage.ts | +| IdeaEditForm | 277 | folder + useIdeaEditForm.ts | + +--- + +### Agent 3F: integrations + merge + my-work + +**Domains:** integrations (9), merge (3), my-work (1) +**Component count:** 13 + +| Component | Lines | Action | +|-----------|-------|--------| +| DiscordActionModal | 304 | folder + useDiscordActionModal.ts | +| SlackActionModal | 292 | folder + useSlackActionModal.ts | +| NotificationsPanel | 285 | folder + useNotificationsPanel.ts | +| EmailPanel | 285 | folder + useEmailPanel.ts | +| IssueCreateForm | 185 | folder + useIssueCreateForm.ts | +| GitHubPanel | 141 | folder + useGitHubPanel.ts | +| DiscordPanel | 139 | folder + useDiscordPanel.ts | +| SlackPanel | 139 | folder + useSlackPanel.ts | +| NotificationRules | 117 | folder + useNotificationRules.ts | +| MergePreviewPanel | 328 | folder + useMergePreviewPanel.ts | +| ConflictResolver | 323 | folder + useConflictResolver.ts | +| MergeConfirmModal | 217 | folder + useMergeConfirmModal.ts | +| MyWorkPage | 567 | folder + useMyWorkPage.ts; extract TaskRow, TeamGroup | + +--- + +### Agent 3G: notes + onboarding + personal + planner + productivity + +**Domains:** notes (4), onboarding (4), personal (1), planner (8), productivity (2) +**Component count:** 19 + +| Component | Lines | Action | +|-----------|-------|--------| +| NotesList | 158 | folder + useNotesList.ts | +| NoteEditor | 156 | folder + useNoteEditor.ts | +| QuickNote | 96 | folder + useQuickNote.ts | +| NotesPage | 57 | folder + useNotesPage.ts | +| IntegrationsStep | 181 | folder + useIntegrationsStep.ts | +| ClaudeCliStep | 141 | folder + useClaudeCliStep.ts | +| OnboardingWizard | 123 | folder + useOnboardingWizard.ts | +| CompleteStep | 87 | folder + useCompleteStep.ts | +| PersonalPage | 132 | folder + usePersonalPage.ts | +| PlannerPage | 277 | folder + usePlannerPage.ts | +| WeeklyReviewPage | 225 | folder + useWeeklyReviewPage.ts | +| DayView | 201 | folder + useDayView.ts | +| CalendarOverlay | 170 | folder + useCalendarOverlay.ts | +| TimeBlockEditor | 130 | folder + useTimeBlockEditor.ts | +| WeekOverview | 130 | folder + useWeekOverview.ts | +| GoalsList | 129 | folder + useGoalsList.ts | +| WeeklyReflectionSection | 95 | folder + useWeeklyReflection.ts | +| SpotifyWidget | 252 | folder + useSpotifyWidget.ts; extract NowPlaying | +| CalendarWidget | 131 | folder + useCalendarWidget.ts | + +--- + +### Agent 3H: projects + runners + terminals + +**Domains:** projects (9), runners (2), terminals (2) +**Component count:** 13 + +| Component | Lines | Action | +|-----------|-------|--------| +| ProjectListPage | 348 | folder + useProjectListPage.ts | +| ProjectInitWizard | 321 | folder + useProjectInitWizard.ts | +| CreateProjectWizard | 287 | folder + useCreateProjectWizard.ts | +| ProjectEditDialog | 277 | folder + useProjectEditDialog.ts | +| WorktreeManager | 168 | folder + useWorktreeManager.ts | +| SetupProgressModal | 158 | folder | +| SubprojectSelector | 157 | folder + useSubprojectSelector.ts | +| BranchSelector | 128 | folder + useBranchSelector.ts | +| ProjectList | 126 | folder + useProjectList.ts | +| RunnerPanel | 152 | folder + useRunnerPanel.ts | +| ProfileEditDialog | 108 | folder + useProfileEditDialog.ts | +| TerminalInstance | 173 | folder + useTerminalInstance.ts | +| TerminalGrid | 132 | folder + useTerminalGrid.ts | + +--- + +### Agent 3I: settings + +**Domains:** settings (18 violations — dedicated agent) +**Component count:** 18 + +| Component | Lines | Action | +|-----------|-------|--------| +| HubSettings | 547 | folder + useHubSettings.ts; extract AutoSetupPanel, GenerateKeyPanel, ConnectionForm | +| StorageManagementSection | 467 | folder + useStorageManagement.ts | +| LayoutSection | 419 | folder + useLayoutSection.ts; move SVG previews | +| TestingSettingsTab | 333 | folder + useTestingSettings.ts | +| WebhookSettings | 320 | folder + useWebhookSettings.ts | +| SettingsPage | 264 | folder + useSettingsPage.ts | +| HotkeySettings | 239 | folder + useHotkeySettings.ts | +| DataLocationSection | 229 | folder + useDataLocation.ts | +| RetentionControl | 210 | folder + useRetentionControl.ts | +| WorkspaceEditor | 190 | folder + useWorkspaceEditor.ts | +| ProfileFormModal | 190 | folder + useProfileFormModal.ts | +| ProfileSection | 173 | folder + useProfileSection.ts | +| GitHubAuthSettings | 154 | folder + useGitHubAuthSettings.ts | +| ClaudeAuthSettings | 134 | folder + useClaudeAuthSettings.ts | +| BackgroundSettings | 122 | folder + useBackgroundSettings.ts | +| AppBehaviorSection | 114 | folder + useAppBehavior.ts | +| WorkspacesTab | 89 | folder + useWorkspacesTab.ts | +| OAuthProviderSettings | ~120 | folder + hook | + +--- + +### Agent 3J: tasks + test-suite + tools + visualization + workflow + workflow-pipeline + workspace + +**Domains:** tasks (7), test-suite (27), tools (2), visualization (1), workflow (1), workflow-pipeline (2), workspace (4) +**Component count:** 44 + +**Note:** This is the largest agent by count. test-suite alone has 27 violations. If this proves too large for a single agent, split into 3J-tasks (7) and 3K-test-suite (27) and 3L-remaining (10). + +| Domain | Components | +|--------|-----------| +| tasks | CreatePrDialog, BulkActionBar, CreateTaskDialog, EditProgressTaskDialog, TaskFiltersToolbar, LinkPrDialog, LinkJiraDialog | +| test-suite | ConfigEditDialog, SaveRecordingDialog, RecordingPanel, ResultsToolbar, SetupCard, ResultsOutputLog, StepList, LibraryPanel, BrowserViewPanel, ScreenshotsPanel, ExportPanel, AnalyticsDetailCards, DiffViewer, ResultsPanel, LibraryScriptRow, CreateTaskFromRunDialog, DataRunDialog, SharedStepsPanel, ResultsWorkflowActions, RunLogDialog, StepTimeline, CreateSharedStepDialog, AnalyticsPanel, TestSuitePage, ScheduleDialog, ScreenshotGallery, TrendChart | +| tools | WorkflowEditor, ToolsPage | +| visualization | VisualizationPage | +| workflow | WorkflowPermissionModal | +| workflow-pipeline | WorkflowPipelinePage, TaskSelector | +| workspace | TeamLeadPanel, PrimarySessionPanel, WorkspacePage, TeamLeadPanelList | + +--- + +## Execution Summary + +| Wave | Agents | Parallel? | Total Components/Changes | +|------|--------|-----------|--------------------------| +| Wave 1 | 3 (1A, 1B, 1C) | Yes | Infrastructure: theme store, chat UX, settings persistence | +| Wave 2 | 4 (2A, 2B, 2C) | Yes (after Wave 1) | Spacing UI, icon buttons, shape settings | +| Wave 3 | 10 (3A-3J) | Yes (after Wave 1) | 163 component folder refactors | +| **Total** | **17 agents** | | | + +## QA Gate + +After all waves complete: +1. `npx tsc --noEmit` — full typecheck +2. `npx eslint src/renderer/` — lint all renderer code +3. Visual smoke test — launch app, verify settings sliders work, chat sends immediately, icon shapes change +4. Verify no broken imports — every refactored component's barrel `index.ts` exports correctly + +## Agent Instructions Template + +Each agent receives: +1. This plan document (their section only) +2. The relevant research document (`docs/cleanup/item-{n}-*-research.md`) +3. Their agent definition from `.claude/agents/` +4. CLAUDE.md project rules +5. Instruction: "Read existing code before modifying. Follow component-engineer patterns exactly. Typecheck your changes with `npx tsc --noEmit` before reporting complete. Lint changed files with `npx eslint <files>`." diff --git a/docs/cleanup/item-1-component-structure-audit.md b/docs/cleanup/item-1-component-structure-audit.md new file mode 100644 index 00000000..aace8ff2 --- /dev/null +++ b/docs/cleanup/item-1-component-structure-audit.md @@ -0,0 +1,431 @@ +# Item 1: Component Folder Structure Audit + +> Research document for `codebase-cleanup` task. Generated 2026-04-20. + +## Target Pattern + +``` +features/{domain}/components/ +├── {ComponentName}/ +│ ├── {ComponentName}.tsx — pure render only +│ ├── use{ComponentName}.ts — ALL logic (hooks, state, handlers) +│ └── index.ts — barrel export +``` + +## Audit Summary + +| Domain | Violations | Clean | Worst Offenders | +|--------|-----------|-------|-----------------| +| agent-dashboard | 17 | 2 | AgentDashboardPage (603L), TemplateEditorPanel (547L), TemplateListPanel (317L) | +| agents | 1 | 0 | AgentDashboard (107L) | +| alerts | 5 | 0 | AlertsPage (315L), CreateAlertModal (228L), AlertEditDialog (232L) | +| assistant | 9 | 2 | SidebarAssistantButton (138L), AssistantInputBar (132L), WidgetMessageArea (131L) | +| auth | 3 | 0 | LoginPage (262L), RegisterPage (188L), AuthGuard (57L) | +| briefing | 3 | 0 | BriefingPage (233L), BriefingConfigPanel (173L) | +| changelog | 3 | 5 | EditEntryDialog (277L), ChangelogPage (180L), VersionCard (126L) | +| dashboard | 7 | 0 | QuickCapture (288L), ActiveAgents (157L), RecentProjects (134L) | +| diff-viewer | 2 | 0 | DiffViewer (302L), DiffFileList (207L) | +| files | 1 | 1 | FileExplorer (175L) | +| fitness | 7 | 3 | BodyComposition (351L), WorkoutForm (337L), GoalsPanel (296L) | +| git | 6 | 3 | WorktreeList (207L), CommitPanel (187L), BranchList (162L) | +| hub | 1 | 0 | HubSetupPage (451L) | +| ideas | 2 | 2 | IdeasPage (303L), IdeaEditForm (277L) | +| insights | 0 | 1 | -- (clean) | +| integrations | 9 | 9 | DiscordActionModal (304L), SlackActionModal (292L), NotificationsPanel (285L) | +| merge | 3 | 1 | MergePreviewPanel (328L), ConflictResolver (323L), MergeConfirmModal (217L) | +| my-work | 1 | 0 | MyWorkPage (567L) | +| notes | 4 | 0 | NoteEditor (156L), NotesList (158L) | +| onboarding | 4 | 1 | IntegrationsStep (181L), ClaudeCliStep (141L) | +| personal | 1 | 0 | PersonalPage (132L) | +| planner | 8 | 3 | PlannerPage (277L), WeeklyReviewPage (225L), DayView (201L) | +| planning | 0 | 1 | -- (clean) | +| productivity | 2 | 1 | SpotifyWidget (252L), CalendarWidget (131L) | +| projects | 9 | 3 | ProjectListPage (348L), ProjectInitWizard (321L), CreateProjectWizard (287L) | +| runners | 2 | 2 | RunnerPanel (152L), ProfileEditDialog (108L) | +| settings | 18 | 10 | HubSettings (547L), StorageManagementSection (467L), LayoutSection (419L) | +| tasks | 7 | 1 | CreatePrDialog (286L), BulkActionBar (241L), CreateTaskDialog (235L) | +| terminals | 2 | 0 | TerminalInstance (173L), TerminalGrid (132L) | +| test-suite | 27 | 12 | ConfigEditDialog (403L), SaveRecordingDialog (378L), RecordingPanel (246L) | +| tools | 2 | 2 | WorkflowEditor (281L), ToolsPage (150L) | +| visualization | 1 | 0 | VisualizationPage (111L) | +| workflow | 1 | 1 | WorkflowPermissionModal (112L) | +| workflow-pipeline | 2 | 3 | WorkflowPipelinePage (179L) | +| workspace | 4 | 0 | TeamLeadPanel (265L), PrimarySessionPanel (134L) | +| **TOTAL** | **~163** | **~69** | | + +**Only 2 domains are fully clean:** insights, planning. + +--- + +## Critical Files (300+ lines, heavy mixed concerns) + +| # | Domain | File | Lines | Hooks/Mutations | +|---|--------|------|-------|-----------------| +| 1 | agent-dashboard | AgentDashboardPage.tsx | 603 | useState x4, useCallback x3, useMemo, useQuery, useMutation | +| 2 | my-work | MyWorkPage.tsx | 567 | useState x3, useMemo x2, useQuery, useIpcEvent x3, 5 sub-components | +| 3 | agent-dashboard | TemplateEditorPanel.tsx | 547 | ~15 useState, useEffect, 2 useMutation | +| 4 | settings | HubSettings.tsx | 547 | useQuery, 4 useMutation, 3 sub-components with own state | +| 5 | settings | StorageManagementSection.tsx | 467 | useState x4, useMemo x3, useReactTable, 6 mutations | +| 6 | hub | HubSetupPage.tsx | 451 | useState x7, useQuery, 2 useMutation, fetch calls | +| 7 | settings | LayoutSection.tsx | 419 | Zustand stores, useMutation, 5 handlers, 3 SVG sub-components | +| 8 | test-suite | ConfigEditDialog.tsx | 403 | useState x2, useEffect, useMutation, form logic | +| 9 | test-suite | SaveRecordingDialog.tsx | 378 | useState x8, useMutation, store, assertion builder | +| 10 | fitness | BodyComposition.tsx | 351 | useState x5, useMutation x2, useQuery | +| 11 | projects | ProjectListPage.tsx | 348 | useState x4, useMemo x3, useQuery x2, useMutation | +| 12 | fitness | WorkoutForm.tsx | 337 | useState x5, useMutation, embedded ExerciseInput | +| 13 | settings | TestingSettingsTab.tsx | 333 | useState x2, useEffect x2, useDebounce, 4 queries/mutations | +| 14 | merge | MergePreviewPanel.tsx | 328 | useState x2, useMemo, useQuery x2, store | +| 15 | merge | ConflictResolver.tsx | 323 | useState x2, useMemo, useQuery x2, 5 handlers | +| 16 | projects | ProjectInitWizard.tsx | 321 | useState x6, useEffect, useMutation x4, useQuery x2 | +| 17 | settings | WebhookSettings.tsx | 320 | useState x3, useQuery x2, useMutation, 2 sub-components | +| 18 | alerts | AlertsPage.tsx | 315 | useState x4, useDebounce, useQuery, 2 useMutation | +| 19 | agent-dashboard | TemplateListPanel.tsx | 317 | useMutation x2, useQuery, store selectors | +| 20 | integrations | DiscordActionModal.tsx | 304 | useState x3, useMutation, renderForm switch | +| 21 | ideas | IdeasPage.tsx | 303 | useState x8, useMemo x3, useQuery, 3 useMutation | +| 22 | diff-viewer | DiffViewer.tsx | 302 | useMemo x3, custom useIsDark hook | +| 23 | fitness | GoalsPanel.tsx | 296 | useState x5, useQuery, useMutation, useDebounce, embedded GoalCard | +| 24 | integrations | SlackActionModal.tsx | 292 | useState x3, useMutation, renderForm switch | +| 25 | dashboard | QuickCapture.tsx | 288 | useState x4, useRef, 4 useMutation, 8 handlers | + +--- + +## Detailed Violations by Domain + +### agent-dashboard + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| AgentDashboardPage.tsx | 603 | mixed-concerns | Create folder + useAgentDashboardPage.ts | +| TemplateEditorPanel.tsx | 547 | mixed-concerns | Create folder + useTemplateEditorPanel.ts | +| TemplateListPanel.tsx | 317 | mixed-concerns | Create folder + useTemplateListPanel.ts | +| TextMessage.tsx | 261 | missing-hook | Create folder + useTextMessage.ts | +| ToolCallCard.tsx | 264 | loose-file | Create folder (hook optional — 1 useState) | +| AgentPanelPopup.tsx | 295 | loose-file | Create folder (pure render but 295 lines) | +| AgentPanelExpanded.tsx | 215 | loose-file | Create folder (pure render but 215 lines) | +| RunningWorkflowsPanel.tsx | 194 | mixed-concerns | Create folder + useRunningWorkflowsPanel.ts | +| QaPanel.tsx | 172 | missing-hook | Create folder + useQaPanel.ts | +| TasksTab.tsx | 166 | missing-hook | Create folder + useTasksTab.ts | +| AgentLayoutToolbar.tsx | 134 | loose-file | Create folder (pure render) | +| AgentStatusBar.tsx | 104 | loose-file | Create folder | +| AgentPanelCompact.tsx | 102 | loose-file | Create folder | +| AgentPanelTabs.tsx | 117 | loose-file | Create folder | +| AgentLayoutSingle.tsx | 100 | loose-file | Create folder | +| AgentChatPanel.tsx | 91 | loose-file | Create folder + useAgentChatPanel.ts | +| AgentLayoutGrid.tsx | 86 | loose-file | Create folder | + +### agents + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| AgentDashboard.tsx | 107 | mixed-concerns | Create folder + useAgentDashboard.ts | + +### alerts + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| AlertsPage.tsx | 315 | mixed-concerns | Create folder + useAlertsPage.ts | +| AlertEditDialog.tsx | 232 | mixed-concerns | Create folder + useAlertEditDialog.ts | +| CreateAlertModal.tsx | 228 | mixed-concerns | Create folder + useCreateAlertModal.ts | +| RecurringAlerts.tsx | 82 | loose-file | Create folder + useRecurringAlerts.ts | +| AlertNotification.tsx | 55 | missing-hook | Create folder + useAlertNotification.ts | + +### assistant + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| SidebarAssistantButton.tsx | 138 | mixed-concerns | Create folder + useSidebarAssistantButton.ts | +| WidgetPanel.tsx | 133 | mixed-concerns | Create folder + useWidgetPanel.ts | +| WidgetMessageArea.tsx | 131 | mixed-concerns | Create folder + useWidgetMessageArea.ts | +| AssistantInputBar.tsx | 132 | mixed-concerns | Create folder + useAssistantInputBar.ts | +| ProjectSelector.tsx | 107 | missing-hook | Create folder + useProjectSelector.ts | +| WidgetInput.tsx | 95 | missing-hook | Create folder + useWidgetInput.ts | +| AssistantPage.tsx | 85 | missing-hook | Create folder + useAssistantPage.ts | +| ResponseStream.tsx | 79 | loose-file | Create folder + useResponseStream.ts | +| AssistantWidget.tsx | 65 | mixed-concerns | Create folder + useAssistantWidget.ts | + +### auth + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| LoginPage.tsx | 262 | mixed-concerns | Create folder + useLoginPage.ts | +| RegisterPage.tsx | 188 | mixed-concerns | Create folder + useRegisterPage.ts | +| AuthGuard.tsx | 57 | missing-hook | Create folder + useAuthGuard.ts | + +### briefing + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| BriefingPage.tsx | 233 | mixed-concerns | Create folder + useBriefingPage.ts | +| BriefingConfigPanel.tsx | 173 | mixed-concerns | Create folder + useBriefingConfigPanel.ts | +| SuggestionCard.tsx | 83 | loose-file | Create folder + useSuggestionCard.ts | + +### changelog + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| EditEntryDialog.tsx | 277 | mixed-concerns | Create folder + useEditEntryDialog.ts | +| ChangelogPage.tsx | 180 | mixed-concerns | Create folder + useChangelogPage.ts | +| VersionCard.tsx | 126 | missing-hook | Create folder + useVersionCard.ts | + +### dashboard + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| QuickCapture.tsx | 288 | mixed-concerns | Create folder + useQuickCapture.ts | +| ActiveAgents.tsx | 157 | mixed-concerns | Create folder + useActiveAgents.ts | +| RecentProjects.tsx | 134 | mixed-concerns | Create folder + useRecentProjects.ts | +| TodayView.tsx | 124 | loose-file | Create folder + useTodayView.ts | +| DashboardPage.tsx | 53 | loose-file | Create folder | +| GreetingHeader.tsx | 44 | loose-file | Create folder + useGreetingHeader.ts | +| DailyStats.tsx | 44 | missing-hook | Create folder + useDailyStats.ts | + +### diff-viewer + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| DiffViewer.tsx | 302 | missing-hook | Create folder + useDiffViewer.ts | +| DiffFileList.tsx | 207 | loose-file | Create folder + useDiffFileList.ts | + +### files + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| FileExplorer.tsx | 175 | mixed-concerns | Create folder + useFileExplorer.ts | + +### fitness + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| BodyComposition.tsx | 351 | mixed-concerns | Create folder + useBodyComposition.ts | +| WorkoutForm.tsx | 337 | mixed-concerns | Create folder + useWorkoutForm.ts; extract ExerciseInput | +| GoalsPanel.tsx | 296 | mixed-concerns | Create folder + useGoalsPanel.ts; extract GoalCard | +| WorkoutEditDialog.tsx | 256 | mixed-concerns | Create folder + useWorkoutEditDialog.ts | +| MeasurementEditDialog.tsx | 221 | missing-hook | Create folder + useMeasurementEditDialog.ts | +| WorkoutLog.tsx | 150 | mixed-concerns | Create folder + useWorkoutLog.ts | +| GoalEditDialog.tsx | 181 | missing-hook | Create folder + useGoalEditDialog.ts | + +### git + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| WorktreeList.tsx | 207 | mixed-concerns | Create folder + useWorktreeList.ts | +| CommitPanel.tsx | 187 | mixed-concerns | Create folder + useCommitPanel.ts | +| BranchList.tsx | 162 | missing-hook | Create folder + useBranchList.ts | +| CreatePrDialog.tsx | 149 | missing-hook | Create folder + useCreatePrDialog.ts | +| ChangelogSummary.tsx | 136 | missing-hook | Create folder + useChangelogSummary.ts | +| CommitHistory.tsx | 120 | missing-hook | Create folder + useCommitHistory.ts | + +### hub + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| HubSetupPage.tsx | 451 | mixed-concerns | Create folder + useHubSetupPage.ts | + +### ideas + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| IdeasPage.tsx | 303 | mixed-concerns | Create folder + useIdeasPage.ts | +| IdeaEditForm.tsx | 277 | mixed-concerns | Create folder + useIdeaEditForm.ts | + +### integrations + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| DiscordActionModal.tsx | 304 | mixed-concerns | Create folder + useDiscordActionModal.ts | +| SlackActionModal.tsx | 292 | mixed-concerns | Create folder + useSlackActionModal.ts | +| NotificationsPanel.tsx | 285 | mixed-concerns | Create folder + useNotificationsPanel.ts | +| EmailPanel.tsx | 285 | mixed-concerns | Create folder + useEmailPanel.ts | +| IssueCreateForm.tsx | 185 | mixed-concerns | Create folder + useIssueCreateForm.ts | +| GitHubPanel.tsx | 141 | missing-hook | Create folder + useGitHubPanel.ts | +| DiscordPanel.tsx | 139 | missing-hook | Create folder + useDiscordPanel.ts | +| SlackPanel.tsx | 139 | missing-hook | Create folder + useSlackPanel.ts | +| NotificationRules.tsx | 117 | missing-hook | Create folder + useNotificationRules.ts | + +### merge + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| MergePreviewPanel.tsx | 328 | mixed-concerns | Create folder + useMergePreviewPanel.ts | +| ConflictResolver.tsx | 323 | mixed-concerns | Create folder + useConflictResolver.ts | +| MergeConfirmModal.tsx | 217 | mixed-concerns | Create folder + useMergeConfirmModal.ts | + +### my-work + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| MyWorkPage.tsx | 567 | mixed-concerns | Create folder + useMyWorkPage.ts; extract TaskRow, TeamGroup | + +### notes + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| NotesList.tsx | 158 | missing-hook | Create folder + useNotesList.ts | +| NoteEditor.tsx | 156 | missing-hook | Create folder + useNoteEditor.ts | +| QuickNote.tsx | 96 | missing-hook | Create folder + useQuickNote.ts | +| NotesPage.tsx | 57 | loose-file | Create folder + useNotesPage.ts | + +### onboarding + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| IntegrationsStep.tsx | 181 | mixed-concerns | Create folder + useIntegrationsStep.ts; extract inline useGitHubAuth | +| ClaudeCliStep.tsx | 141 | missing-hook | Create folder + useClaudeCliStep.ts | +| OnboardingWizard.tsx | 123 | missing-hook | Create folder + useOnboardingWizard.ts | +| CompleteStep.tsx | 87 | missing-hook | Create folder + useCompleteStep.ts | + +### personal + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| PersonalPage.tsx | 132 | missing-hook | Create folder + usePersonalPage.ts | + +### planner + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| PlannerPage.tsx | 277 | mixed-concerns | Create folder + usePlannerPage.ts | +| WeeklyReviewPage.tsx | 225 | mixed-concerns | Create folder + useWeeklyReviewPage.ts | +| DayView.tsx | 201 | missing-hook | Create folder + useDayView.ts | +| CalendarOverlay.tsx | 170 | missing-hook | Create folder + useCalendarOverlay.ts | +| TimeBlockEditor.tsx | 130 | missing-hook | Create folder + useTimeBlockEditor.ts | +| WeekOverview.tsx | 130 | missing-hook | Create folder + useWeekOverview.ts | +| GoalsList.tsx | 129 | missing-hook | Create folder + useGoalsList.ts | +| WeeklyReflectionSection.tsx | 95 | missing-hook | Create folder + useWeeklyReflection.ts | + +### productivity + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| SpotifyWidget.tsx | 252 | mixed-concerns | Create folder + useSpotifyWidget.ts; extract NowPlaying | +| CalendarWidget.tsx | 131 | missing-hook | Create folder + useCalendarWidget.ts | + +### projects + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| ProjectListPage.tsx | 348 | mixed-concerns | Create folder + useProjectListPage.ts | +| ProjectInitWizard.tsx | 321 | mixed-concerns | Create folder + useProjectInitWizard.ts | +| CreateProjectWizard.tsx | 287 | mixed-concerns | Create folder + useCreateProjectWizard.ts | +| ProjectEditDialog.tsx | 277 | mixed-concerns | Create folder + useProjectEditDialog.ts | +| WorktreeManager.tsx | 168 | missing-hook | Create folder + useWorktreeManager.ts | +| SetupProgressModal.tsx | 158 | loose-file | Create folder | +| SubprojectSelector.tsx | 157 | missing-hook | Create folder + useSubprojectSelector.ts | +| BranchSelector.tsx | 128 | missing-hook | Create folder + useBranchSelector.ts | +| ProjectList.tsx | 126 | missing-hook | Create folder + useProjectList.ts | + +### runners + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| RunnerPanel.tsx | 152 | mixed-concerns | Create folder + useRunnerPanel.ts | +| ProfileEditDialog.tsx | 108 | missing-hook | Create folder + useProfileEditDialog.ts | + +### settings + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| HubSettings.tsx | 547 | mixed-concerns | Create folder + useHubSettings.ts; extract 3 sub-panels | +| StorageManagementSection.tsx | 467 | mixed-concerns | Create folder + useStorageManagement.ts | +| LayoutSection.tsx | 419 | mixed-concerns | Create folder + useLayoutSection.ts; move SVG previews | +| TestingSettingsTab.tsx | 333 | mixed-concerns | Create folder + useTestingSettings.ts | +| WebhookSettings.tsx | 320 | mixed-concerns | Create folder + useWebhookSettings.ts | +| SettingsPage.tsx | 264 | mixed-concerns | Create folder + useSettingsPage.ts | +| HotkeySettings.tsx | 239 | mixed-concerns | Create folder + useHotkeySettings.ts | +| DataLocationSection.tsx | 229 | mixed-concerns | Create folder + useDataLocation.ts | +| RetentionControl.tsx | 210 | missing-hook | Create folder + useRetentionControl.ts | +| WorkspaceEditor.tsx | 190 | missing-hook | Create folder + useWorkspaceEditor.ts | +| ProfileFormModal.tsx | 190 | missing-hook | Create folder + useProfileFormModal.ts | +| ProfileSection.tsx | 173 | mixed-concerns | Create folder + useProfileSection.ts | +| GitHubAuthSettings.tsx | 154 | mixed-concerns | Create folder + useGitHubAuthSettings.ts | +| ClaudeAuthSettings.tsx | 134 | missing-hook | Create folder + useClaudeAuthSettings.ts | +| BackgroundSettings.tsx | 122 | missing-hook | Create folder + useBackgroundSettings.ts | +| AppBehaviorSection.tsx | 114 | missing-hook | Create folder + useAppBehavior.ts | +| WorkspacesTab.tsx | 89 | missing-hook | Create folder + useWorkspacesTab.ts | +| OAuthProviderSettings.tsx | ~120 | loose-file | Needs folder + hook | + +### tasks + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| CreatePrDialog.tsx | 286 | mixed-concerns | Create folder + useCreatePrDialog.ts | +| BulkActionBar.tsx | 241 | missing-hook | Create folder + useBulkActionBar.ts | +| CreateTaskDialog.tsx | 235 | mixed-concerns | Create folder + useCreateTaskDialog.ts | +| EditProgressTaskDialog.tsx | 215 | mixed-concerns | Create folder + useEditProgressTaskDialog.ts | +| TaskFiltersToolbar.tsx | 196 | mixed-concerns | Create folder + useTaskFiltersToolbar.ts | +| LinkPrDialog.tsx | 158 | missing-hook | Create folder + useLinkPrDialog.ts | +| LinkJiraDialog.tsx | 154 | missing-hook | Create folder + useLinkJiraDialog.ts | + +### terminals + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| TerminalInstance.tsx | 173 | mixed-concerns | Create folder + useTerminalInstance.ts | +| TerminalGrid.tsx | 132 | mixed-concerns | Create folder + useTerminalGrid.ts | + +### test-suite + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| ConfigEditDialog.tsx | 403 | mixed-concerns | Create folder + useConfigEditDialog.ts | +| SaveRecordingDialog.tsx | 378 | mixed-concerns | Create folder + useSaveRecordingDialog.ts | +| RecordingPanel.tsx | 246 | mixed-concerns | Create folder + useRecordingPanel.ts | +| ResultsToolbar.tsx | 242 | missing-hook | Create folder + useResultsToolbar.ts | +| SetupCard.tsx | 225 | mixed-concerns | Create folder + useSetupCard.ts | +| ResultsOutputLog.tsx | 215 | loose-file | Create folder + useResultsOutputLog.ts | +| StepList.tsx | 211 | mixed-concerns | Create folder + useStepList.ts | +| LibraryPanel.tsx | 203 | mixed-concerns | Create folder + useLibraryPanel.ts | +| BrowserViewPanel.tsx | 185 | mixed-concerns | Create folder + useBrowserViewPanel.ts | +| ScreenshotsPanel.tsx | 182 | mixed-concerns | Create folder + useScreenshotsPanel.ts | +| ExportPanel.tsx | 181 | mixed-concerns | Create folder + useExportPanel.ts | +| AnalyticsDetailCards.tsx | 165 | loose-file | Create folder (pure render) | +| DiffViewer.tsx | 150 | missing-hook | Create folder + useDiffViewer.ts | +| ResultsPanel.tsx | 146 | mixed-concerns | Create folder + useResultsPanel.ts | +| LibraryScriptRow.tsx | 144 | loose-file | Create folder for consistency | +| CreateTaskFromRunDialog.tsx | 139 | missing-hook | Create folder + useCreateTaskFromRunDialog.ts | +| DataRunDialog.tsx | 136 | missing-hook | Create folder + useDataRunDialog.ts | +| SharedStepsPanel.tsx | 128 | mixed-concerns | Create folder + useSharedStepsPanel.ts | +| ResultsWorkflowActions.tsx | 125 | mixed-concerns | Create folder + useResultsWorkflowActions.ts | +| RunLogDialog.tsx | 118 | missing-hook | Create folder + useRunLogDialog.ts | +| StepTimeline.tsx | 105 | loose-file | Create folder for consistency | +| CreateSharedStepDialog.tsx | 100 | missing-hook | Create folder + useCreateSharedStepDialog.ts | +| AnalyticsPanel.tsx | 99 | missing-hook | Create folder + useAnalyticsPanel.ts | +| TestSuitePage.tsx | 95 | missing-hook | Create folder + useTestSuitePage.ts | +| ScheduleDialog.tsx | 88 | missing-hook | Create folder + useScheduleDialog.ts | +| ScreenshotGallery.tsx | 163 | missing-hook | Create folder + useScreenshotGallery.ts | +| TrendChart.tsx | 111 | loose-file | Create folder for consistency | + +### tools + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| WorkflowEditor.tsx | 281 | mixed-concerns | Create folder + useWorkflowEditor.ts | +| ToolsPage.tsx | 150 | missing-hook | Create folder + useToolsPage.ts | + +### visualization + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| VisualizationPage.tsx | 111 | mixed-concerns | Create folder + useVisualizationPage.ts | + +### workflow + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| WorkflowPermissionModal.tsx | 112 | mixed-concerns | Create folder + useWorkflowPermissionModal.ts | + +### workflow-pipeline + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| WorkflowPipelinePage.tsx | 179 | mixed-concerns | Create folder + useWorkflowPipelinePage.ts | +| TaskSelector.tsx | 53 | loose-file | Create folder for consistency | + +### workspace + +| File | Lines | Violation | Action | +|------|-------|-----------|--------| +| TeamLeadPanel.tsx | 265 | mixed-concerns | Create folder + useTeamLeadPanel.ts | +| PrimarySessionPanel.tsx | 134 | mixed-concerns | Create folder + usePrimarySessionPanel.ts | +| WorkspacePage.tsx | 98 | missing-hook | Create folder + useWorkspacePage.ts | +| TeamLeadPanelList.tsx | 54 | loose-file | Create folder for consistency | diff --git a/docs/cleanup/item-2-chat-ux-research.md b/docs/cleanup/item-2-chat-ux-research.md new file mode 100644 index 00000000..03f7e40b --- /dev/null +++ b/docs/cleanup/item-2-chat-ux-research.md @@ -0,0 +1,326 @@ +# Item 2: Chat UX Message Flow — Research & Root Cause Analysis + +## Current Message Flow Diagrams + +### A. Workspace Sessions (Primary + Team Lead) + +``` +User types message in PrimarySessionPanel / TeamLeadPanel + | + v +handleSend() -> useWorkspaceSend().mutate({ sessionId, message }) + | (clears draft immediately via clearDraft(sessionId)) + | (NO optimistic user message added to UI) + | + v +IPC invoke: WORKSPACE.SEND.MESSAGE -> workspace-handlers.ts + | + v +WorkspaceSessionManager.sendMessage(sessionId, message) + | + v +agentManager.sendMessage(sessionId, message) [agent-host-client.ts] + | (fire-and-forget: posts to control port, returns true immediately) + | + v +AgentHost utility process receives 'send-message' control request + | + v +AgentManagerService.sendMessage() in agent-manager-service.ts + | 1. processManager.sendMessage(process, message) -> writes to stdin + | 2. Creates a SYNTHETIC user AgentChatMessage: + | { id: randomUUID(), agentId: sessionId, role: 'user', + | content: [{ type: 'text', text: message }], timestamp: now } + | 3. Calls handleChatMessage(internal, userMessage) + | + v +handleChatMessage() emits TWO things: + 1. router.emit(AGENT_DASHBOARD_EVENTS.MESSAGE.RECEIVED, message) + -> IPC event to renderer + 2. emitEvent({ type: 'message.received', ... }) + -> agent-host event port to main process cache + | + v +Renderer receives event:agent-dashboard.messageReceived + | + v +useAgentDashboardEvents() hook: setQueryData appends to + ['agent-dashboard', 'messages', message.agentId] + | + v +PrimarySessionPanel / TeamLeadPanel re-renders via useQuery on same key + -> messagesToChatItems() converts to AgentChatItem[] + -> AgentChatPanel renders the message +``` + +**Time from user press Enter to message appearing: ~50-200ms IPC round-trip** + +### B. Assistant Widget / Assistant Page + +``` +User types in AssistantInputBar / WidgetInput + | + v +onSubmit(trimmed) -> useSendCommand().mutate() + | + v +onMutate callback: + 1. setLastCommand(input) [module-level ref] + 2. setIsThinking(true) [Zustand store] + 3. clearCurrentResponse() [Zustand store] + (NO user message added to responseHistory) + | + v +IPC invoke: ASSISTANT.SEND.COMMAND -> assistant-handlers.ts + | + v +AssistantService.sendCommand(input, context) + 1. sendEvent(THINKING, { isThinking: true }) + 2. agentManager.sendMessage(sessionId, input) + | + v +[Same path as workspace: stdin write + synthetic user message emitted] + | + v +AssistantService event subscription catches 'message.received': + - Extracts text from assistant message + - Sends ASSISTANT_EVENTS.MESSAGE.RESPONSE to renderer + | + v +useAssistantEvents() hook catches MESSAGE.RESPONSE: + 1. setCurrentResponse(payload.content) + 2. addResponseEntry({ input: _lastCommand, response: payload.content, type }) + ^^ THIS is the ONLY place the user's message enters the UI + | + v +WidgetMessageArea / ResponseStream renders responseHistory + (each entry has { input, response } — user message is coupled to response) +``` + +**User message does NOT appear until Claude responds.** + +### C. Agent Dashboard (AgentChatPanel standalone) + +``` +AgentPanelExpanded passes agent.messages (AgentChatItem[]) to AgentChatPanel + | + v +Messages come from AgentSession.messages field + (populated by buildChatItems in some places, or directly from query cache) + | + v +Same event-driven cache as workspace: useAgentDashboardEvents +appends to ['agent-dashboard', 'messages', agentId] +``` + +### D. Terminal (working reference) + +``` +User types in xterm.js terminal + | + v +term.onData(data) -> ipc(TERMINALS.SEND.INPUT, { sessionId, data }) + | Input is ECHO'd by the PTY itself (shell echo) + | + v +Main process writes data to PTY stdin +PTY echoes input back to stdout + | + v +TERMINALS_EVENTS.TERMINAL.OUTPUT event fires + | + v +useIpcEvent listener: xtermRef.current.write(data) + -> User sees their input instantly because PTY echo +``` + +--- + +## Root Cause Analysis + +### Problem 1: User message does NOT appear immediately + +**Root cause (Workspace sessions):** `PrimarySessionPanel.handleSend()` and `TeamLeadPanel.handleSend()` call `useWorkspaceSend().mutate()` which is a fire-and-forget IPC call. The mutation has **no `onMutate` callback** that optimistically inserts the user message into the React Query cache. The user message only appears when: + +1. `agentManager.sendMessage()` runs in the agent-host utility process +2. It creates a synthetic `AgentChatMessage` with `role: 'user'` +3. Emits `AGENT_DASHBOARD_EVENTS.MESSAGE.RECEIVED` back to renderer +4. `useAgentDashboardEvents()` appends it to the query cache + +This is a ~50-200ms round-trip through: renderer -> IPC -> main -> MessagePort -> agent-host -> MessagePort -> main -> IPC -> renderer. + +**Root cause (Assistant):** The assistant flow is worse. `useSendCommand()` has an `onMutate` that sets `isThinking` and `clearCurrentResponse`, but it **never adds the user's input to `responseHistory`**. The user's input only enters the UI when `addResponseEntry()` is called inside `useAssistantEvents()` — which only fires when Claude's response arrives. The `ResponseEntry` type couples `{ input, response }` into a single object, so the user message literally cannot render without a response. + +**Files involved:** +- `src/renderer/features/workspace/api/useWorkspace.ts` — `useWorkspaceSend()` (line 74-79, no onMutate) +- `src/renderer/features/workspace/components/PrimarySessionPanel.tsx` — `handleSend()` (line 69-74) +- `src/renderer/features/workspace/components/TeamLeadPanel.tsx` — `handleSend()` (line 63-68) +- `src/renderer/features/assistant/api/useAssistant.ts` — `useSendCommand()` (line 26-52, onMutate only sets thinking) +- `src/renderer/features/assistant/store.ts` — `ResponseEntry` type couples input+response (line 7-13) +- `src/renderer/features/assistant/hooks/useAssistantEvents.ts` — `addResponseEntry` only on response (line 31-41) +- `src/main/services/agent-manager/agent-manager-service.ts` — synthetic user message (line 456-463) + +### Problem 2: No loading/thinking indicator while Claude processes + +**Root cause (Workspace sessions):** `useSessionThinking(sessionId)` listens for `AGENT_DASHBOARD_EVENTS.SESSION.STATUS-CHANGED` and returns true when `newStatus === 'running'`. This event fires from `handleStreamEvent()` when the agent-manager-service sees `event.type === 'assistant'` or `event.type === 'stream_event'`. The thinking indicator **only appears after Claude starts producing output** — not when the user sends a message. There is no event emitted at the moment of send. + +Both `PrimarySessionPanel` and `TeamLeadPanel` do render `<ThinkingIndicator>` when `isThinking` is true, but `isThinking` stays false during the critical wait between send and first stream event. + +**Root cause (Assistant):** The assistant flow IS correct for thinking. `useSendCommand().onMutate` sets `isThinking(true)` immediately, and `WidgetMessageArea` / `ResponseStream` render `<ThinkingIndicator>` when `isThinking === true`. The thinking indicator works for the assistant. However, it shows **below the empty message area** rather than below the user's message (since the user message isn't shown yet). + +**Files involved:** +- `src/renderer/features/workspace/hooks/useSessionThinking.ts` — only triggers on status-changed event +- `src/main/services/agent-manager/agent-manager-service.ts` — `updateSessionStatus` at line 196-200, only fires on stream events +- `src/renderer/features/assistant/api/useAssistant.ts` — `onMutate` correctly sets thinking (line 42-44) + +### Problem 3: Claude response re-posts user's message + +**Root cause (Assistant):** This is a direct consequence of the `ResponseEntry` design. Each `ResponseEntry` in `responseHistory` contains `{ input, response }`. When `addResponseEntry` is called on each assistant response, it grabs `_lastCommand` (a module-level mutable variable set in `setLastCommand()`). If Claude sends multiple response messages (e.g., a text message followed by another after tool execution), each response creates a new `ResponseEntry` with the same `_lastCommand` value. The user's input text appears once per Claude response chunk. + +The `_lastCommand` variable is set once in `useSendCommand().onMutate` and never cleared. So subsequent assistant messages from the same prompt all pair with the same user input. + +**Root cause (Workspace):** This should NOT happen in workspace sessions because user messages are separate `AgentChatMessage` objects with `role: 'user'` and unique IDs. The deduplication in `useAgentDashboardEvents()` (line 79: `existing.some(m => m.id === message.id)`) prevents duplicate insertion. If workspace sessions DO show duplicates, it would be because `agentManager.sendMessage()` is being called multiple times (e.g., the synthetic user message is emitted once per `sendMessage` call in the agent-manager, and separately once from the agent-host client cache in `message.received` handler). Both paths create separate message objects with different IDs, so dedup doesn't catch it. + +Wait — re-examining: in the agent-host architecture, `sendMessage` in `agent-host-client.ts` is fire-and-forget (posts to control port). The actual `sendMessage` runs in the utility process's `AgentManagerService`. The utility process emits `message.received` on the event port. The `agent-host-client.ts` event handler at line 133 adds the message to `messageStore`. The same event is forwarded to all `eventHandlers`. The `event-wiring` module in the main process subscribes to these events and forwards them to the renderer via `router.emit`. So there is ONE path for user messages, not two. The synthetic user message from `agent-manager-service.ts:456-463` is emitted once. + +**Files involved:** +- `src/renderer/features/assistant/hooks/useAssistantEvents.ts` — `_lastCommand` reuse (line 18-20, 32-38) +- `src/renderer/features/assistant/store.ts` — `ResponseEntry` coupling (line 7-13) + +### Problem 4: Bug report says "no duplication on continued conversation" + +This is consistent with the analysis. For workspace sessions, the deduplication check (`existing.some(m => m.id === message.id)`) prevents actual duplicates. For the assistant, the `_lastCommand` re-posting only happens when Claude sends multiple response chunks for a single query. In normal single-response conversations, each input maps to exactly one response entry. + +--- + +## What the Terminal Does Differently + +The terminal works because **xterm.js + PTY provide inherent echo**: + +1. **Input echo is built into the PTY layer.** When xterm.js sends keystrokes via `term.onData()`, the PTY shell echoes them back through stdout. The renderer writes stdout to the terminal via `TERMINALS_EVENTS.TERMINAL.OUTPUT`. The user sees their input instantly because the PTY echoes it. + +2. **No separate "user message" concept.** The terminal doesn't distinguish user input from output — it's a single continuous stream. There's no need for optimistic updates because the PTY provides real-time bidirectional I/O. + +3. **Direct event streaming.** Terminal output arrives character-by-character through IPC events (`useIpcEvent`), not through query cache. This means zero latency between the PTY producing output and the UI rendering it. + +The chat UX needs to implement the equivalent of "echo" — showing the user's message instantly in local state before any IPC round-trip. + +--- + +## Recommended Fix Approach + +### Fix 1: Optimistic User Messages for Workspace Sessions + +Add `onMutate` to `useWorkspaceSend()` that immediately inserts a user message into the React Query cache: + +```typescript +// In useWorkspace.ts +export function useWorkspaceSend() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ sessionId, message }: { sessionId: string; message: string }) => + ipc(WORKSPACE.SEND.MESSAGE, { sessionId, message }), + + onMutate: ({ sessionId, message }) => { + // Optimistically add user message to cache + const optimisticMessage: AgentChatMessage = { + id: crypto.randomUUID(), + agentId: sessionId, + role: 'user', + content: [{ type: 'text', text: message }], + timestamp: new Date().toISOString(), + }; + + queryClient.setQueryData<AgentChatMessage[]>( + ['agent-dashboard', 'messages', sessionId], + (old) => [...(old ?? []), optimisticMessage], + ); + + return { optimisticId: optimisticMessage.id }; + }, + }); +} +``` + +Then in `useAgentDashboardEvents()`, the dedup check will catch the real user message from the agent-host (it has a different ID, but same content). Two options: +- **Option A:** Skip dedup for user messages and accept the duplicate, then deduplicate by content+role+close-timestamp. +- **Option B (preferred):** Do not emit synthetic user messages from `agent-manager-service.ts` at all — let the renderer be the single source of truth for user messages. Remove lines 456-463 in `agent-manager-service.ts`. + +**Risk with Option B:** The agent-host-client cache and the agent-dashboard `useAgentMessages` cache would diverge (agent-host has user messages, renderer doesn't get them from events). But since the renderer query cache is the rendering source of truth, this is fine — the agent-host cache is only used for main-process operations. + +### Fix 2: Decouple User Messages from Responses in Assistant + +Refactor `ResponseEntry` to support standalone user messages: + +```typescript +// New types in assistant store +type ChatEntry = + | { kind: 'user'; id: string; input: string; timestamp: string } + | { kind: 'response'; id: string; input: string; response: string; type: 'text' | 'error'; timestamp: string }; + +// In useSendCommand onMutate: +addUserEntry({ input: variables.input }); // appears instantly + +// In useAssistantEvents MESSAGE.RESPONSE: +addResponseEntry({ response: payload.content, type: payload.type }); // no input field needed +``` + +Update `WidgetMessageArea` and `ResponseStream` to render `ChatEntry[]` as separate bubbles. + +### Fix 3: Immediate Thinking State for Workspace Sessions + +Add an `onMutate` to `useWorkspaceSend()` that sets a per-session thinking state: + +```typescript +onMutate: ({ sessionId }) => { + // Set thinking immediately in workspace store + useWorkspaceStore.getState().setSessionThinking(sessionId, true); +}, +``` + +Or simpler: in `PrimarySessionPanel` and `TeamLeadPanel`, derive thinking from `send.isPending || isThinking`: + +```typescript +const showThinking = send.isPending || isThinking; +``` + +This uses the mutation's pending state to show thinking immediately on send, then the `useSessionThinking` hook takes over when the agent starts streaming. + +### Fix 4: Clean Up `_lastCommand` Module Variable + +Replace the mutable module-level `_lastCommand` with proper state flow. Store the pending input in the assistant Zustand store, and clear it after the response is received. This prevents the re-posting bug entirely. + +--- + +## Files to Modify (by fix) + +### Fix 1 (optimistic user messages — workspace) +- `src/renderer/features/workspace/api/useWorkspace.ts` — add onMutate to useWorkspaceSend +- `src/main/services/agent-manager/agent-manager-service.ts` — optionally remove synthetic user message emit (lines 456-463) +- `src/renderer/features/agent-dashboard/hooks/useAgentEvents.ts` — adjust dedup if keeping server-side user messages + +### Fix 2 (decouple assistant messages) +- `src/renderer/features/assistant/store.ts` — refactor ResponseEntry to ChatEntry union +- `src/renderer/features/assistant/api/useAssistant.ts` — add user entry in onMutate +- `src/renderer/features/assistant/hooks/useAssistantEvents.ts` — remove _lastCommand, only add response entries +- `src/renderer/features/assistant/components/WidgetMessageArea.tsx` — render ChatEntry[] +- `src/renderer/features/assistant/components/ResponseStream.tsx` — render ChatEntry[] + +### Fix 3 (immediate thinking — workspace) +- `src/renderer/features/workspace/components/PrimarySessionPanel.tsx` — combine send.isPending with isThinking +- `src/renderer/features/workspace/components/TeamLeadPanel.tsx` — same + +### Fix 4 (clean up _lastCommand) +- `src/renderer/features/assistant/hooks/useAssistantEvents.ts` — remove _lastCommand variable +- `src/renderer/features/assistant/api/useAssistant.ts` — store pending input in Zustand +- `src/renderer/features/assistant/store.ts` — add pendingInput field + +--- + +## Priority Order + +1. **Fix 1** — Most impactful. Users see their message immediately in workspace (primary + team lead). +2. **Fix 3** — Quick win. One-line change to show thinking indicator on send. +3. **Fix 2** — Larger refactor but needed for assistant correctness. +4. **Fix 4** — Cleanup of mutable module state, prevents edge-case duplication. diff --git a/docs/cleanup/item-3-icon-button-research.md b/docs/cleanup/item-3-icon-button-research.md new file mode 100644 index 00000000..c992daf6 --- /dev/null +++ b/docs/cleanup/item-3-icon-button-research.md @@ -0,0 +1,222 @@ +# Item 3: Icon Button Consistency Audit + +## Current @ui Icon Component API + +**File:** `src/renderer/shared/components/ui/icon.tsx` + +The `Icon` component is a variant-driven wrapper for Lucide-compatible icon components. It does NOT handle click behavior -- it is purely presentational. + +```ts +interface IconProps extends VariantProps<typeof iconVariants> { + component: React.ComponentType<{ className?: string }>; + className?: string; +} +``` + +**Variants:** +- `variant`: default | muted | success | passed | error | failed | warning | info | active | running | pending +- `size`: xs (h-3 w-3) | sm (h-3.5 w-3.5, default) | md (h-4 w-4) | lg (h-5 w-5) + +Base class: `shrink-0`. The Icon component applies color and sizing only. No interactivity, no shape/radius handling. + +## Current @ui Button `size="icon"` API + +**File:** `src/renderer/shared/components/ui/button.tsx` + +The Button component has 4 icon-related size variants: + +| Size | Classes | Description | +|------|---------|-------------| +| `icon` | `h-9 w-9` | Standard icon button, inherits base `rounded-md` | +| `icon-sm` | `h-7 w-7 rounded-sm [&_svg]:h-3.5 [&_svg]:w-3.5` | Small icon button | +| `icon-xs` | `h-6 w-6 rounded-sm p-1 [&_svg]:h-3.5 [&_svg]:w-3.5` | Extra-small icon button | +| `toolbar` | `h-full w-10 rounded-none border-border border-l [&_svg]:h-4 [&_svg]:w-4` | Toolbar-specific icon button | + +**Button variant relevant to icon usage:** +- `toolbar`: `text-muted-foreground hover:bg-muted hover:text-foreground` -- dedicated variant for toolbar buttons + +**Key observation:** The base `rounded-md` in the Button CVA is hardcoded. `icon-sm` and `icon-xs` override to `rounded-sm`. `toolbar` overrides to `rounded-none`. There is no mechanism for the shape to respond to user preferences. + +## Theme Store Shape/Radius Capabilities + +**File:** `src/renderer/shared/stores/theme-store.ts` + +The theme store manages: +- `mode`: light | dark | system +- `colorTheme`: string (default or custom theme ID) +- `uiScale`: number (75-150) +- `customThemes`: CustomTheme[] + +**No shape or radius preferences exist.** There is no `borderRadius`, `shape`, `cornerStyle`, or similar property in the theme store. + +**File:** `src/renderer/shared/stores/layout-store.ts` + +The layout store manages: +- `sidebarLayout`: SidebarLayoutId (16 options) +- `toolbarStyle`: ToolbarStyleId (default | compact | spacious | floating | bordered | glass | minimal | inset) +- `contentLayout`: ContentLayoutId (flush | padded | bordered | inset) + +**No shape/radius preferences here either.** The toolbar style affects the toolbar container (height, background, border), but does NOT cascade to individual icon buttons within it. + +**Settings UI** (`src/renderer/features/settings/components/LayoutSection.tsx`): Has sidebar style, toolbar style, content area style, and color theme selectors. No shape/radius/corner controls. + +## Correct Usage (Button size="icon" family) + +**144 instances** across 80 files use `Button` with `size="icon"`, `size="icon-sm"`, `size="icon-xs"`, or `size="toolbar"`. + +Breakdown: +- `size="icon"`: ~133 instances across 78 files +- `size="icon-sm"`: 3 instances in `BrowserViewPanel.tsx` +- `size="icon-xs"`: 8 instances across 3 files +- `size="toolbar"` + `variant="toolbar"`: 6 instances across 2 files (TopBar.tsx, TitleBarScreenshot.tsx) + +These are all correctly using the @ui Button primitive. + +## Violations: Raw `<button>` Elements + +**28 raw `<button>` elements** across 18 files. Classified below by type: + +### Icon-Only Raw Buttons (should be `Button size="icon"`) + +These render only an icon with no visible text label -- prime candidates for `Button size="icon"` or `size="icon-xs"`. + +| # | File | Line | Context | Icon | +|---|------|------|---------|------| +| 1 | `src/renderer/app/layouts/ProjectTabBar.tsx` | 74 | "Add project" icon-only button | Plus | +| 2 | `src/renderer/shared/components/WebhookNotification.tsx` | 104 | Dismiss notification | X | +| 3 | `src/renderer/shared/components/HubNotification.tsx` | 125 | Dismiss hub notification | X | +| 4 | `src/renderer/shared/components/MutationErrorToast.tsx` | 103 | Dismiss toast | X | +| 5 | `src/renderer/shared/components/AppUpdateNotification.tsx` | 99 | Dismiss update notification | X | +| 6 | `src/renderer/shared/components/AuthNotification.tsx` | 74 | Dismiss auth notification | X | +| 7 | `src/renderer/shared/components/ui/search-input.tsx` | 38 | Clear search | X | +| 8 | `src/renderer/shared/components/ui/sidebar.tsx` | 255 | SidebarRail toggle | (invisible hit area) | + +### Text+Icon Raw Buttons (should be `Button` with variant) + +These have both icon and text -- should use `Button` with an appropriate variant instead of raw `<button>`. + +| # | File | Line | Context | +|---|------|------|---------| +| 9 | `src/renderer/app/router.tsx` | 64 | Error page "Retry" (icon + text) | +| 10 | `src/renderer/shared/components/IntegrationRequired.tsx` | 58 | "Set Up in Settings" (icon + text) | +| 11 | `src/renderer/shared/components/error-boundaries/FeatureErrorBoundary.tsx` | 91 | "Retry" (icon + text) | +| 12 | `src/renderer/shared/components/error-boundaries/FeatureErrorBoundary.tsx` | 99 | "Copy Error Details" (icon + text) | +| 13 | `src/renderer/shared/components/error-boundaries/RouteErrorBoundary.tsx` | 105 | "Go to Dashboard" (icon + text) | +| 14 | `src/renderer/shared/components/error-boundaries/RouteErrorBoundary.tsx` | 113 | "Retry" (icon + text) | +| 15 | `src/renderer/shared/components/error-boundaries/RouteErrorBoundary.tsx` | 122 | "Copy Error Details" (icon + text) | +| 16 | `src/renderer/shared/components/error-boundaries/RootErrorBoundary.tsx` | 88 | "Reload App" (icon + text) | +| 17 | `src/renderer/shared/components/error-boundaries/RootErrorBoundary.tsx` | 96 | "Copy Error Details" (icon + text) | +| 18 | `src/renderer/shared/components/AppUpdateNotification.tsx` | 82 | "Restart" (text only) | +| 19 | `src/renderer/shared/components/AppUpdateNotification.tsx` | 90 | "Download" (text only) | +| 20 | `src/renderer/shared/components/AuthNotification.tsx` | 53 | "Authorize in Settings" (icon + text) | +| 21 | `src/renderer/shared/components/ConfirmDialog.tsx` | 109 | Cancel button (text only) | +| 22 | `src/renderer/shared/components/ConfirmDialog.tsx` | 120 | Confirm button (text only) | + +### Composite/Special Raw Buttons (contextual -- may need different treatment) + +| # | File | Line | Context | +|---|------|------|---------| +| 23 | `src/renderer/app/layouts/ProjectTabBar.tsx` | 49 | Tab button (icon + text + close button) | +| 24 | `src/renderer/shared/components/HubStatus.tsx` | 66 | Hub status indicator (icon/dot + text) | +| 25 | `src/renderer/shared/components/HubConnectionIndicator.tsx` | 73 | Hub connection sidebar item (dot + text) | +| 26 | `src/renderer/app/layouts/UserMenu.tsx` | 65 | User menu trigger (avatar + name + chevron) | +| 27 | `src/renderer/features/test-suite/components/ScreenshotGallery.tsx` | 74 | Screenshot card (image + metadata) | +| 28 | `src/renderer/features/test-suite/components/ScreenshotGallery.tsx` | 141 | Thumbnail nav strip button (image) | + +## Summary Counts + +| Category | Count | +|----------|-------| +| **Correct usage** (Button size="icon/icon-sm/icon-xs/toolbar") | **144** | +| **Icon-only raw `<button>` violations** | **8** | +| **Text+Icon raw `<button>` violations** | **14** | +| **Composite/special raw `<button>`** | **6** | +| **Total raw `<button>`** | **28** | + +## Analysis + +### What works well +- The `size="toolbar"` + `variant="toolbar"` pattern in TopBar.tsx is clean and consistent +- The `size="icon-xs"` variant is well-used in dense UI (task grids, step lists) +- ~84% of icon buttons (144 of 172 total) correctly use @ui Button + +### Gaps + +1. **No shape preference system.** The `rounded-md` in Button base is hardcoded. There is no way for users to choose square, rounded, or pill icon buttons. Toolbar style (`floating`, `bordered`, etc.) only affects the toolbar container, not the buttons inside it. + +2. **No `IconButton` convenience component.** Every icon button must manually compose `<Button size="icon" variant="ghost">` + child SVG. An `IconButton` could enforce `aria-label`, tooltip, and shape consistency. + +3. **Dismiss buttons are all raw `<button>`.** The 5 notification dismiss patterns (WebhookNotification, HubNotification, MutationErrorToast, AppUpdateNotification, AuthNotification) all use raw `<button>` with identical styling (`text-muted-foreground hover:text-foreground shrink-0 p-0.5` + X icon). This is a clear extraction candidate. + +4. **Error boundaries use raw `<button>` by design.** The 3 error boundary files (Root, Route, Feature) are class components that deliberately avoid importing @ui to minimize dependency risk in crash scenarios. These are defensible violations. + +5. **SearchInput's clear button is raw.** The @ui `search-input.tsx` component itself uses a raw `<button>` for the clear action rather than using the @ui Button. + +## Recommended Approach + +### 1. Add shape preference to theme store + +```ts +// In theme-store.ts +type IconButtonShape = 'rounded' | 'square' | 'pill'; + +interface ThemeState { + // ... existing + iconButtonShape: IconButtonShape; + setIconButtonShape: (shape: IconButtonShape) => void; +} +``` + +Map to CSS custom property: `--icon-button-radius: var(--radius-md | 0 | 9999px)` + +### 2. Create `IconButton` convenience component + +```tsx +// src/renderer/shared/components/ui/icon-button.tsx +interface IconButtonProps extends Omit<ButtonProps, 'size' | 'children'> { + icon: React.ComponentType<{ className?: string }>; + label: string; // Required aria-label + size?: 'xs' | 'sm' | 'md' | 'lg' | 'toolbar'; + tooltip?: boolean; // Auto-wrap in Tooltip +} +``` + +This enforces: +- Required `aria-label` via the `label` prop +- Shape awareness via theme store subscription +- Consistent icon sizing per button size +- Optional tooltip integration + +### 3. Create `DismissButton` extraction + +A small `DismissButton` component for the 5+ notification dismiss patterns: +```tsx +function DismissButton({ onDismiss, label }: { onDismiss: () => void; label?: string }) { + return ( + <IconButton icon={X} label={label ?? 'Dismiss'} size="xs" variant="ghost" /> + ); +} +``` + +### 4. Fix priority order + +1. **SearchInput** (in @ui itself -- highest visibility) +2. **Notification dismiss buttons** (5 files, identical pattern) +3. **ProjectTabBar** add-project button (1 file) +4. **HubStatus / HubConnectionIndicator** (sidebar visibility) +5. **ConfirmDialog** cancel/confirm buttons (2 raw buttons) +6. **Error boundaries** -- leave as-is (crash-safety justification) + +### 5. Shape-aware Button variant (alternative to IconButton) + +Instead of a new component, extend the existing Button CVA with a CSS custom property approach: + +```ts +// In button.tsx base string, replace hardcoded rounded-md: +'rounded-[var(--btn-radius,0.375rem)]' + +// icon-sm / icon-xs override: +'rounded-[var(--btn-radius-sm,0.125rem)]' +``` + +Then the theme store sets `--btn-radius` on `<html>` based on user preference. This approach is lighter than a new component and applies to ALL buttons automatically. diff --git a/docs/cleanup/item-4-spacing-research.md b/docs/cleanup/item-4-spacing-research.md new file mode 100644 index 00000000..7d6c2a7b --- /dev/null +++ b/docs/cleanup/item-4-spacing-research.md @@ -0,0 +1,274 @@ +# Item 4: Layout Spacing Standardization Research + +## 1. Current Theme Store Pattern (how values are applied to `<html>`) + +**File:** `src/renderer/shared/stores/theme-store.ts` + +The theme store uses Zustand and applies values to `document.documentElement` via three mechanisms: + +- **Mode (dark/light):** `applyMode()` adds/removes CSS classes on `<html>` (`classList.add('dark')`) +- **Custom tokens:** `applyCustomTokens()` calls `root.style.setProperty('--token', value)` for each color token +- **UI Scale:** `applyUiScale()` calls `root.setAttribute('data-ui-scale', String(scale))` + +The `data-ui-scale` attribute is then consumed in `globals.css` by attribute selectors: + +```css +[data-ui-scale='100'] { font-size: 16px; } +[data-ui-scale='105'] { font-size: 16.8px; } +/* ... etc for 75-150 in steps of 5 */ +``` + +**Recommended approach for spacing:** Use `document.documentElement.style.setProperty('--layout-gap', value)` directly, which is simpler than the data-attribute approach since we need a continuous CSS custom property value, not discrete steps that map to fixed values. + +--- + +## 2. Settings > Display Slider Implementation + +**File:** `src/renderer/features/settings/components/UiScaleSection.tsx` + +Pattern: +- Constants: `UI_SCALE_MIN = 75`, `UI_SCALE_MAX = 150`, `UI_SCALE_STEP = 5` +- Component receives `currentScale: number` and `onScaleChange` callback as props +- Uses `<Input type="range">` from `@ui` (not raw HTML) +- Shows min/max labels on left/right, current value centered below +- Styled: `className="bg-muted accent-primary h-2 flex-1 cursor-pointer appearance-none rounded-full border-0"` + +**Wiring in SettingsPage** (`src/renderer/features/settings/components/SettingsPage.tsx`, line 108): +```tsx +<UiScaleSection currentScale={uiScale} onScaleChange={handleUiScaleChange} /> +``` + +The handler (line 63): +```tsx +function handleUiScaleChange(event: React.ChangeEvent<HTMLInputElement>) { + const scale = Number(event.target.value); + setUiScale(scale); // Updates Zustand store + applies to DOM + updateSettings.mutate({ uiScale: scale }); // Persists via IPC +} +``` + +**Persistence path:** +1. Zustand store setter applies to DOM immediately +2. `updateSettings.mutate()` calls `ipc(SETTINGS.UPDATE.ALL, updates)` which is a `z.record(z.string(), z.unknown())` input +3. On app load, `useSettings` queryFn fetches settings and calls `setUiScale(settings.uiScale)` to restore + +--- + +## 3. Inventory of All Layout Gap/Spacing Values + +### A. Page Layout Primitives (`src/renderer/shared/components/ui/page-layout.tsx`) + +| Component | Line | Classes | Layout Role | +|---|---|---|---| +| `PageLayout` | 47 | `flex h-full w-full flex-col` | No gap — pure flex column | +| `PageHeader` | 69 | `flex w-full shrink-0 flex-col border-b` | No gap/padding — children control it | +| `PageHeader.Row` | 90 | `gap-4 px-6 py-4` | **gap-4** between title/actions, **px-6 py-4** padding | +| `PageHeader.Actions` | 132 | `gap-2` | **gap-2** between action buttons | +| `PageHeader.TabList` | 163 | `gap-1 px-6` | **gap-1** between tabs, **px-6** horizontal padding | +| `PageHeader.Tab` | 181 | `gap-2 px-3 py-2` | **gap-2** icon-to-label, **px-3 py-2** internal | +| `PageContent` | 212 | `px-6 py-4` | **px-6 py-4** — main content padding | + +### B. App Shell Layouts (`src/renderer/app/layouts/`) + +| Component | File | Line | Classes | Layout Role | +|---|---|---|---|---| +| RootLayout root | `RootLayout.tsx` | 78 | `flex h-screen flex-col` | No gap | +| LayoutWrapper | `LayoutWrapper.tsx` | 18 | `SidebarProvider className="h-full"` | No explicit gap | +| SidebarInset | `sidebar.tsx` | 283 | `relative flex w-full min-w-0 flex-1 flex-col` | Sidebar-to-content: handled by sidebar gap element | +| ContentAreaContainer (flush) | `ContentAreaContainer.tsx` | 23 | (empty string) | No gap/padding | +| ContentAreaContainer (padded) | `ContentAreaContainer.tsx` | 24 | `p-2 gap-2` | **p-2 gap-2** | +| ContentAreaContainer (bordered) | `ContentAreaContainer.tsx` | 25 | `p-2 gap-2` | **p-2 gap-2** | +| ContentAreaContainer (inset) | `ContentAreaContainer.tsx` | 26 | `p-3 gap-3` | **p-3 gap-3** | +| TopBar | `TopBar.tsx` | 102 | `flex shrink-0 items-stretch` + height from TOOLBAR_CLASSES | No gap — items stretch | +| TitleBar | `TitleBar.tsx` | 70 | `flex h-8 shrink-0 items-center border-b` | No gap | +| ContentHeader | `ContentHeader.tsx` | 16 | `gap-2 px-3` | **gap-2** between trigger/breadcrumbs | +| ProjectTabBar | `ProjectTabBar.tsx` | 44 | `gap-px px-1` | **gap-px** between tab buttons | + +### C. Sidebar Internals (`src/renderer/shared/components/ui/sidebar.tsx`) + +| Component | Line | Classes | Layout Role | +|---|---|---|---| +| SidebarHeader | 311 | `gap-2 p-2` | **gap-2 p-2** | +| SidebarFooter | 322 | `gap-2 p-2` | **gap-2 p-2** | +| SidebarContent | 350 | `gap-2` | **gap-2** between groups | +| SidebarGroup | 363 | `p-2` | **p-2** group padding | +| SidebarMenu | 430 | `gap-1` | **gap-1** between menu items | +| SidebarMenuSub | 603 | `gap-1 px-2.5 py-0.5` | **gap-1** sub items | +| SidebarMenuButton | 450 | `gap-2 p-2` | **gap-2** icon-to-text | +| SidebarMenuSkeleton | 578 | `gap-2 px-2` | **gap-2** skeleton items | + +### D. Composition Components (`src/renderer/shared/components/ui/composition/`) + +| Component | File | Line | Classes | Layout Role | +|---|---|---|---|---| +| FilterBar | `FilterBar.tsx` | 167 | `gap-3 px-4 py-3` | **gap-3** between filters, **px-4 py-3** padding | +| FilterBar (multi-select) | `FilterBar.tsx` | 56 | `gap-1` | **gap-1** between toggle buttons | +| ActionBar | `ActionBar.tsx` | 39 | `gap-2 px-4 py-3` | **gap-2** between actions, **px-4 py-3** padding | +| DetailPanel header | `DetailPanel.tsx` | 63 | `px-4 py-3` | **px-4 py-3** | +| DetailPanel content | `DetailPanel.tsx` | 76 | `px-4 py-4` | **px-4 py-4** | + +### E. Sidebar Gap Element (`sidebar.tsx`, line 186-194) + +The sidebar uses a "gap element" div for the space between sidebar and content: +- Default variant: width matches `--sidebar-width` (no additional gap) +- Floating/inset variant: adds `+16px` for icon mode + +There is no explicit gap between sidebar and content — the sidebar-gap div is a sizing spacer for the fixed sidebar, not a visible gap. + +--- + +## 4. Layout Region Map + +``` ++------------------------------------------------------------------+ +| TopBar (h-8 to h-12 depending on toolbar style) | +| [SidebarToggle | ProjectTabs ... | Spacer | Utils | WinControls]| ++------------------------------------------------------------------+ +| | | +| Sidebar | Content Area | +| (16rem / | +----------------------------------------------+ | +| 3rem icon) | | PageHeader (border-b) | | +| | | Row: px-6 py-4 gap-4 | | +| | | TabList: px-6 gap-1 | | +| | +----------------------------------------------+ | +| Header p-2 | | PageContent: px-6 py-4 | | +| Content | | | | +| gap-2 | | [FilterBar: px-4 py-3 gap-3] | | +| Group p-2 | | [Main content area] | | +| Menu gap-1 | | [ActionBar: px-4 py-3 gap-2] | | +| Footer p-2 | | | | +| | +----------------------------------------------+ | ++------------------------------------------------------------------+ +``` + +### Region Gaps: + +| Region Boundary | Current Gap | Where Defined | +|---|---|---| +| **Sidebar <-> Content** | 0px (flush) / sidebar-gap element | `sidebar.tsx` line 186 | +| **TopBar <-> Content** | 0px (flush layout) / `gap-2` (padded) / `gap-3` (inset) | `ContentAreaContainer.tsx` | +| **PageHeader <-> PageContent** | 0px (border-b provides visual separation) | `page-layout.tsx` | +| **TabList <-> TabContent** | 0px (tabs sit in header, content in PageContent) | `page-layout.tsx` | +| **FilterBar <-> Content** | Managed by consumer padding | Per-feature | +| **Content <-> ActionBar** | 0px (sticky bottom with border-t) | `ActionBar.tsx` | +| **DetailPanel header <-> content** | 0px (border-b separation) | `DetailPanel.tsx` | + +--- + +## 5. Hardcoded Pixel/Rem Values in Inline Styles + +**No inline `style={{ gap: ... }}` or `style={{ padding: ... }}` found** in any layout component. All spacing uses Tailwind utility classes. This is good for standardization. + +The only inline styles related to layout are: +- `sidebar.tsx` line 129: `--sidebar-width: 16rem` and `--sidebar-width-icon: 3rem` (CSS custom properties, appropriate) + +--- + +## 6. Recommended CSS Custom Property Design + +### Property: `--layout-gap` + +A single CSS custom property that controls the spacing between major layout regions. This maps to a `rem` value. + +**Default:** `0.5rem` (8px at default scale = `gap-2`) + +**Tailwind integration:** In `globals.css`, define inside `:root` and `.dark`: +```css +:root { + --layout-gap: 0.5rem; +} +``` + +Then in the `@theme` block: +```css +@theme { + --spacing-layout: var(--layout-gap); +} +``` + +This enables `gap-layout`, `p-layout`, `px-layout`, `py-layout` as Tailwind utilities. + +### What it controls (and what it does NOT): + +**Controlled by `--layout-gap`** (layout shell spacing): +- `ContentAreaContainer` container padding and gap (currently `p-2 gap-2` / `p-3 gap-3`) +- `PageHeader.Row` padding (`px-6 py-4` -> could use a multiplied variant) +- `PageContent` padding (`px-6 py-4`) +- `FilterBar` padding (`px-4 py-3`) +- `ActionBar` padding (`px-4 py-3`) +- `DetailPanel` header/content padding (`px-4 py-3`, `px-4 py-4`) + +**NOT controlled** (component-internal spacing that should remain fixed): +- `gap-1` between menu items, tabs, action buttons (micro-spacing) +- `gap-2` between icon and text in buttons/menu items (element-level) +- `p-2` inside sidebar groups (component density, not layout) +- `px-3 py-2` inside tab triggers (component sizing) + +### Derived properties for proportional scaling: + +```css +:root { + --layout-gap: 0.5rem; /* base: 8px */ + --layout-gap-sm: calc(var(--layout-gap) * 0.75); /* 6px */ + --layout-gap-lg: calc(var(--layout-gap) * 1.5); /* 12px */ + --layout-pad-x: calc(var(--layout-gap) * 3); /* 24px = px-6 equivalent */ + --layout-pad-y: calc(var(--layout-gap) * 2); /* 16px = py-4 equivalent */ +} +``` + +--- + +## 7. Recommended Slider Range and Steps + +| Property | Value | +|---|---| +| **Min** | 0 (flush — zero gaps between regions) | +| **Max** | 16 (spacious — `1rem` = 16px base gap) | +| **Step** | 2 (9 discrete positions: 0, 2, 4, 6, 8, 10, 12, 14, 16) | +| **Default** | 8 (= `0.5rem`, matches current `gap-2` / `p-2`) | +| **Unit** | pixels (displayed) / rem (applied as `--layout-gap: ${value/16}rem`) | + +**Preset labels (optional):** +- 0 = Flush +- 4 = Compact +- 8 = Default +- 12 = Relaxed +- 16 = Spacious + +--- + +## 8. Files That Need Modification + +### New/Modified for the feature: + +| File | Change | +|---|---| +| `src/renderer/shared/stores/theme-store.ts` | Add `layoutGap: number`, `setLayoutGap()`, `applyLayoutGap()` | +| `src/renderer/styles/globals.css` | Add `--layout-gap` to `:root`, add `--spacing-layout` to `@theme` block, add derived properties | +| `src/renderer/features/settings/components/SettingsPage.tsx` | Wire new `SpacingSection` into Display tab | +| `src/renderer/features/settings/components/SpacingSection.tsx` | **New file** — slider component (copy UiScaleSection pattern) | +| `src/shared/ipc/settings/schemas.ts` | Add `layoutGap: z.number().optional()` to `AppSettingsSchema` | +| `src/renderer/features/settings/api/useSettings.ts` | Add `setLayoutGap(settings.layoutGap)` to queryFn | + +### Layout components to convert to use `--layout-gap`: + +| File | Lines to Change | Current | New | +|---|---|---|---| +| `src/renderer/app/layouts/ContentAreaContainer.tsx` | 24-26 | `p-2 gap-2` / `p-3 gap-3` | Use `--layout-gap` CSS var via Tailwind custom spacing | +| `src/renderer/shared/components/ui/page-layout.tsx` | 90, 212 | `px-6 py-4` | `px-[var(--layout-pad-x)] py-[var(--layout-pad-y)]` or `p-layout-*` | +| `src/renderer/shared/components/ui/composition/FilterBar.tsx` | 167 | `gap-3 px-4 py-3` | Use layout gap tokens | +| `src/renderer/shared/components/ui/composition/ActionBar.tsx` | 39 | `gap-2 px-4 py-3` | Use layout gap tokens | +| `src/renderer/shared/components/ui/composition/DetailPanel.tsx` | 63, 76 | `px-4 py-3`, `px-4 py-4` | Use layout gap tokens | +| `src/renderer/app/layouts/ContentHeader.tsx` | 16 | `gap-2 px-3` | Use layout gap tokens | + +### Settings page sections (minor — padding for sections): + +| File | Current | Note | +|---|---|---| +| `src/renderer/features/settings/components/SettingsPage.tsx` | `mb-8` on sections | Could be driven by `--layout-gap` but lower priority | + +### Main process (persistence): + +| File | Change | +|---|---| +| `src/main/features/settings/settings-service.ts` | Accept `layoutGap` in update handler, persist to settings.json | diff --git a/docs/decisions/2026-04-14-category-5-scope-decisions.md b/docs/decisions/2026-04-14-category-5-scope-decisions.md new file mode 100644 index 00000000..bf7723af --- /dev/null +++ b/docs/decisions/2026-04-14-category-5-scope-decisions.md @@ -0,0 +1,168 @@ +# Category 5 Scope Decisions — 2026-04-14 + +These four items from the codebase issues breakdown (Category 5: Overkill / Scope Creep) require +product decisions before engineering can proceed. Each section describes what exists, what the +tradeoffs are, and presents the decision options. + +--- + +## Decision 1 — Personal Productivity Stack (Issue 5.1) + +### What exists + +Nine personal productivity domains embedded in ADC across migrations 0002, 0003, 0008: + +| Domain | SQLite tables | Status | +|--------|--------------|--------| +| `notes` | `notes` | Active (UI + IPC) | +| `ideas` / `ideation` | `ideas` | Active (UI + IPC) | +| `changelog` | `changelog_entries` | Active (IPC, stub UI) | +| `milestones` | `milestones`, `milestone_tasks` | Active (IPC + UI in roadmap) | +| `alerts` | `alerts` | Active (IPC + UI) | +| `planner` | `daily_plans`, `time_blocks`, `weekly_reviews` | Active (IPC + UI) | +| `briefing` | `daily_briefings`, `briefing_configs` | Active (IPC + UI) | +| `fitness` | `workouts`, `body_measurements`, `fitness_goals` | Active (IPC + UI) | +| `insights` | (none separate — uses progress data) | Partial | + +### The tension + +ADC's core purpose is **multi-project management + agent team orchestration**. Notes and ideas are +defensible inside a dev tool (capture context, track feature ideas). The rest — daily briefings, +body measurements, workout logs, time blocking, planner — belong to a personal productivity app, +not an agent orchestrator. + +Keeping all nine domains means ongoing maintenance burden for features unrelated to the core value. + +### Options + +**A. Trim the personal stack** — remove fitness, briefing, planner. Keep notes, ideas, changelog, +milestones, alerts. Changelog and milestones support project-scoped work; alerts notify on +deadlines. ~5 domains removed, 3 SQLite migrations partially rolled back or left as dead tables. + +**B. Fork personal stack** — extract fitness/briefing/planner/alerts into a separate Electron app +that shares the SQLite file. ADC stays focused; the personal app can grow independently. + +**C. Keep everything** — accept ADC as a "life OS" product, not just a dev tool. Full personal +productivity stack stays. Accept the maintenance cost. + +**D. Keep everything, de-emphasize in UI** — hide personal features behind a settings flag. +No removal, minimal ongoing effort. + +### Decision needed + +Which personal domains should ADC own long-term? (Options A–D, or a custom subset.) + +--- + +## Decision 2 — Six MCP Servers (Issue 5.2) + +### What exists + +Six in-process MCP servers at `src/main/mcp-servers/`: + +| Server | Tie to agent orchestration | +|--------|---------------------------| +| `browser` | Strong — agents browse, test, interact with web | +| `github` | Strong — agents work on git repos, read PRs/issues | +| `discord` | Weak — notification channel, no agent workflow | +| `slack` | Weak — notification channel, no agent workflow | +| `spotify` | None — music playback | +| `calendar` | Weak — scheduling, not directly agent-related | + +### The tension + +Each MCP server adds connection overhead, auth surface area, and maintenance cost. Discord, Slack, +and Spotify have no obvious agent-orchestration use case. Calendar is borderline (sprint planning?). + +### Options + +**A. Trim to browser + github** — remove discord, slack, spotify, calendar. Reduces surface by 4. + +**B. Trim to browser + github + calendar** — keep calendar for sprint planning / deadline awareness. + +**C. Keep all six** — accept the breadth; each integration adds value for different users. + +**D. Move non-core MCPs to optional plugins** — load on demand, not always-on. + +### Decision needed + +Which MCP servers should ship with ADC core? (Options A–D.) + +--- + +## Decision 3 — Visualization Feature / xyflow Node Graph (Issue 5.3) + +### What exists + +A full `@xyflow/react` feature at `src/renderer/features/visualization/` with: +- Canvas, edges, nodes, panels, toolbar components +- IPC domain + main + renderer +- EventBridge integration wiring `BUS_EVENTS.SESSION.*` to agent team node graph + +Sprint 6–7 wired the EventBridge so the node graph now updates live as agent sessions start/stop. + +### The tension + +The node graph visualizes agent team structure during a session. This is directly tied to the core +use case (agent orchestration). However it is a heavyweight dependency (@xyflow) and the feature +is still young. + +### Options + +**A. Keep and expand** — the node graph is the "cockpit" for watching agent teams. Invest in it. + +**B. Keep as-is, de-prioritize** — ship what's there, don't add more. Revisit later. + +**C. Replace with a simpler list view** — remove @xyflow, show agents as a plain table. Lighter. + +**D. Remove** — agent orchestration works without visualization. Cut the scope. + +### Decision needed + +Is the xyflow node graph a core product feature worth investing in, or should it be simplified/removed? + +--- + +## Decision 4 — Workflow Pipeline Builder UI (Issue 5.4) + +### What exists + +`src/renderer/features/workflow-pipeline/` — a pipeline builder UI for composing workflows. +Related backend: `workflow/engine` IPC domain with `WORKFLOW.LAUNCH.WORKFLOW` which currently +throws "use command bus instead". + +`workflow-engine` and `workflow-templates` IPC domains are partially deprecated — they have active +renderer callers but the main process has moved to `busSessionManager` (command bus). A migration +sprint is needed to reconcile them (deferred from Sprint 9 Task 3 & 4). + +### The tension + +The workflow pipeline builder sits on top of a half-migrated engine. The command bus direction +(spawning sessions directly) is simpler and already working. A pipeline builder implies users can +define multi-step automated workflows, which is a significant product capability. + +### Options + +**A. Finish the pipeline builder** — complete the migration of workflow-engine + workflow-templates +onto the command bus, then build out the pipeline builder UI as a first-class feature. + +**B. Retire the pipeline builder** — remove `workflow-pipeline` renderer feature and +`workflow-engine` / `workflow-templates` IPC domains. The command bus (direct session spawning) is +the workflow primitive. + +**C. Defer** — leave in its current half-migrated state, prioritize other sprints. + +### Decision needed + +Is the workflow pipeline builder a core product feature (Option A) or should it be retired (Option B)? + +--- + +## Summary Table + +| # | Issue | Decision needed | Options | +|---|-------|----------------|---------| +| 1 | Personal productivity stack | Which domains stay in ADC? | A (trim) / B (fork) / C (keep all) / D (hide) | +| 2 | MCP servers | Which servers ship with core? | A (browser+github only) / B (+calendar) / C (all) / D (optional plugins) | +| 3 | Visualization / xyflow | Core feature or cut? | A (expand) / B (keep as-is) / C (simplify) / D (remove) | +| 4 | Workflow pipeline builder | Finish or retire? | A (finish) / B (retire) / C (defer) | diff --git a/docs/features/agent-dashboard-view/plan.md b/docs/features/agent-dashboard-view/plan.md deleted file mode 100644 index ab603cec..00000000 --- a/docs/features/agent-dashboard-view/plan.md +++ /dev/null @@ -1,818 +0,0 @@ -# Agent Dashboard View — UI Expectations & Layout Spec - -**Date**: 2026-03-30 -**Status**: DRAFT -**Slug**: `agent-dashboard-view` -**Style Reference**: OpenCode TUI + OpenChamber Desktop (chat-style agent output, structured tool calls, diff viewer, git sidebar) - ---- - -## Visual References - -### Primary Reference: OpenChamber (React + Tauri frontend for OpenCode) - -OpenChamber is the closest existing implementation to what we're building. It's a React desktop app that renders OpenCode's structured output as a proper GUI. - -**GitHub**: https://github.com/openchamber/openchamber -**Tech Stack**: React + Vite + Tauri (desktop), Ghostty-web (terminal), Pierre.js (diff viewer), PostCSS themes - -**Screenshots** (reference for our UI design): - -| View | URL | What to study | -|------|-----|---------------| -| Chat Interface | `https://raw.githubusercontent.com/openchamber/openchamber/main/docs/references/chat_example.png` | Message layout, tool call rendering, input area | -| Tool Output | `https://raw.githubusercontent.com/openchamber/openchamber/main/docs/references/tool_output_example.png` | How tool calls (Read, Edit, Bash) are displayed as cards | -| Diff View | `https://raw.githubusercontent.com/openchamber/openchamber/main/docs/references/diff_example.png` | Inline diff rendering, file change display | -| Settings | `https://raw.githubusercontent.com/openchamber/openchamber/main/docs/references/settings_example.png` | Settings panel layout | -| Web Version | `https://raw.githubusercontent.com/openchamber/openchamber/main/docs/references/web_version_example.png` | Full layout with sidebar + chat + panels | -| Mobile Chat | `https://raw.githubusercontent.com/openchamber/openchamber/main/docs/references/pwa_chat_example.png` | Responsive chat design | -| Mobile Diff | `https://raw.githubusercontent.com/openchamber/openchamber/main/docs/references/pwa_diff_example.png` | Responsive diff design | -| VS Code Extension | `https://raw.githubusercontent.com/openchamber/openchamber/main/packages/vscode/extension.jpg` | Sidebar integration pattern | - -**Key implementation details from OpenChamber:** -- Uses `ghostty-web` for terminal rendering (Ghostty compiled to WASM) -- Uses `pierre.js` for diff viewing with syntax highlighting -- Communicates with OpenCode backend via HTTP + Server-Sent Events (SSE) -- Supports 18+ built-in themes with custom theme hot-reload via JSON -- Branchable chat timeline — undo, redo, fork from any turn -- IndexedDB for client-side caching - -### Secondary Reference: OpenCode WebUI (mobile-first web interface) - -**GitHub**: https://github.com/threehymns/opencode-webui -**Tech Stack**: React 19 + Vite + SSE streaming - -**Demo GIFs**: -- Chat: `https://github.com/chriswritescode-dev/opencode-web/releases/download/0.3.0/Chat.gif` -- File editing: `https://github.com/chriswritescode-dev/opencode-web/releases/download/0.3.0/git-file-edit.gif` -- File context: `https://github.com/chriswritescode-dev/opencode-web/releases/download/0.2.5/file-context.gif` - -Features: git diff viewer with line numbers, directory tree navigation, @filename autocomplete, plan/build mode toggle - -### Additional References - -| Project | URL | What to study | -|---------|-----|---------------| -| OpenCode Snapshots | https://github.com/phishy/opencode-snapshots | Session timeline, file diff before/after toggle, snapshot browser | -| OpenCode Diff Viewer Plugin | https://github.com/AruNi-01/opencode-diff-viewer | TUI diff rendering using lumen | -| Diffity | https://github.com/kamranahmedse/diffity | GitHub-style diff viewer with AI review comments, severity tags | -| OpenCode GUI (VS Code) | https://github.com/ktmage/opencode-gui | Collapsible tool calls, reasoning steps, sidebar chat | - -### OpenCode Desktop Layout (reference description) - -The OpenCode Desktop app arranges panels as: -- **Chat Panel** (bottom) — typing queries, AI responds with text/code diffs/action previews -- **File Explorer** (left sidebar) — tree view of project files -- **Diff Viewer** (center) — proposed changes with green/red highlighting, Accept/Reject/Undo -- **Terminal Pane** (bottom-right) — embedded shell -- **Sessions Sidebar** (right) — multi-tab parallel agents - -Our layout differs because we have **multiple agents** rather than a single session, but the component patterns (chat rendering, tool call cards, diff display) should match. - ---- - -## Overview - -The Agents tab is the primary view for monitoring all Claude sessions across projects. It renders structured stream-json / session JSONL data as a chat-style UI — not raw terminal output. Every agent panel shows the same structured content: markdown messages, tool call cards, file edit diffs, task status, and errors. - ---- - -## Layout System - -### Layout Modes (user-selectable via toolbar toggle) - -``` -┌─────────────────────────────────────────────────────────┐ -│ Toolbar: [Layout ▾] [Project Filter ▾] [Status Filter] │ -│ Single | 2-Column | 3-Column | Grid | Multi │ -└─────────────────────────────────────────────────────────┘ -``` - -#### 1. Single (default) - -Main session takes the left 60%, team lead + agents stack on the right 40%. - -``` -┌────────────────────────────┬──────────────────────┐ -│ │ Team Lead │ -│ Main Session │ (compact) │ -│ (Project Owner) ├──────────────────────┤ -│ │ Agent #1 (compact) │ -│ Full chat view ├──────────────────────┤ -│ Messages, tool calls │ Agent #2 (compact) │ -│ Input box at bottom ├──────────────────────┤ -│ │ Agent #3 (compact) │ -│ ├──────────────────────┤ -│ │ ...grows vertically │ -└────────────────────────────┴──────────────────────┘ -``` - -#### 2. Two-Column - -Main and Team Lead side by side (equal width). Agents below in a scrollable row. - -``` -┌───────────────────────┬───────────────────────┐ -│ │ │ -│ Main Session │ Team Lead │ -│ │ │ -│ │ │ -├───────┬───────┬───────┼───────┬───────┬───────┤ -│ Agt 1 │ Agt 2 │ Agt 3 │ Agt 4 │ Agt 5 │ ... │ -│(compact cards, horizontally scrollable) │ -└───────┴───────┴───────┴───────┴───────┴───────┘ -``` - -#### 3. Three-Column - -Main, Team Lead, and a third column for the selected/active agent expanded. - -``` -┌──────────────┬──────────────┬──────────────────┐ -│ │ │ │ -│ Main │ Team Lead │ Selected Agent │ -│ Session │ │ (expanded) │ -│ │ │ │ -│ │ │ Full chat │ -│ │ │ Task list │ -│ │ │ Error log │ -└──────────────┴──────────────┴──────────────────┘ - Agent selector tabs/list below or in sidebar -``` - -#### 4. Grid - -All agents in equal-sized cells. Auto-wraps based on window width. - -``` -┌──────────┬──────────┬──────────┬──────────┐ -│ Main │ Team │ Agent │ Agent │ -│ Session │ Lead │ #1 │ #2 │ -│ │ │ │ │ -├──────────┼──────────┼──────────┼──────────┤ -│ Agent │ Agent │ Agent │ Agent │ -│ #3 │ #4 │ #5 │ #6 │ -│ │ │ │ │ -└──────────┴──────────┴──────────┴──────────┘ -``` - -#### 5. Multi-Project - -Multiple projects stacked vertically, each with its own Main + Team Lead + Agents row. Horizontal scrolling within each project row. - -``` -┌─ Project A ─────────────────────────────────┐ -│ ┌─────────┐ ┌──────────┐ ┌─────┐ ┌─────┐ │ -│ │ Main │ │ Team Lead│ │Agt 1│ │Agt 2│ │ -│ └─────────┘ └──────────┘ └─────┘ └─────┘ │ -├─ Project B ─────────────────────────────────┤ -│ ┌─────────┐ ┌──────────┐ ┌─────┐ │ -│ │ Main │ │ Team Lead│ │Agt 1│ │ -│ └─────────┘ └──────────┘ └─────┘ │ -├─ Project C (no team — solo session) ────────┤ -│ ┌─────────┐ │ -│ │ Main │ │ -│ └─────────┘ │ -└─────────────────────────────────────────────┘ -``` - ---- - -## Agent Panel States - -Every agent panel has three states, toggled by user interaction: - -### 1. Compact (default in lists/grids) - -Shows minimal status at a glance. Fixed height (~120px). - -``` -┌──────────────────────────────────────┐ -│ ● Agent #1 — component-engineer │ ← colored dot = status -│ Model: sonnet │ Task: #3 │ -│ Status: Running │ Tokens: 12.4K │ -│ │ -│ Last: "Implementing the FileTree │ ← truncated last message -│ component with react-arborist..." │ -│ │ -│ [↗ Expand] [⬚ Popup] │ ← action buttons -└──────────────────────────────────────┘ -``` - -**Status dot colors:** -- 🟢 Green: Running (streaming output) -- 🔵 Blue: Idle (waiting for input / task) -- 🟡 Yellow: Needs attention (permission prompt, error recovery) -- 🔴 Red: Failed / crashed -- ⚪ Gray: Completed / shut down - -### 2. Expanded (click to grow) - -Panel expands in-place within the layout. Shows full agent detail with tabs. - -``` -┌──────────────────────────────────────────────┐ -│ ● Agent #1 — component-engineer [─ ⬚]│ -│ Model: sonnet │ Tokens: 12.4K/45.2K │ -│ Task: #3 — FileTree component │ -│ Branch: work/DASH-003/file-tree-panel │ -├──────────────────────────────────────────────┤ -│ [Chat] [Tasks] [Files Changed] [Errors] │ ← tabs -├──────────────────────────────────────────────┤ -│ │ -│ Chat Tab (default): │ -│ ┌────────────────────────────────────────┐ │ -│ │ 🤖 Let me read the task file first. │ │ -│ │ │ │ -│ │ ┌─ Read ───────────────────────────┐ │ │ -│ │ │ progress/DASH-003/tasks/ │ │ │ -│ │ │ task-3.md │ │ │ -│ │ └─────────────────────────────────┘ │ │ -│ │ │ │ -│ │ 🤖 I'll implement the FileTree │ │ -│ │ component using react-arborist... │ │ -│ │ │ │ -│ │ ┌─ Edit ───────────────────────────┐ │ │ -│ │ │ src/renderer/features/files/ │ │ │ -│ │ │ components/FileTree.tsx │ │ │ -│ │ │ +42 -0 [View Diff] │ │ │ -│ │ └─────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌─ Bash ───────────────────────────┐ │ │ -│ │ │ $ npm run build │ │ │ -│ │ │ ✓ Build succeeded (4.2s) │ │ │ -│ │ └─────────────────────────────────┘ │ │ -│ └────────────────────────────────────────┘ │ -│ │ -│ Tasks Tab: │ -│ ☑ Phase 0: Load rules + read task file │ -│ ☑ Phase 1: Write execution plan │ -│ ◻ Phase 2: Implement FileTree component │ ← in progress -│ ◻ Phase 3: Self-review + npm run build │ -│ ◻ Phase 4: Report to team leader │ -│ │ -│ Files Changed Tab: │ -│ M src/renderer/features/files/FileTree.tsx │ -│ A src/renderer/features/files/useFileTree.ts│ -│ (click any file → diff viewer) │ -│ │ -│ Errors Tab: │ -│ (empty — no errors) │ -└──────────────────────────────────────────────┘ -``` - -### 3. Popup (click popup button → styled modal) - -A proper modal dialog with header, description, metadata bar, and tabbed content. Not a raw overlay — a designed, structured view of everything about this agent. Dismissable via Escape, close button, or clicking the backdrop. - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ │ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ ┌─ Header ─────────────────────────────────────────────┐ │ │ -│ │ │ │ │ │ -│ │ │ ● Agent #1 — component-engineer [ ╳ ] │ │ │ -│ │ │ │ │ │ -│ │ │ Building the FileTree component using react-arborist │ │ │ -│ │ │ with live filesystem watching and drag-drop support. │ │ │ -│ │ │ │ │ │ -│ │ │ ┌──────────┬───────────┬──────────┬───────────────┐ │ │ │ -│ │ │ │ Model │ Task │ Branch │ Duration │ │ │ │ -│ │ │ │ sonnet │ #3 of 6 │ work/... │ 4m 32s │ │ │ │ -│ │ │ ├──────────┼───────────┼──────────┼───────────────┤ │ │ │ -│ │ │ │ Status │ Tokens │ Cost │ Files Changed │ │ │ │ -│ │ │ │ Running │ 12.4K in │ $0.018 │ 3 files │ │ │ │ -│ │ │ │ │ 2.1K out │ │ +142 -8 │ │ │ │ -│ │ │ └──────────┴───────────┴──────────┴───────────────┘ │ │ │ -│ │ │ │ │ │ -│ │ └───────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌─ Tabs ───────────────────────────────────────────────┐ │ │ -│ │ │ [Chat] [Tasks] [Files Changed] [Errors] [Terminal] │ │ │ -│ │ └──────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌─ Tab Content ────────────────────────────────────────┐ │ │ -│ │ │ │ │ │ -│ │ │ (content for selected tab — see below) │ │ │ -│ │ │ │ │ │ -│ │ │ │ │ │ -│ │ │ │ │ │ -│ │ │ │ │ │ -│ │ │ │ │ │ -│ │ │ │ │ │ -│ │ └──────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌─ Input ─────────────────────────────────────────────┐ │ │ -│ │ │ Type a message to this agent... [Send ↵] │ │ │ -│ │ └─────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ └────────────────────────────────────────────────────────────┘ │ -│ │ -└──────────────────────────────────────────────────────────────────┘ - ↑ backdrop (click to dismiss) -``` - -#### Popup Tab: Chat - -Full scrollable conversation history. Same rendering as the expanded panel chat (markdown messages, tool call cards, file edit diffs) but with maximum vertical space. Auto-scrolls to latest message. Scroll up to browse history. - -``` -┌──────────────────────────────────────────────────────┐ -│ 🤖 Let me read the task file first. │ -│ │ -│ ┌─ Read ──────────────────────────────────────────┐ │ -│ │ progress/DASH-003/tasks/task-3.md │ │ -│ │ [▸ Show content] │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ 🤖 I'll implement the FileTree component using │ -│ react-arborist. Here's my execution plan: │ -│ │ -│ 1. Create FileTree.tsx with virtualized tree │ -│ 2. Add useFileTree hook for fs.watch │ -│ 3. Wire to sidebar via IPC │ -│ │ -│ ┌─ Edit ──────────────────────────────────────────┐ │ -│ │ ✏️ src/renderer/features/files/FileTree.tsx │ │ -│ │ +42 -0 │ │ -│ │ ┌─────────────────────────────────────────────┐ │ │ -│ │ │ + import { Tree } from 'react-arborist'; │ │ │ -│ │ │ + import { useFileTree } from './hooks'; │ │ │ -│ │ │ + │ │ │ -│ │ │ + export function FileTree({ root }) { │ │ │ -│ │ │ + const { nodes } = useFileTree(root); │ │ │ -│ │ │ ... (3 more lines) │ │ │ -│ │ └─────────────────────────────────────────────┘ │ │ -│ │ [View Full Diff] │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Bash ──────────────────────────────────────────┐ │ -│ │ $ npm run build │ │ -│ │ ┌─────────────────────────────────────────────┐ │ │ -│ │ │ > tsc -b && vite build │ │ │ -│ │ │ ✓ 247 modules transformed │ │ │ -│ │ │ ✓ built in 4.21s │ │ │ -│ │ └─────────────────────────────────────────────┘ │ │ -│ │ ✅ Exit 0 (4.2s) │ │ -│ └─────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────┘ -``` - -#### Popup Tab: Tasks - -Checklist view of the agent's workflow phases. Shows progress, completion state, and time spent per phase. Draws from task file YAML (Layer 2 when available) or inferred from session activity (Layer 1). - -``` -┌──────────────────────────────────────────────────────┐ -│ Task #3 — FileTree Component │ -│ Assigned: component-engineer │ -│ Acceptance Criteria: 4 items │ -│ │ -│ Progress ████████████░░░░░░░░ 60% │ -│ │ -│ Workflow Phases: │ -│ ────────────────────────────────────────────────── │ -│ ✅ Phase 0: Load rules + read task file (0:12) │ -│ ✅ Phase 1: Write execution plan (1:04) │ -│ 🔄 Phase 2: Execute plan (3:16) │ ← in progress -│ ✅ Create FileTree.tsx │ -│ ✅ Create useFileTree.ts │ -│ 🔄 Wire to sidebar IPC │ ← current step -│ ◻ Add drag-drop support │ -│ ◻ Phase 3: Self-review + build │ -│ ◻ Phase 4: Report to team leader │ -│ │ -│ Acceptance Criteria: │ -│ ────────────────────────────────────────────────── │ -│ ✅ Renders directory tree from project root │ -│ ✅ Live updates when files change (< 300ms) │ -│ ◻ Supports expand/collapse with keyboard nav │ -│ ◻ Click file opens in editor panel │ -└──────────────────────────────────────────────────────┘ -``` - -#### Popup Tab: Files Changed - -All files modified by this agent on its workbranch. Click any file to view the full diff inline. - -``` -┌──────────────────────────────────────────────────────┐ -│ Branch: work/DASH-003/file-tree-panel │ -│ 3 files changed │ +142 additions │ -8 deletions │ -│ │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ A src/renderer/features/files/FileTree.tsx │ │ -│ │ +98 lines │ │ -│ ├──────────────────────────────────────────────┤ │ -│ │ A src/renderer/features/files/useFileTree.ts│ │ -│ │ +36 lines │ │ -│ ├──────────────────────────────────────────────┤ │ -│ │ M src/renderer/features/files/index.ts │ │ -│ │ +8 -8 lines │ │ -│ └──────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Selected: FileTree.tsx (full diff) ───────────┐ │ -│ │ │ │ -│ │ (rendered via @git-diff-view/react) │ │ -│ │ GitHub-style split or unified diff │ │ -│ │ Syntax highlighted, line numbers │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────┘ -``` - -#### Popup Tab: Errors - -Filtered view of only errors, warnings, and issues. Pulled from session JSONL (failed tool calls, non-zero exit codes, error messages) and workflow tracking (QA failures). Empty state shows a green checkmark. - -``` -┌──────────────────────────────────────────────────────┐ -│ Errors & Warnings │ -│ │ -│ ┌─ Error ────────────────────────────── 12:38 PM ┐ │ -│ │ ❌ Bash: npm run build │ │ -│ │ Exit code: 2 │ │ -│ │ │ │ -│ │ src/renderer/features/files/FileTree.tsx:24 │ │ -│ │ Type 'string' is not assignable to type │ │ -│ │ 'FileNode[]' │ │ -│ │ │ │ -│ │ [View in Chat Context] │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Warning ──────────────────────────── 12:36 PM ┐ │ -│ │ ⚠️ Agent attempted to edit file outside scope │ │ -│ │ src/main/services/git/git-service.ts │ │ -│ │ (reported as out-of-scope observation) │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ QA Failure ───────────────────────── 12:42 PM ┐ │ -│ │ 🔴 QA Round 1: FAIL │ │ -│ │ Missing keyboard navigation (acceptance │ │ -│ │ criterion #3 not met) │ │ -│ │ [View QA Report] │ │ -│ └─────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────┘ - -Empty state: -┌──────────────────────────────────────────────────────┐ -│ │ -│ ✅ No errors │ -│ │ -│ Clean run — no issues detected │ -│ │ -└──────────────────────────────────────────────────────┘ -``` - -#### Popup Tab: Terminal (optional, escape hatch) - -Raw terminal output via `ghostty-web` or `xterm.js`. Only available for tmux-backed agents (team lead + teammates). Shows the actual terminal the agent is running in. Useful for: -- Debugging when structured output misses something -- Interactive permission prompts -- Viewing subprocess output that isn't captured in session JSONL - -``` -┌──────────────────────────────────────────────────────┐ -│ Raw Terminal — tmux pane %3 │ -│ ────────────────────────────────────────────────── │ -│ │ -│ (ghostty-web / xterm.js terminal emulator) │ -│ (connected to tmux pane via pipe-pane or attach) │ -│ │ -│ $ claude --name coder-task-3 │ -│ ╭──────────────────────────────────────────────╮ │ -│ │ Claude Code v2.1.87 │ │ -│ │ Model: claude-sonnet-4-6 │ │ -│ │ │ │ -│ │ Let me read the task file first. │ │ -│ │ │ │ -│ │ Read progress/DASH-003/tasks/... │ │ -│ │ ... │ │ -│ ╰──────────────────────────────────────────────╯ │ -│ │ -└──────────────────────────────────────────────────────┘ -``` - ---- - -## Chat Message Components (OpenCode-style) - -### Text Message - -``` -┌──────────────────────────────────────────┐ -│ 🤖 I'll implement the FileTree │ -│ component using react-arborist. │ -│ │ -│ The component needs to: │ -│ - Watch the filesystem recursively │ -│ - Support expand/collapse │ -│ - Show file type icons │ -│ │ -│ 12:34:02 PM │ -└──────────────────────────────────────────┘ -``` - -Rendered via `@llm-ui/react` for smooth streaming + `react-markdown` for formatting. - -### Tool Call Card — Read - -``` -┌─ Read ──────────────────────────────────┐ -│ 📄 src/shared/types/file-node.types.ts │ -│ Lines 1-45 │ -│ [▸ Show content] │ ← collapsible -└─────────────────────────────────────────┘ -``` - -### Tool Call Card — Edit - -``` -┌─ Edit ──────────────────────────────────┐ -│ ✏️ src/renderer/features/files/ │ -│ FileTree.tsx │ -│ +42 lines -3 lines │ -│ │ -│ ┌─ Diff ─────────────────────────────┐ │ -│ │ - import { Tree } from 'old-lib'; │ │ -│ │ + import { Tree } from │ │ -│ │ + 'react-arborist'; │ │ -│ │ │ │ -│ │ ... (collapsed, click to expand) │ │ -│ └─────────────────────────────────────┘ │ -│ │ -│ [View Full Diff] │ ← opens in diff panel -└─────────────────────────────────────────┘ -``` - -Inline diff rendered via `@git-diff-view/react` (compact mode). Full diff opens the Diff View tab. - -### Tool Call Card — Bash - -``` -┌─ Bash ──────────────────────────────────┐ -│ $ npm run build │ -│ │ -│ ┌─ Output ─────────────────────────┐ │ -│ │ > tsc -b && vite build │ │ -│ │ vite v6.2.0 building... │ │ -│ │ ✓ 247 modules transformed │ │ -│ │ ✓ built in 4.21s │ │ -│ └───────────────────────────────────┘ │ -│ │ -│ ✅ Exit code: 0 (4.2s) │ -└─────────────────────────────────────────┘ -``` - -Failed commands show red border + error highlighting. - -### Tool Call Card — Agent Spawn (Team Lead only) - -``` -┌─ Agent Spawned ─────────────────────────┐ -│ 👤 component-engineer │ -│ Task: #3 — FileTree component │ -│ Model: sonnet │ -│ Branch: work/DASH-003/file-tree │ -│ │ -│ ● Running │ -│ [View Agent Panel] │ ← scrolls to / opens panel -└─────────────────────────────────────────┘ -``` - -### User Message (from PO or direct interaction) - -``` -┌──────────────────────────────────────────┐ -│ You 12:30 PM │ -│ Execute the plan at progress/ │ -│ DASH-003/tasks/. Create an agent team │ -│ with 4 teammates using Sonnet. │ -└──────────────────────────────────────────┘ -``` - -Right-aligned, different background color. Sent via input box at bottom of panel. - ---- - -## Main Session Panel (Project Owner) - -The main session is always the leftmost / most prominent panel. It has additional features beyond standard agent panels: - -- **Input box always visible** at bottom (primary interaction point) -- **Slash command palette** — typing `/` shows available commands -- **Plan output** — `/new-plan` results render as structured cards with task breakdowns -- **Research output** — `/deep-research` results render as formatted reports -- **Send to Team Lead** button — when a plan is ready, one-click sends it to the team lead pane - -``` -┌──────────────────────────────────────────────┐ -│ ★ Main Session — Project Owner [⬚] │ -│ Project: gpMS_ConsoleFrontend │ -│ Model: opus │ Tokens: 45.2K │ -├──────────────────────────────────────────────┤ -│ │ -│ (Chat messages, tool calls, plans, etc.) │ -│ │ -│ ┌─ Plan Ready ─────────────────────────┐ │ -│ │ DASH-003: Agent Dashboard View │ │ -│ │ 6 tasks across 3 waves │ │ -│ │ │ │ -│ │ Wave 1: Schema + Service (2 tasks) │ │ -│ │ Wave 2: Components (3 tasks) │ │ -│ │ Wave 3: Integration (1 task) │ │ -│ │ │ │ -│ │ [Send to Team Lead ▸] [View Plan] │ │ -│ └───────────────────────────────────────┘ │ -│ │ -├──────────────────────────────────────────────┤ -│ ┌──────────────────────────────────────────┐ │ -│ │ Type a message or /command... [Send ↵] │ │ -│ └──────────────────────────────────────────┘ │ -└──────────────────────────────────────────────┘ -``` - ---- - -## Team Lead Panel - -The team lead panel shows orchestration activity — agent spawns, QA cycles, merge operations, and team coordination. It is always the second panel (after Main). - -``` -┌──────────────────────────────────────────────┐ -│ ◆ Team Lead [─ ⬚]│ -│ Team: DASH-003 │ Agents: 4/4 running │ -│ Model: opus │ Wave: 2/3 │ -├──────────────────────────────────────────────┤ -│ [Chat] [Team Status] [QA Results] │ -├──────────────────────────────────────────────┤ -│ │ -│ Team Status Tab: │ -│ ┌────────────────────────────────────────┐ │ -│ │ Agent Task Status │ │ -│ │ ───────────────────────────────────── │ │ -│ │ ● coder-task-1 #1 ✅ Complete │ │ -│ │ ● coder-task-2 #2 ✅ QA Passed │ │ -│ │ ● coder-task-3 #3 🔄 Running │ │ -│ │ ● coder-task-4 #4 🔄 Running │ │ -│ └────────────────────────────────────────┘ │ -│ │ -│ QA Results Tab: │ -│ Task #1: PASS (3 checks passed) │ -│ Task #2: PASS (4 checks passed) │ -│ Task #3: Pending... │ -│ │ -├──────────────────────────────────────────────┤ -│ ┌──────────────────────────────────────────┐ │ -│ │ Send instruction to team lead... [Send] │ │ -│ └──────────────────────────────────────────┘ │ -└──────────────────────────────────────────────┘ -``` - ---- - -## Sidebar Integration - -The sidebar (when visible alongside Agents tab) shows: - -``` -┌──────────┐ -│ Files │ ← react-arborist file tree, live updating -│ ──────── │ -│ 📂 src/ │ -│ 📂 main │ -│ 📂 rend │ -│ ... │ -│ │ -│ Changes │ ← git status, click for diff -│ ──────── │ -│ M file1 │ -│ A file2 │ -│ D file3 │ -│ │ -│ Agents │ ← quick agent list with status dots -│ ──────── │ -│ ● Main │ -│ ◆ Lead │ -│ ● Agt 1 │ -│ ● Agt 2 │ -│ ○ Agt 3 │ -│ │ -│ Tasks │ ← ticket/task quick view -│ ──────── │ -│ ☑ #1 ✓ │ -│ ☑ #2 ✓ │ -│ ◻ #3 ◎ │ -│ ◻ #4 ◎ │ -└──────────┘ -``` - ---- - -## Interactions - -| Action | Result | -|--------|--------| -| Click compact panel | Expand in-place (shows tabs: Chat, Tasks, Files, Errors) | -| Click `[⬚ Popup]` button | Full-screen overlay with complete agent output | -| Click `[─]` on expanded | Collapse back to compact | -| Click file in Files Changed tab | Opens diff in the Diff View panel | -| Click `[View Diff]` on Edit card | Opens inline diff or scrolls to diff panel | -| Click `[View Agent Panel]` on spawn card | Scrolls to or opens that agent's panel | -| Click `[Send to Team Lead ▸]` | Sends plan reference to team lead via tmux/stdin | -| Click agent in sidebar list | Scrolls to that agent's panel in main view | -| Type in input box + Enter | Sends message to that specific agent | -| Escape in popup | Closes popup, returns to layout view | -| Layout toggle in toolbar | Switches between Single/2-Col/3-Col/Grid/Multi | - ---- - -## Responsive Behavior - -| Window Width | Default Layout | Behavior | -|-------------|---------------|----------| -| < 1024px | Single | Main full width, agents in collapsible bottom drawer | -| 1024-1440px | Single | Main 60%, agent stack 40% | -| 1440-1920px | 2-Column | Main + Lead equal, agents below | -| > 1920px | 3-Column | Main + Lead + selected agent | - -User can override default at any width. - ---- - -## Data Sources per Panel Section - -| Panel Section | Data Source | Layer | -|---------------|-----------|-------| -| Status dot + running/idle | Session JSONL (last event type) | Agent Visibility | -| Chat messages | Session JSONL (assistant + user messages) | Agent Visibility | -| Tool call cards | Session JSONL (tool_use content blocks) | Agent Visibility | -| Token usage | Session JSONL (usage fields) | Agent Visibility | -| Task list (phases) | `progress/*/tasks/task-N.md` | Workflow Tracking | -| QA results | `progress/*/proof-ledger.jsonl` | Workflow Tracking | -| Files changed | `git diff` on agent's worktree/branch | Git Service | -| Error log | Session JSONL (error events) + stderr | Agent Visibility | -| Team membership | `~/.claude/teams/*/config.json` | Agent Visibility | -| Ticket association | Correlation by team name ↔ ticket ID | Dashboard (Layer 3) | - ---- - -## Component Library Reference - -### Packages to evaluate (from research 2026-03-30) - -| Category | Package | Downloads/wk | Why | -|----------|---------|-------------|-----| -| **File Tree** | `react-arborist` | 302K | Virtualized, drag-drop, inline rename, 3.6K stars | -| **File Tree** (alt) | `react-complex-tree` | 48K | More unopinionated, stronger a11y | -| **Diff Viewer** | `@git-diff-view/react` | 36K | GitHub-faithful UI, inline comments via widget system, split/unified | -| **Diff Viewer** (alt) | `react-diff-view` | 223K | Mature widget architecture for code review commenting | -| **Diff Viewer** (simple) | `react-diff-viewer-continued` | 566K | Highest adoption, good for quick before/after | -| **Editor Diff** | `@monaco-editor/react` DiffEditor | 3.9M | Full VS Code diff experience | -| **LLM Streaming** | `@llm-ui/react` + `@llm-ui/markdown` | 37K | Smooth character-by-character at native frame rate | -| **Chat UI** | `@assistant-ui/react` | Active | Composable chat primitives, streaming, auto-scroll | -| **Markdown** | `react-markdown` + `remark-gfm` | Massive | GitHub-flavored markdown | -| **Code Highlight** | `react-syntax-highlighter` | Massive | Prism.js backend, 200+ languages | -| **ANSI Fallback** | `ansi-to-react` | 187K | ANSI escape codes → React elements | -| **Terminal** | `ghostty-web` | 42K | Ghostty→WASM, xterm.js API compatible drop-in | -| **Diff (reference)** | `pierre.js` | — | Used by OpenChamber for diff rendering | - -### OpenChamber's stack (closest reference implementation) - -``` -React + Vite (frontend) -Tauri (desktop shell — we use Electron instead) -ghostty-web (terminal rendering) -pierre.js (diff viewer) -PostCSS (18+ themes) -HTTP + SSE (backend communication — we use stream-json + JSONL) -IndexedDB (client caching) -``` - -### Our adapted stack - -``` -React 19 + Vite (existing ADC frontend) -Electron 39 (existing ADC desktop shell) -@llm-ui/react (LLM output rendering — replaces terminal) -@git-diff-view/react (diff viewer — replaces pierre.js) -react-arborist (file tree — replaces custom tree) -ghostty-web (escape hatch terminal tab only) -stream-json stdin/stdout (PO session) -session JSONL watching (team lead + teammates) -Tailwind CSS 4 (existing ADC styling) -Zustand 5 (existing ADC state) -``` - ---- - -## Raw Terminal Tab (escape hatch) - -Each agent panel has an optional "Raw Terminal" tab (hidden by default, shown in popup view) that renders the actual terminal output via `ghostty-web` or `xterm.js`. This is the escape hatch for: - -- Debugging when structured output doesn't capture something -- Interactive prompts (permission requests, confirmations) -- Viewing output from non-Claude processes the agent spawned - -Only relevant for tmux-backed agents (team lead + teammates). The Project Owner's headless session has no terminal — only the chat UI. diff --git a/docs/features/assistant-popover-refactor/plan.md b/docs/features/assistant-popover-refactor/plan.md deleted file mode 100644 index f2d968ed..00000000 --- a/docs/features/assistant-popover-refactor/plan.md +++ /dev/null @@ -1,38 +0,0 @@ -# Assistant Popover Refactor — Stub - -> **Status**: QUEUED (after Agent Harness + Sidenav Restructure) -> **Priority**: P2 -> **Created**: 2026-04-06 - -## Problem - -`WidgetPanel` is a fully custom fixed-position popup with manual animation, hardcoded `bg-card`, and inline `style={{ left }}` positioning. This violates the project rule of using `@ui` primitives. - -## Solution - -Replace `WidgetPanel` with the `Popover` primitive from `@ui/popover` (Radix-based). - -### Changes - -**File: `src/renderer/features/assistant/components/WidgetPanel.tsx`** -- Replace the `fixed` positioned `<div>` with `<PopoverContent>` from `@ui/popover` -- Uses `bg-popover text-popover-foreground` theme tokens (not `bg-card`) -- Built-in open/close animations via Radix data attributes -- Portaled automatically — no z-index management needed - -**File: `src/renderer/features/assistant/components/AssistantWidget.tsx`** -- Wrap with `<Popover open={mode === 'popup'} onOpenChange={...}>` -- The trigger is the sidebar button (already exists as `SidebarAssistantButton`) -- Wire Zustand store's `mode` state to Radix's `open` prop - -**File: `src/renderer/features/assistant/components/SidebarAssistantButton.tsx`** -- Wrap the collapsed-mode button with `<PopoverTrigger asChild>` -- When clicked in collapsed sidebar, opens the popover anchored to the button -- Inline mode stays unchanged (not a popover) - -### Considerations -- `PopoverContent` needs `side="top"` and `align="start"` to position above/right of the sidebar button -- Width override: `w-[380px]` on `PopoverContent` (same as current) -- Min/max height: `min-h-[40vh] max-h-[70vh]` carried over -- Focus management handled by Radix automatically (currently manual in `useEffect`) -- Escape to close handled by Radix (currently manual keydown listener) diff --git a/docs/features/command-palette/plan.md b/docs/features/command-palette/plan.md deleted file mode 100644 index 50d6e63b..00000000 --- a/docs/features/command-palette/plan.md +++ /dev/null @@ -1,62 +0,0 @@ -# Global Command Palette - -> Tracker Key: `command-palette` | Status: **DRAFT** | Priority: **P1** | Created: 2026-02-26 - ---- - -## Problem - -Navigation relies entirely on the sidebar. No way to quickly jump to a feature, project, task, or action without clicking through menus. Power users need keyboard-driven navigation. - -## Goals - -- `Cmd+K` / `Ctrl+K` opens a fuzzy-search command palette -- Search across: routes/pages, projects, tasks, recent items, actions -- Keyboard-driven: arrow keys to navigate, Enter to select, Esc to close -- Extensible: features can register their own commands - -## Approach - -### Component: `CommandPalette.tsx` - -- Dialog overlay with search input + scrollable results list -- Grouped results: Navigation, Projects, Tasks, Actions -- Fuzzy matching (use `fuse.js` or similar lightweight fuzzy library) -- Recent items shown when input is empty -- Mounted at app root level (inside `LayoutWrapper` or `AppProviders`) - -### Registry Pattern - -```typescript -// Features register commands -commandRegistry.register({ - id: 'nav.dashboard', - label: 'Go to Dashboard', - group: 'Navigation', - keywords: ['home', 'overview'], - action: () => navigate({ to: '/dashboard' }), -}); -``` - -- Static nav commands registered at mount -- Dynamic commands (projects, tasks) loaded on palette open via IPC -- Actions: theme toggle, sidebar toggle, settings sections - -### Keyboard Handling - -- Global `Ctrl+K` / `Cmd+K` listener (registered in app root) -- Arrow Up/Down to navigate results -- Enter to execute selected command -- Esc to close -- Type-ahead filtering with debounce - -## Files - -- `src/renderer/shared/components/CommandPalette.tsx` — Main component -- `src/renderer/shared/lib/command-registry.ts` — Registry + types -- `src/renderer/shared/hooks/useCommandPalette.ts` — Open/close state + keyboard handler -- Feature modules register commands in their barrel exports or route loaders - -## Estimated Effort - -~2 days diff --git a/docs/features/devices-ui/plan.md b/docs/features/devices-ui/plan.md deleted file mode 100644 index 21bd8903..00000000 --- a/docs/features/devices-ui/plan.md +++ /dev/null @@ -1,45 +0,0 @@ -# Devices Management Page - -> Tracker Key: `devices-ui` | Status: **DRAFT** | Priority: **P1** | Created: 2026-02-26 - ---- - -## Problem - -Device registration, heartbeat, and management are fully functional in the backend (IPC channels, Hub API proxy, service). But there's no dedicated devices page — device selection is buried in the settings sidebar layout selector. - -## Goals - -- Dedicated `/devices` page accessible from navigation -- List registered devices with status, last heartbeat, OS info -- Register new devices -- Remove/deregister devices -- Show which device is currently active -- Device health indicators (online/offline/stale) - -## Approach - -### Route - -- Add `/devices` route (personal navigation section or settings sub-route) -- Add to `shared-nav.ts` - -### Components - -| Component | Purpose | -|-----------|---------| -| `DeviceList.tsx` | Table/card list of all registered devices | -| `DeviceCard.tsx` | Device card with name, OS, status, last seen | -| `RegisterDeviceDialog.tsx` | Form to register a new device | -| `DeviceStatusBadge.tsx` | Online/offline/stale indicator | - -### Data Flow - -Uses existing hooks in `src/renderer/features/devices/`: -- `useDevices()` — list query -- `useRegisterDevice()` — mutation -- `useDeviceEvents()` — WebSocket event handler for status changes - -## Estimated Effort - -~1 day (backend complete, just UI components needed) diff --git a/docs/features/docs-sync/plan.md b/docs/features/docs-sync/plan.md deleted file mode 100644 index 91450c31..00000000 --- a/docs/features/docs-sync/plan.md +++ /dev/null @@ -1,63 +0,0 @@ -# Documentation Sync Pass - -> Tracker Key: `docs-sync` | Status: **DRAFT** | Priority: **P0** | Created: 2026-02-26 - ---- - -## Problem - -The `ai-docs/` directory has fallen behind the actual codebase. An audit found **47 discrepancies** across service counts, feature listings, handler documentation, IPC domains, and patterns. - -## Scope - -### FEATURES-INDEX.md — Stale Counts & Missing Entries - -| Item | Documented | Actual | Delta | -|------|-----------|--------|-------| -| Renderer Features | 29 | 31 | +2 missing (communications, workflow-pipeline) | -| Main Services | 33 | 35 | +2 missing | -| IPC Domains | 27 | 30 | +3 missing (docker, agents, +1) | -| Handler Files | 42 | ~48 | +6 missing (dashboard, docker, error, hotkey, mcp, tracker, window) | - -**Missing services from table:** app, calendar, docker, health, ideas, insights, screen, terminal, time-parser, tracker, workflow (11 total) - -**Missing features from table:** communications, health, workflow-pipeline (3 undocumented + 8 with minimal docs) - -### PATTERNS.md — Stale Sidebar Guidance - -- "Adding a New Feature" section still says "add nav item to Sidebar.tsx" -- Should reference `shared-nav.ts` + the 16-layout system -- No mention of `LayoutWrapper.tsx` or `LAYOUT_MAP` - -### ARCHITECTURE.md — Stale Counts & References - -- Lists 32 services (actual: 35) -- Agent system references `AgentService` but primary is `agent-orchestrator` -- Terminal service path incorrect - -### Agent Definitions (`.claude/agents/*.md`) - -- Need audit against actual codebase for stale references -- New features (communications, health, workflow-pipeline) not reflected -- Sidebar layout changes need propagation to all UI-facing agents - -## Tasks - -1. **Update FEATURES-INDEX.md** — Add all missing services, features, handlers, IPC domains; fix counts -2. **Update PATTERNS.md** — Fix "Adding a Feature" section for new sidebar system; update primitive counts -3. **Update ARCHITECTURE.md** — Fix service count, agent system references, terminal path -4. **Audit agent definitions** — Check all `.claude/agents/*.md` files for stale references -5. **Update DATA-FLOW.md** — Add any missing data flows for new services -6. **Verify with `npm run check:docs`** — Ensure doc check passes - -## Verification - -- `npm run check:docs` passes -- All service/feature/handler counts in docs match reality -- No references to files that don't exist -- PATTERNS.md sidebar guidance matches current implementation -- Agent definitions reference current file paths and patterns - -## Estimated Effort - -~2 days with parallel agents (can split by doc file) diff --git a/docs/features/future-roadmap/plan.md b/docs/features/future-roadmap/plan.md deleted file mode 100644 index 676eaf18..00000000 --- a/docs/features/future-roadmap/plan.md +++ /dev/null @@ -1,64 +0,0 @@ -# Future Roadmap - -> Tracker Key: `future-roadmap` | Status: **TRACKING** | Created: 2026-02-16 | Updated: 2026-02-26 - ---- - -## Prioritized Roadmap - -All items below are tracked individually in `docs/tracker.json`. Items marked with a slug link to their plan file in `docs/features/<slug>/plan.md`. - -### P0 — Fix Now (Blocking Quality) - -| Slug | Title | Type | Effort | Plan | -|------|-------|------|--------|------| -| `sidebar-architecture-refactor` | Sidebar/Navigation Architecture Refactor | Refactor | ~3 days | [plan](../sidebar-architecture-refactor/plan.md) | -| `docs-sync` | Documentation Sync Pass | Docs | ~2 days | [plan](../docs-sync/plan.md) | - -**Why P0:** The sidebar has 2,491 lines of 95% duplicated code across 16 files. Any nav change touches all 16. The docs have 47 stale items that will cause agents to produce incorrect code. - -### P1 — High Impact Features - -| Slug | Title | Type | Effort | Plan | -|------|-------|------|--------|------| -| `command-palette` | Global Command Palette | Feature | ~2 days | [plan](../command-palette/plan.md) | -| `workspace-ui` | Workspaces Management UI | Feature | ~1 day | [plan](../workspace-ui/plan.md) | -| `devices-ui` | Devices Management Page | Feature | ~1 day | [plan](../devices-ui/plan.md) | -| `productivity-hub-restructure` | Productivity Hub Restructure | Refactor | ~2 days | [plan](../productivity-hub-restructure/plan.md) | - -**Why P1:** Workspaces and devices backends are fully wired with zero UI. Command palette transforms navigation UX. Productivity hub is cramped with 5 features in tabs. - -### P2 — Medium Impact - -| Slug | Title | Type | Effort | -|------|-------|------|--------| -| `favorites-pinning` | Favorites & Pinning System | Feature | ~2 days | -| `task-bulk-ops` | Bulk Task Operations | Feature | ~2 days | -| `agent-dashboard-enhanced` | Enhanced Agent Dashboard | Feature | ~2 days | -| `insights-analytics` | Insights Analytics Upgrade | Feature | ~3 days | -| `health-dashboard` | Service Health Dashboard | Feature | ~1 day | - -### P3 — Polish & Infrastructure - -| Slug | Title | Type | Effort | -|------|-------|------|--------| -| `ci-cd-pipeline` | GitHub Actions CI/CD Pipeline | Infra | ~1 day | -| `e2e-test-expansion` | E2E Test Suite Expansion | Tests | ~3 days | -| `accessibility-audit` | WCAG Accessibility Audit | Quality | ~2 days | -| `offline-resilience` | Offline Mode / Graceful Degradation | Feature | ~3 days | -| `eslint-warnings-cleanup` | Clean ESLint Warnings | Cleanup | ~1 hr | - ---- - -## Archived — Previously Tracked (Now Separate Entries) - -The following items from the original hardening brainstorm are now tracked as individual entries: - -- **Layout Uplevel** → Superseded by `sidebar-architecture-refactor` + `productivity-hub-restructure` -- **GitHub CI/CD Pipeline** → Tracked as `ci-cd-pipeline` -- **Tier 2-3 Tests** → Tracked as `e2e-test-expansion` -- **Database migration system** → Deferred (no immediate need) -- **Offline mode resilience** → Tracked as `offline-resilience` -- **Memory leak detection** → Deferred (not yet a reported issue) -- **Startup performance** → Deferred (not yet a reported issue) -- **Accessibility audit** → Tracked as `accessibility-audit` diff --git a/docs/features/productivity-hub-restructure/plan.md b/docs/features/productivity-hub-restructure/plan.md deleted file mode 100644 index f03374cb..00000000 --- a/docs/features/productivity-hub-restructure/plan.md +++ /dev/null @@ -1,57 +0,0 @@ -# Productivity Hub Restructure - -> Tracker Key: `productivity-hub-restructure` | Status: **DRAFT** | Priority: **P1** | Created: 2026-02-26 - ---- - -## Problem - -Five features (Notes, Planner, Briefing, Alerts, Communications) are crammed into a single tabbed view under the "Productivity" route. The tab layout is cramped, features fight for space, and users can't keep multiple productivity views open simultaneously. - -## Goals - -- Break productivity features into proper sub-routes or a flexible layout -- Each feature gets adequate screen real estate -- Users can navigate directly to any productivity feature via sidebar or command palette -- Maintain the "Productivity" grouping for organization but not as a tab container - -## Options - -### Option A: Sub-routes (Recommended) - -Replace the tab view with sub-routes: -- `/productivity` — Overview/dashboard with widget cards for each feature -- `/productivity/planner` — Full planner view -- `/productivity/notes` — Full notes view -- `/productivity/briefing` — Full briefing view -- `/productivity/alerts` — Full alerts view -- `/productivity/communications` — Full communications view - -Sidebar shows "Productivity" as a collapsible group with each feature as a child item. - -### Option B: Resizable Grid Layout - -Keep single page but use `react-resizable-panels` to let users arrange features in a customizable grid. More complex but allows multi-feature views. - -## Approach (Option A) - -1. Create route group for `/productivity/*` with sub-routes -2. Create `ProductivityDashboard.tsx` overview page with summary cards -3. Move each tab content into its own route component -4. Update `shared-nav.ts` to show Productivity as expandable group -5. Update breadcrumbs to show `Productivity > Planner` etc. -6. Remove the tab container component - -## Files Touched - -- `src/renderer/app/routes/` — New productivity route group -- `src/renderer/features/planner/` — Standalone route wrapper -- `src/renderer/features/notes/` — Standalone route wrapper -- `src/renderer/features/briefing/` — Standalone route wrapper -- `src/renderer/features/alerts/` — Standalone route wrapper -- `src/renderer/features/communications/` — Standalone route wrapper -- `src/renderer/features/sidebar-layouts/shared-nav.ts` — Expandable productivity group - -## Estimated Effort - -~2 days diff --git a/docs/features/sidebar-architecture-refactor/plan.md b/docs/features/sidebar-architecture-refactor/plan.md deleted file mode 100644 index 3c05371f..00000000 --- a/docs/features/sidebar-architecture-refactor/plan.md +++ /dev/null @@ -1,86 +0,0 @@ -# Sidebar/Navigation Architecture Refactor - -> Tracker Key: `sidebar-architecture-refactor` | Status: **DRAFT** | Priority: **P0** | Created: 2026-02-26 - ---- - -## Problem - -The 16 `SidebarLayoutXX.tsx` files contain ~2,491 lines with ~95% duplicated boilerplate. Adding a nav item, fixing a bug, or changing the header requires touching all 16 files. No shared hooks or layout primitives exist. - -### Specific Issues - -1. **URL-to-store sync** — Same 6-line regex block copy-pasted 16 times -2. **Active state detection** — `isPersonalActive`/`isDevActive` helpers duplicated in every file -3. **Navigation handlers** — `handlePersonalNav`/`handleDevNav` identical across layouts -4. **Footer** — UserMenu + HubConnectionIndicator + Settings button repeated in all layouts -5. **Header** — Inconsistent styling (`text-lg font-bold` vs `text-sm font-semibold` vs `text-xl font-bold`) -6. **No error boundary** in `LayoutWrapper` — if a layout throws, app crashes -7. **No responsive behavior** — all layouts desktop-only, no breakpoint handling -8. **Preview SVGs brittle** — manually maintained config objects can go stale - -## Goals - -- Reduce each layout file from ~180 lines to ~30-40 lines of layout-specific JSX -- Centralize all shared navigation logic into reusable hooks -- Create shared layout primitives for footer, header, and collapsible groups -- Add error boundary to `LayoutWrapper` -- Normalize header styling across all layouts -- No user-facing behavior changes (pure refactor) - -## Approach - -### Phase 1: Extract Shared Hooks - -Create these hooks in `src/renderer/shared/hooks/`: - -| Hook | Replaces | -|------|----------| -| `useUrlProjectSync()` | 6-line URL regex block in every layout | -| `useSidebarNavigation()` | `handlePersonalNav`, `handleDevNav`, `isPersonalActive`, `isDevActive` | -| `useActiveRoute()` | Route matching logic scattered across layouts | - -### Phase 2: Create Shared Layout Primitives - -Create these in `src/renderer/shared/components/layout/`: - -| Component | Replaces | -|-----------|----------| -| `SidebarHeaderTemplate` | Header section with consistent styling + logo | -| `SidebarFooterTemplate` | UserMenu + HubConnectionIndicator + Settings | -| `SidebarNavGroup` | Repeating SidebarGroup + SidebarMenu + SidebarMenuItem pattern | -| `CollapsibleNavGroup` | Collapsible wrapper used by layouts 02, 05, 09, 11 | - -### Phase 3: Refactor Layout Files - -Rewrite each `SidebarLayoutXX.tsx` to use the shared hooks and primitives. Each file should only contain layout-specific structure (e.g., collapsible vs flat, dual sidebar, sticky header). - -### Phase 4: Add Safety & Polish - -- Add `ErrorBoundary` in `LayoutWrapper` around lazy-loaded layouts -- Add fallback layout if selected layout fails to load -- Normalize any remaining styling inconsistencies -- Validate all 16 layouts render correctly post-refactor - -## Files Touched - -- `src/renderer/features/sidebar-layouts/SidebarLayout01-16.tsx` (all 16) -- `src/renderer/features/sidebar-layouts/shared-nav.ts` (may reorganize) -- `src/renderer/shared/hooks/` (3 new hooks) -- `src/renderer/shared/components/layout/` (4 new primitives) -- `src/renderer/app/LayoutWrapper.tsx` (add error boundary) - -## Verification - -- All 16 layouts render identically to pre-refactor (visual regression) -- `npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e` -- No new ESLint violations -- Layout switching works in Settings > Display -- Sidebar collapse/expand works in all layouts -- Navigation works for all personal + development items -- Project tab bar still works -- Breadcrumbs still render correctly - -## Estimated Effort - -~3 days with parallel agents (4 phases, phases 1-2 can parallelize) diff --git a/docs/features/sidenav-restructure/design.md b/docs/features/sidenav-restructure/design.md deleted file mode 100644 index 2be6f443..00000000 --- a/docs/features/sidenav-restructure/design.md +++ /dev/null @@ -1,269 +0,0 @@ -# Feature Design: Sidenav Restructure - -**Author**: /new-plan -**Created**: 2026-04-06 -**Status**: READY FOR IMPLEMENTATION -**Workflow Mode**: standard -**Priority**: P1 - ---- - -## 1. Overview - -Reorganize project-scoped navigation from a catch-all "Tools" bucket into logical groupings. The current "Tools" nav item houses GitHub, Ideation, Roadmap, Changelog, and Insights — unrelated features grouped by elimination. This restructure splits them into three purposeful nav items: **Planning** (Roadmap + Ideation + Insights), **Git** (GitHub + Changelog summary in header), and **Tools** (repurposed as a Claude Config suite placeholder for future skill/command/plugin management). - -The existing `ToolsPage` tabbed pattern already proves the approach — it wraps existing feature pages as tabs. This restructure creates two new tabbed wrappers (PlanningPage, GitPage), updates route constants, and modifies the sidebar config. No existing feature slices are modified internally. - -## 2. Requirements - -### Functional Requirements -- "Planning" nav item shows Roadmap, Ideation, and Insights as tabs -- "Git" nav item shows GitHub as primary view with Changelog summary in the PageHeader -- "Tools" nav item repurposed as placeholder for future Claude Config suite -- Sidebar nav order: Workspace, Tasks, Terminals, Planning, Git, Tools, Visual Map -- All existing page functionality preserved — zero changes to feature internals -- Existing direct routes (e.g., `/projects/$projectId/github`) should redirect to new locations - -### Non-Functional Requirements -- Route lazy loading maintained (lazyRouteComponent pattern) -- PageHeader compound component pattern for all new pages -- Zustand stores for active tab state (same pattern as existing ToolsPage) - -### Out of Scope -- Claude Config suite implementation (Phase 3-4 of the larger plan — separate feature) -- Chat slash command autocomplete (depends on Tools Config) -- MarkdownFileEditor component - -## 3. Architecture - -### Selected Approach -Reuse the existing `ToolsPage` tabbed pattern. Create `PlanningPage` and `GitPage` as thin wrapper components that embed existing feature pages via tabs. Update route constants and sidebar nav config. The compositional `AppSidebar` + `shared-nav.ts` config means sidebar changes are a single-file data update. - -### Route Changes - -| Old Route | New Route | Component | -|-----------|-----------|-----------| -| `/projects/$projectId/roadmap` | `/projects/$projectId/planning` | PlanningPage (tab: roadmap) | -| `/projects/$projectId/ideation` | `/projects/$projectId/planning` | PlanningPage (tab: ideation) | -| `/projects/$projectId/insights` | `/projects/$projectId/planning` | PlanningPage (tab: insights) | -| `/projects/$projectId/github` | `/projects/$projectId/git` | GitPage (tab: github) | -| `/projects/$projectId/changelog` | `/projects/$projectId/git` | GitPage (changelog in header) | -| `/projects/$projectId/tools` | `/projects/$projectId/tools` | ToolsConfigPlaceholder | - -### UI Flow -- **PlanningPage**: PageHeader with tabs (Roadmap, Ideation, Insights). Each tab renders the existing feature page component. Zustand store for active tab. -- **GitPage**: PageHeader with Changelog summary component (latest entry, expand/copy/update buttons). Main content is GitHubPage. No tabs needed — Changelog is a header element, GitHub is the body. -- **ToolsConfigPlaceholder**: Simple empty state page with "Coming Soon" and description of what will be here (Skills, Commands, Agents, Plugins, Config management). - -## 4. Task Breakdown - -### Task #1: Route Constants + Sidebar Nav Config - -**Agent**: router-engineer -**Wave**: 1 -**Blocked by**: none -**Blocks**: [2, 3, 4] -**Estimated complexity**: LOW -**Context budget**: ~12,000 tokens (files: 4) - -**Description**: -Add `PLANNING` and `GIT` route segments to route constants. Update sidebar `shared-nav.ts` to replace the current 5 dev items with 7 items (Workspace, Tasks, Terminals, Planning, Git, Tools, Visual Map) using correct icons. Update `layout-configs.ts` sidebar-09 devSubGroups indices since the item count changes from 5 to 7. Update routes in `project.routes.ts` to add planning and git routes, remove individual roadmap/ideation/insights/changelog routes (replace with redirects or remove). - -**Files to Modify**: -- `src/shared/constants/routes.ts` — Add PLANNING, GIT to PROJECT_VIEWS and ROUTE_PATTERNS. Remove ROADMAP, IDEATION, INSIGHTS, CHANGELOG from PROJECT_VIEWS (keep in ROUTE_PATTERNS for redirects). -- `src/renderer/app/layouts/sidebar-layouts/shared-nav.ts` — Update developmentItems: replace Tools(BarChart3) with Planning(Map), Git(GitBranch), Tools(Wrench). Update imports. -- `src/renderer/app/layouts/sidebar-layouts/layout-configs.ts` — Update sidebar-09 devSubGroups indices for 7 items instead of 5. -- `src/renderer/app/routes/project.routes.ts` — Add planningRoute and gitRoute. Remove individual roadmap/ideation/changelog/insights routes. Add redirect routes from old paths to new. - -**Files to Read for Context**: -- `src/renderer/app/layouts/sidebar-layouts/AppSidebar.tsx` — Understand how nav items are consumed -- `docs/features/sidenav-restructure/plan.md` — Full restructure plan - -**Acceptance Criteria**: -- [ ] `PROJECT_VIEWS.PLANNING` and `PROJECT_VIEWS.GIT` exist -- [ ] `ROUTE_PATTERNS.PROJECT_PLANNING` and `ROUTE_PATTERNS.PROJECT_GIT` exist -- [ ] Sidebar shows 7 project-scoped items in correct order with correct icons -- [ ] Old routes (`/projects/$projectId/roadmap`, etc.) redirect to new locations -- [ ] `npm run typecheck` passes -- [ ] `npm run lint` passes - -### Task #2: PlanningPage Feature Module - -**Agent**: component-engineer -**Wave**: 2 -**Blocked by**: [1] -**Blocks**: [] -**Estimated complexity**: LOW -**Context budget**: ~12,000 tokens (files: 4) - -**Description**: -Create a new `planning` feature module with a tabbed page wrapping Roadmap, Ideation, and Insights. Follow the exact same pattern as the existing `ToolsPage` (which also wraps existing features as tabs using PageHeader compound component). The existing `ToolsPage` is the reference implementation — copy its structure. - -**Files to Create**: -- `src/renderer/features/planning/index.ts` — Barrel export for PlanningPage -- `src/renderer/features/planning/components/PlanningPage.tsx` — Tabbed wrapper using PageHeader.Tabs, renders RoadmapPage, IdeationPage, InsightsPage as tab content -- `src/renderer/features/planning/store.ts` — Zustand store for activeTab (type: 'roadmap' | 'ideation' | 'insights') - -**Files to Read for Context**: -- `src/renderer/features/tools/components/ToolsPage.tsx` — Reference implementation to copy pattern from -- `src/renderer/features/tools/store.ts` — Reference store pattern -- `src/renderer/features/tools/index.ts` — Reference barrel pattern - -**Acceptance Criteria**: -- [ ] PlanningPage renders with PageHeader, title "Planning", description -- [ ] Three tabs: Roadmap (Map icon), Ideation (Lightbulb icon), Insights (BarChart3 icon) -- [ ] Each tab renders the existing feature page component unchanged -- [ ] Active tab persisted via Zustand store -- [ ] Uses `@ui` primitives only (PageLayout, PageHeader, PageContent) -- [ ] `npm run typecheck` and `npm run lint` pass - -### Task #3: GitPage Feature Module - -**Agent**: component-engineer -**Wave**: 2 -**Blocked by**: [1] -**Blocks**: [] -**Estimated complexity**: MEDIUM -**Context budget**: ~14,000 tokens (files: 6) - -**Description**: -Create a `git-overview` feature module. Unlike PlanningPage (which is tabs), GitPage has a single main view (GitHubPage) with a Changelog summary component embedded in the PageHeader. The ChangelogSummary shows the most recent entry inline, with buttons to expand (full popup via Dialog), copy full changelog to clipboard, and add a new entry. The main content area renders GitHubPage directly. - -**Files to Create**: -- `src/renderer/features/git-overview/index.ts` — Barrel export for GitPage -- `src/renderer/features/git-overview/components/GitPage.tsx` — PageHeader with ChangelogSummary in header actions area, GitHubPage as main content -- `src/renderer/features/git-overview/components/ChangelogSummary.tsx` — Compact latest-entry display with expand/copy/update actions. Uses existing `useChangelog` query hook from `@features/changelog`. Shows latest entry text truncated, with: Expand button (opens Dialog with full ChangelogPage), Copy button (copies all entries to clipboard), Update button (opens inline form or small dialog to add entry). - -**Files to Read for Context**: -- `src/renderer/features/changelog/index.ts` — What's exported (ChangelogPage, hooks) -- `src/renderer/features/changelog/api/useChangelog.ts` — Query hook for changelog data -- `src/renderer/features/github/components/GitHubPage.tsx` — What GitHubPage renders -- `src/renderer/features/tools/components/ToolsPage.tsx` — PageHeader pattern reference -- `src/renderer/shared/components/ui/dialog.tsx` — Dialog primitive for expand popup - -**Acceptance Criteria**: -- [ ] GitPage renders with PageHeader, title "Git", description -- [ ] ChangelogSummary in PageHeader.Actions area shows latest changelog entry -- [ ] Expand button opens Dialog with full changelog -- [ ] Copy button copies changelog to clipboard -- [ ] Main content area renders GitHubPage unchanged -- [ ] Uses `@ui` primitives only (PageLayout, PageHeader, PageContent, Dialog, Button) -- [ ] `npm run typecheck` and `npm run lint` pass - -### Task #4: ToolsConfigPlaceholder + Cleanup - -**Agent**: component-engineer -**Wave**: 2 -**Blocked by**: [1] -**Blocks**: [] -**Estimated complexity**: LOW -**Context budget**: ~11,000 tokens (files: 3) - -**Description**: -Replace the current `ToolsPage` (which renders Roadmap/Ideation/Insights/Changelog/GitHub as tabs) with a placeholder page for the future Claude Config suite. Simple empty state with icon, title, description of what's coming (Skills, Commands, Agents, Plugins management). The old ToolsPage content has been moved to PlanningPage and GitPage by tasks 2 and 3. - -**Files to Modify**: -- `src/renderer/features/tools/components/ToolsPage.tsx` — Replace entire content with placeholder using EmptyState or similar pattern. Remove imports of RoadmapPage, IdeationPage, etc. -- `src/renderer/features/tools/store.ts` — Simplify or keep for future use. Remove old tab types that reference roadmap/ideation/etc. -- `src/renderer/features/tools/index.ts` — Keep barrel, ensure ToolsPage is still exported - -**Files to Read for Context**: -- `src/renderer/shared/components/ui/empty-state.tsx` — EmptyState primitive -- `src/renderer/features/tools/components/ToolsPage.tsx` — Current implementation to replace - -**Acceptance Criteria**: -- [ ] ToolsPage renders a clean placeholder with Wrench icon, "Tools" title -- [ ] Description mentions upcoming: Skills, Commands, Agents, Plugins, Config -- [ ] No longer imports or renders Roadmap/Ideation/Insights/Changelog/GitHub -- [ ] Store cleaned up (no stale tab types) -- [ ] `npm run typecheck` and `npm run lint` pass -- [ ] `npm run build` passes - -## 5. Wave Plan - -### Wave 1: Foundation (no blockers) -- Task #1: Route Constants + Sidebar Nav Config — router-engineer - -### Wave 2: Feature Modules (blocked by Wave 1) -- Task #2: PlanningPage Feature Module — component-engineer -- Task #3: GitPage Feature Module — component-engineer -- Task #4: ToolsConfigPlaceholder + Cleanup — component-engineer - -(Tasks 2, 3, 4 touch different feature directories — run in parallel) - -### Dependency Graph - -``` -#1 Routes + Sidebar ──┬──> #2 PlanningPage - ├──> #3 GitPage - └──> #4 ToolsConfigPlaceholder -``` - -### Parallel Opportunities -- Wave 2: All three tasks are fully independent (different feature dirs, no shared writable files) - -## 6. File Ownership Matrix - -``` -src/shared/constants/routes.ts → Task #1 -src/renderer/app/layouts/sidebar-layouts/shared-nav.ts → Task #1 -src/renderer/app/layouts/sidebar-layouts/layout-configs.ts → Task #1 -src/renderer/app/routes/project.routes.ts → Task #1 -src/renderer/features/planning/* → Task #2 -src/renderer/features/git-overview/* → Task #3 -src/renderer/features/tools/* → Task #4 -``` - -Conflicts: NONE - -## 7. Context Budget - -``` -Task #1: 8,000 + 4 × 1,000 + 3,000 = ~15,000 tokens -Task #2: 8,000 + 4 × 1,000 + 3,000 = ~15,000 tokens (3 create + read refs) -Task #3: 8,000 + 6 × 1,000 + 3,000 = ~17,000 tokens (3 create + read refs) -Task #4: 8,000 + 3 × 1,000 + 3,000 = ~14,000 tokens -``` - -All under threshold. - -## 8. Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| Old route bookmarks break | Medium | Low | Redirect routes from old paths to new | -| Changelog hook not exported from feature | Low | Low | Check barrel before starting Task #3 | -| Layout-09 devSubGroups break with 7 items | Medium | Medium | Task #1 explicitly updates indices | -| ToolsPage removal leaves unused deps | Low | Low | Task #4 cleans up imports | - -## 9. QA Strategy - -### Per-Task QA Sections -- Task #1: route constants match, sidebar renders correct items, old routes redirect -- Task #2: tabs render correct content, store persists, PageHeader pattern correct -- Task #3: ChangelogSummary shows data, expand/copy/update work, GitHubPage renders -- Task #4: placeholder renders, no stale imports, build passes - -### Guardian Focus Areas -- No orphaned route constants (all old paths either redirect or removed) -- No broken imports from deleted routes -- Sidebar item count matches route count -- All features still accessible via navigation - -## 10. Implementation Notes - -- The existing `ToolsPage` at `src/renderer/features/tools/components/ToolsPage.tsx` is the exact pattern to follow for PlanningPage -- `PageHeader.Tabs` / `PageHeader.TabList` / `PageHeader.Tab` / `PageHeader.TabContent` are the tab primitives -- Icons: use `Map` for Planning, `GitBranch` for Git, `Wrench` for Tools (from lucide-react) -- The `ChangelogSummary` component in Task #3 needs to use the existing changelog query hooks — check `@features/changelog` barrel for what's exported -- `shared-nav.ts` `developmentItems` array order directly controls sidebar item order - -## Task Handoff Files - -Per-agent task files generated at `docs/features/sidenav-restructure/tasks/`: -- task-1.md — Route Constants + Sidebar Nav Config -- task-2.md — PlanningPage Feature Module -- task-3.md — GitPage Feature Module -- task-4.md — ToolsConfigPlaceholder + Cleanup - -To execute this plan, run: `/agent-team` diff --git a/docs/features/sidenav-restructure/plan.md b/docs/features/sidenav-restructure/plan.md deleted file mode 100644 index a1046ba2..00000000 --- a/docs/features/sidenav-restructure/plan.md +++ /dev/null @@ -1,172 +0,0 @@ -# Sidenav Restructure Plan - -> Reorganize project-scoped navigation from a catch-all "Tools" bucket into logical groupings: Planning, Git, Tools (Claude Config), while keeping feature slices untouched. - -## Current State - -**Global nav:** Home, My Work, Fitness, Productivity -**Project-scoped nav:** Workspace, Tasks, Terminals, Visual Map, Tools - -"Tools" currently houses GitHub, Ideation, Roadmap, Changelog, Insights — unrelated features grouped by elimination. - -## Target State - -### Global (top section — always visible) - -| Item | Icon | Route | Contents | -|------|------|-------|----------| -| Home | `Home` | `/dashboard` | Dashboard, daily stats, quick capture | -| My Work | `Briefcase` | `/my-work` | Cross-project task view | -| Productivity | `Headphones` | `/productivity` | Calendar, Spotify, Briefing, Notes, Planner, Alerts, Comms | -| Fitness | `Dumbbell` | `/fitness` | Workouts, metrics | - -### Project-scoped (middle section — requires active project) - -| Item | Icon | Route Segment | Contents | -|------|------|---------------|----------| -| Workspace | `Bot` | `agents` | Primary session + Team Leads (unchanged) | -| Tasks | `ListTodo` | `tasks` | Task data grid (unchanged) | -| Terminals | `Terminal` | `terminals` | Terminal grid (unchanged) | -| Planning | `Map` | `planning` | Tabbed: Roadmap, Ideation, Insights | -| Git | `GitBranch` | `git` | GitHub (PRs, Issues, Notifications) + Changelog summary in header | -| Tools | `Wrench` | `tools` | Claude Config suite: Skills, Commands, Agents, Plugins, Config tabs | -| Visual Map | `Network` | `visualization` | Codebase graph (unchanged) | - -## Changes Required - -### Phase 1: Route Constants + Sidebar Labels (minimal risk) - -**File: `src/shared/constants/routes.ts`** -- Add `PLANNING: 'planning'` and `GIT: 'git'` to `PROJECT_VIEWS` -- Add `PROJECT_PLANNING` and `PROJECT_GIT` to `ROUTE_PATTERNS` -- Keep existing constants (`ROADMAP`, `IDEATION`, `GITHUB`, `CHANGELOG`, `INSIGHTS`) for backwards compat until Phase 2 routes are wired -- `TOOLS` stays as-is (same path segment, new content in Phase 3) - -**File: `src/renderer/app/layouts/Sidebar.tsx`** -- Update `projectItems` array: - - Workspace, Tasks, Terminals — unchanged - - Replace `Visual Map` → keep as-is - - Replace `Tools` (BarChart3, tools path) with three entries: - - `{ label: 'Planning', icon: Map, path: 'planning' }` - - `{ label: 'Git', icon: GitBranch, path: 'git' }` - - `{ label: 'Tools', icon: Wrench, path: 'tools' }` (repurposed) - -### Phase 2: New Tabbed Pages (low risk — new components, existing feature slices) - -**New file: `src/renderer/features/planning/`** -- `PlanningPage.tsx` — Tabbed container using `PageHeader` compound component with `.Tabs` - - Tab 1: Roadmap → renders existing `RoadmapPage` component - - Tab 2: Ideation → renders existing `IdeationPage` component - - Tab 3: Insights → renders existing `InsightsPage` component -- Pattern: identical to how `ProductivityPage` wraps its sub-features as tabs -- Barrel export in `index.ts` - -**New file: `src/renderer/features/git-hub/` (or modify existing `github/`)** -- `GitPage.tsx` — GitHub as primary content, Changelog as header summary - - Embeds existing `GitHubPage` components (PRList, IssueList, etc.) - - `ChangelogSummary` component in `PageHeader` area: - - Shows most recent changelog entry inline - - "Expand" button → full popup/dialog with all entries - - "Copy" button → clipboard (full changelog) - - "Update" button → add new entry -- Reuses all existing GitHub feature hooks/api/store unchanged - -**File: `src/renderer/app/routes/project.routes.ts`** -- Add `planningRoute` → lazy loads `PlanningPage` -- Add `gitRoute` → lazy loads `GitPage` -- Keep old individual routes as redirects to new locations (or remove if no deep links exist) - -### Phase 3: Tools → Claude Config Suite (new feature, largest effort) - -**New feature: `src/renderer/features/tools-config/`** - -``` -tools-config/ -├── index.ts -├── api/ -│ ├── queryKeys.ts -│ ├── useToolsConfig.ts # queries for skills/commands/agents/plugins -│ └── useToolsConfigMutations.ts -├── components/ -│ ├── ToolsConfigPage.tsx # tabbed container -│ ├── SkillsTab.tsx # list + editor -│ ├── CommandsTab.tsx # list + editor -│ ├── AgentsTab.tsx # list + editor -│ ├── PluginsTab.tsx # installed plugins browser -│ ├── ConfigTab.tsx # settings.json + CLAUDE.md visual editor -│ ├── MarkdownFileEditor.tsx # shared split-pane editor with preview -│ └── GenerateWithAi.tsx # AI generation prompt overlay -├── hooks/ -│ └── useToolsConfigEvents.ts -└── store.ts # active tab, selected file, editor state -``` - -**IPC channels (new domain: `src/shared/ipc/tools/`):** - -``` -tools.skills.list → SkillEntry[] -tools.skills.read → { content, frontmatter } -tools.skills.write → void -tools.skills.generate → streamed response - -tools.commands.list → CommandEntry[] -tools.commands.read → { content } -tools.commands.write → void - -tools.agents.list → AgentEntry[] -tools.agents.read → { content } -tools.agents.write → void - -tools.plugins.list → PluginEntry[] -tools.plugins.toggle → void - -tools.config.read → { settings, claudeMd } -tools.config.write → void -``` - -**Main process service: `src/main/services/tools-config/`** -- Reads/writes `.claude/skills/`, `.claude/commands/`, `.claude/agents/` -- Reads `~/.claude/commands/` for user-level commands -- Reads `~/.claude/plugins/` registry for installed plugins -- "Generate with AI" routes through assistant service with skill-authoring system prompt - -### Phase 4: Chat Autocomplete (depends on Phase 3) - -**Shared hook: `src/renderer/shared/hooks/useAvailableCommands.ts`** -- Consumes `tools.commands.list` + `tools.skills.list` IPC channels -- Returns unified list of available slash commands for autocomplete - -**Shared component: `src/renderer/shared/components/CommandAutocomplete.tsx`** -- Wraps `<Input>` with slash-command popup -- Triggered by `/` at position 0 -- Arrow keys + Tab/Enter to select -- Used in: PrimarySessionPanel, TeamLeadPanel, SidebarAssistantButton - -## Risk Assessment - -| Phase | Risk | Reason | -|-------|------|--------| -| 1 | Minimal | Constants + sidebar labels only | -| 2 | Low | New wrapper components, existing features unchanged | -| 3 | Medium | New feature slice + IPC + service, but isolated | -| 4 | Low | Additive UI component, no existing behavior changed | - -## Key Principles - -- **Feature slices stay untouched.** RoadmapPage, IdeationPage, InsightsPage, GitHubPage, ChangelogPage — zero internal changes. They get re-mounted under new routes via wrapper pages. -- **Follows existing patterns.** PlanningPage tabs = same pattern as ProductivityPage. GitPage = same PageHeader compound component pattern. -- **Incremental delivery.** Each phase is independently shippable and testable. - -## Agent Team Decomposition - -| Wave | Task | Files | -|------|------|-------| -| 1 | Route constants + Sidebar update | routes.ts, Sidebar.tsx | -| 1 | PlanningPage (tabbed wrapper) | features/planning/* | -| 1 | GitPage + ChangelogSummary | features/git-hub/* or github/* | -| 2 | Tools Config IPC contracts | shared/ipc/tools/*, main/ipc/handlers/tools-* | -| 2 | Tools Config service | main/services/tools-config/* | -| 3 | MarkdownFileEditor component | features/tools-config/components/* | -| 3 | ToolsConfigPage + all tabs | features/tools-config/* | -| 4 | useAvailableCommands + CommandAutocomplete | shared/hooks/*, shared/components/* | -| 4 | Wire autocomplete into chat inputs | workspace/*, assistant/* | diff --git a/docs/features/sidenav-restructure/tasks/task-1.md b/docs/features/sidenav-restructure/tasks/task-1.md deleted file mode 100644 index af2f368d..00000000 --- a/docs/features/sidenav-restructure/tasks/task-1.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -taskNumber: 1 -taskName: "Route Constants + Sidebar Nav Config" -taskSlug: "routes-sidebar" -agentRole: "router-engineer" -wave: 1 -blockedBy: [] -blocks: [2, 3, 4] -estimatedTokens: 15000 -complexity: "LOW" -status: "pending" ---- - -# Task #1: Route Constants + Sidebar Nav Config - -## Description -Add PLANNING and GIT route segments to route constants. Update sidebar shared-nav.ts to show 7 project-scoped items (Workspace, Tasks, Terminals, Planning, Git, Tools, Visual Map). Update layout-configs.ts sidebar-09 devSubGroups for the new item count. Update project.routes.ts to add planning and git routes, remove old individual routes for roadmap/ideation/insights/changelog (add redirects from old paths). - -## Acceptance Criteria -- [ ] `PROJECT_VIEWS.PLANNING` and `PROJECT_VIEWS.GIT` exist in routes.ts -- [ ] `ROUTE_PATTERNS.PROJECT_PLANNING` and `ROUTE_PATTERNS.PROJECT_GIT` exist -- [ ] Old `PROJECT_VIEWS.ROADMAP`, `IDEATION`, `INSIGHTS`, `CHANGELOG` removed from PROJECT_VIEWS -- [ ] Old ROUTE_PATTERNS kept temporarily for redirect routes -- [ ] Sidebar shared-nav.ts has 7 development items in order: Workspace, Tasks, Terminals, Planning, Git, Tools, Visual Map -- [ ] Icons: Bot, ListTodo, Terminal, Map, GitBranch, Wrench, Network -- [ ] layout-configs.ts sidebar-09 devSubGroups indices updated for 7 items -- [ ] project.routes.ts: planningRoute lazy-loads PlanningPage from @features/planning -- [ ] project.routes.ts: gitRoute lazy-loads GitPage from @features/git-overview -- [ ] Old roadmap/ideation/insights/changelog routes redirect to /planning or /git -- [ ] Individual github route removed (now part of git route) -- [ ] `npm run typecheck` passes -- [ ] `npm run lint` passes - -## Files to Modify -- `src/shared/constants/routes.ts` — Add PLANNING, GIT to PROJECT_VIEWS and ROUTE_PATTERNS -- `src/renderer/app/layouts/sidebar-layouts/shared-nav.ts` — Replace 5 items with 7 items, update icons -- `src/renderer/app/layouts/sidebar-layouts/layout-configs.ts` — Update sidebar-09 devSubGroups -- `src/renderer/app/routes/project.routes.ts` — Add planningRoute, gitRoute; redirect old routes - -## Files to Read for Context -- `src/renderer/app/layouts/sidebar-layouts/AppSidebar.tsx` — How nav items are consumed -- `docs/features/sidenav-restructure/plan.md` — Full restructure plan - -## Implementation Notes -- Use `lazyRouteComponent(() => import('@features/planning'), 'PlanningPage')` for planning route -- Use `lazyRouteComponent(() => import('@features/git-overview'), 'GitPage')` for git route -- Redirect old routes using `beforeLoad: ({ params }) => { throw redirect({ to: newPath, params }) }` -- The features don't exist yet (Tasks 2-4 create them) — the routes will fail to resolve until Wave 2 completes. This is expected. -- sidebar-09 currently slices dev items into Code(0-3), Plan(3-6), Track(6-9). With 7 items: Code(0-3), Plan(3-5), Track(5-7) or similar — use judgment for logical grouping. diff --git a/docs/features/sidenav-restructure/tasks/task-2.md b/docs/features/sidenav-restructure/tasks/task-2.md deleted file mode 100644 index 6874f9ab..00000000 --- a/docs/features/sidenav-restructure/tasks/task-2.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -taskNumber: 2 -taskName: "PlanningPage Feature Module" -taskSlug: "planning-page" -agentRole: "component-engineer" -wave: 2 -blockedBy: [1] -blocks: [] -estimatedTokens: 12000 -complexity: "LOW" -status: "pending" ---- - -# Task #2: PlanningPage Feature Module - -## Description -Create a new `planning` feature module with a tabbed page wrapping Roadmap, Ideation, and Insights. Follow the exact same pattern as the existing ToolsPage which wraps features as tabs using PageHeader compound component. - -## Acceptance Criteria -- [ ] `src/renderer/features/planning/` directory created with standard feature structure -- [ ] PlanningPage renders with PageHeader, title "Planning", description "Roadmap, ideas, and project analytics" -- [ ] Three tabs: Roadmap (Map icon), Ideation (Lightbulb icon), Insights (BarChart3 icon) -- [ ] Each tab renders the existing feature page component from its barrel export -- [ ] Active tab persisted via Zustand store -- [ ] Uses `@ui` primitives only (PageLayout, PageHeader, PageContent) -- [ ] Barrel export: `export { PlanningPage } from './components/PlanningPage'` -- [ ] `npm run typecheck` and `npm run lint` pass - -## Files to Create -- `src/renderer/features/planning/index.ts` — Barrel export -- `src/renderer/features/planning/components/PlanningPage.tsx` — Tabbed wrapper -- `src/renderer/features/planning/store.ts` — Zustand store for activeTab - -## Files to Read for Context -- `src/renderer/features/tools/components/ToolsPage.tsx` — COPY THIS PATTERN EXACTLY -- `src/renderer/features/tools/store.ts` — Store pattern reference -- `src/renderer/features/tools/index.ts` — Barrel pattern reference - -## Implementation Notes -- The ToolsPage at `src/renderer/features/tools/components/ToolsPage.tsx` is the reference implementation. Read it and follow its exact structure. -- Import existing pages: `import { RoadmapPage } from '@features/roadmap'`, `import { IdeationPage } from '@features/ideation'`, `import { InsightsPage } from '@features/insights'` -- Tab type: `type PlanningTab = 'roadmap' | 'ideation' | 'insights'` -- Store: `create<{ activeTab: PlanningTab; setActiveTab: (tab: PlanningTab) => void }>` -- Default tab: 'roadmap' diff --git a/docs/features/sidenav-restructure/tasks/task-3.md b/docs/features/sidenav-restructure/tasks/task-3.md deleted file mode 100644 index abdae26a..00000000 --- a/docs/features/sidenav-restructure/tasks/task-3.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -taskNumber: 3 -taskName: "GitPage Feature Module" -taskSlug: "git-page" -agentRole: "component-engineer" -wave: 2 -blockedBy: [1] -blocks: [] -estimatedTokens: 17000 -complexity: "MEDIUM" -status: "pending" ---- - -# Task #3: GitPage Feature Module - -## Description -Create a `git-overview` feature module. GitPage has a single main view (GitHubPage) with a ChangelogSummary component embedded in the PageHeader. The ChangelogSummary shows the most recent entry inline, with buttons to expand (full popup via Dialog), copy full changelog to clipboard, and add a new entry. - -## Acceptance Criteria -- [ ] `src/renderer/features/git-overview/` directory created with standard feature structure -- [ ] GitPage renders with PageHeader, title "Git", description "Source control and changelog" -- [ ] ChangelogSummary component in PageHeader.Actions area shows latest changelog entry -- [ ] Latest entry truncated to ~100 chars with ellipsis -- [ ] Expand button opens Dialog with full ChangelogPage rendered inside -- [ ] Copy button copies full changelog text to clipboard (navigator.clipboard.writeText) -- [ ] Main content area renders GitHubPage unchanged -- [ ] Uses `@ui` primitives only (PageLayout, PageHeader, PageContent, Dialog, Button, Text) -- [ ] Barrel export: `export { GitPage } from './components/GitPage'` -- [ ] `npm run typecheck` and `npm run lint` pass - -## Files to Create -- `src/renderer/features/git-overview/index.ts` — Barrel export -- `src/renderer/features/git-overview/components/GitPage.tsx` — PageHeader + GitHubPage body -- `src/renderer/features/git-overview/components/ChangelogSummary.tsx` — Compact changelog in header - -## Files to Read for Context -- `src/renderer/features/changelog/index.ts` — What's exported (ChangelogPage, hooks) -- `src/renderer/features/changelog/api/useChangelog.ts` — Query hook for changelog data (or check barrel) -- `src/renderer/features/github/index.ts` — GitHubPage export -- `src/renderer/features/tools/components/ToolsPage.tsx` — PageHeader pattern reference -- `src/renderer/shared/components/ui/dialog.tsx` — Dialog primitive for expand popup - -## Implementation Notes -- Check `@features/changelog` barrel to see what query hooks are exported. If none, import directly from the api/ directory. -- ChangelogSummary should use TanStack Query to fetch changelog entries (reuse existing hook) -- For the expand dialog, render `<ChangelogPage />` inside `<DialogContent>` with appropriate sizing -- Copy button: `await navigator.clipboard.writeText(entries.map(e => e.text).join('\n'))` -- If no changelog entries exist, show a subtle "No entries yet" text with just the Update button -- The GitHubPage is the main content — it should fill the available space below the header diff --git a/docs/features/sidenav-restructure/tasks/task-4.md b/docs/features/sidenav-restructure/tasks/task-4.md deleted file mode 100644 index 32a675e2..00000000 --- a/docs/features/sidenav-restructure/tasks/task-4.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -taskNumber: 4 -taskName: "ToolsConfigPlaceholder + Cleanup" -taskSlug: "tools-placeholder" -agentRole: "component-engineer" -wave: 2 -blockedBy: [1] -blocks: [] -estimatedTokens: 11000 -complexity: "LOW" -status: "pending" ---- - -# Task #4: ToolsConfigPlaceholder + Cleanup - -## Description -Replace the current ToolsPage (which renders Roadmap/Ideation/Insights/Changelog/GitHub as tabs) with a placeholder page for the future Claude Config suite. Simple empty state with icon, title, description of what's coming. Clean up the store to remove old tab types. - -## Acceptance Criteria -- [ ] ToolsPage renders a clean placeholder with Wrench icon -- [ ] Title: "Tools" with description: "Manage Claude skills, commands, agents, and plugins" -- [ ] Body shows coming-soon cards or list: Skills, Commands, Agents, Plugins, Config -- [ ] No longer imports RoadmapPage, IdeationPage, InsightsPage, ChangelogPage, GitHubPage -- [ ] Store simplified — remove old ToolsTab type and tab state if not needed -- [ ] Barrel export still works: `export { ToolsPage } from './components/ToolsPage'` -- [ ] `npm run typecheck` and `npm run lint` pass -- [ ] `npm run build` passes (final verification) - -## Files to Modify -- `src/renderer/features/tools/components/ToolsPage.tsx` — Replace with placeholder -- `src/renderer/features/tools/store.ts` — Clean up old tab types -- `src/renderer/features/tools/index.ts` — Keep barrel, ensure exports - -## Files to Read for Context -- `src/renderer/features/tools/components/ToolsPage.tsx` — Current implementation to replace -- `src/renderer/shared/components/ui/empty-state.tsx` — EmptyState primitive if it exists -- `src/renderer/features/tools/store.ts` — Current store to simplify - -## Implementation Notes -- Use PageLayout + PageHeader + PageContent from @ui -- For the placeholder content, use Card primitives to show 5 "coming soon" items: Skills, Commands, Agents, Plugins, Config — each with a small icon and one-line description -- Icons: Sparkles (Skills), Terminal (Commands), Bot (Agents), Puzzle (Plugins), Settings (Config) from lucide-react -- Keep the store file but simplify it — future Tools Config implementation will add real state -- This task should also run `npm run build` as the final check since it's the last task diff --git a/docs/features/user-scoped-storage/plan.md b/docs/features/user-scoped-storage/plan.md deleted file mode 100644 index c450c8b3..00000000 --- a/docs/features/user-scoped-storage/plan.md +++ /dev/null @@ -1,897 +0,0 @@ -# User-Scoped Data Storage Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Isolate user data (notes, captures, briefings, etc.) per logged-in user so different Hub accounts don't share local data. - -**Architecture:** Create a `users/<userId>/` subdirectory structure under userData. Services that store user-specific data receive a user-scoped path after login. Global settings (hub-config, oauth-tokens, app settings) remain at the root. On login, services reinitialize with user-scoped paths. On logout, in-memory caches clear and services reset to pre-login state. - -**Tech Stack:** Electron (app.getPath), Node.js fs, TypeScript, existing service factory pattern - ---- - -## File Categories - -### User-Scoped Files (move to `users/<userId>/`) - -- `notes.json` - Personal notes -- `captures.json` - Quick capture entries -- `briefings.json` - Daily briefing summaries -- `assistant-history.json` - Assistant command history -- `assistant-watches.json` - Proactive notification triggers -- `alerts.json` - User alerts -- `ideas.json` - Ideas board -- `milestones.json` - Project milestones -- `changelog.json` - Change records -- `planner/` - Daily plans directory -- `fitness/` - Workouts, measurements, goals - -### Global Files (remain at root) - -- `settings.json` - App preferences (theme, hotkeys, API keys) -- `hub-config.json` - Hub connection (needed before login) -- `oauth-tokens.json` - OAuth tokens -- `oauth-providers.json` - OAuth client credentials -- `voice-config.json` - Voice input configuration -- `email-config.json` - SMTP configuration -- `error-log.json` - Error diagnostics -- `hub-sync-queue.json` - Offline sync queue -- `worktrees.json` - Git worktree mapping - ---- - -## Task 1: Create User Data Path Resolver - -**Files:** - -- Create: `src/main/services/data-management/user-data-resolver.ts` -- Modify: `src/main/services/data-management/index.ts` - -**Step 1: Create the user data resolver module** - -```typescript -// src/main/services/data-management/user-data-resolver.ts -/** - * User Data Path Resolver - * - * Provides user-scoped data directory paths. User data is stored in - * `<userData>/users/<userId>/` to isolate data between Hub accounts. - */ - -import { existsSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; - -export interface UserDataResolver { - /** Get the data directory for a specific user. Creates if needed. */ - getUserDataDir(userId: string): string; - /** Get the global data directory (for non-user-scoped data). */ - getGlobalDataDir(): string; - /** Check if a user data directory exists. */ - userDataExists(userId: string): boolean; -} - -export function createUserDataResolver(baseDataDir: string): UserDataResolver { - const usersDir = join(baseDataDir, 'users'); - - function getUserDataDir(userId: string): string { - if (!userId) { - throw new Error('userId is required for user-scoped data'); - } - const userDir = join(usersDir, userId); - if (!existsSync(userDir)) { - mkdirSync(userDir, { recursive: true }); - } - return userDir; - } - - function getGlobalDataDir(): string { - return baseDataDir; - } - - function userDataExists(userId: string): boolean { - if (!userId) return false; - return existsSync(join(usersDir, userId)); - } - - return { - getUserDataDir, - getGlobalDataDir, - userDataExists, - }; -} -``` - -**Step 2: Export from barrel** - -Add to `src/main/services/data-management/index.ts`: - -```typescript -export { createUserDataResolver, type UserDataResolver } from './user-data-resolver'; -``` - -**Step 3: Commit** - -```bash -git add src/main/services/data-management/user-data-resolver.ts src/main/services/data-management/index.ts -git commit -m "feat(storage): add user data path resolver" -``` - ---- - -## Task 2: Create User Session Manager - -**Files:** - -- Create: `src/main/services/auth/user-session-manager.ts` -- Create: `src/main/services/auth/index.ts` - -**Step 1: Create user session manager** - -```typescript -// src/main/services/auth/user-session-manager.ts -/** - * User Session Manager - * - * Tracks the currently logged-in user and emits events on login/logout. - * Services subscribe to these events to reinitialize with user-scoped paths. - */ - -import type { IpcRouter } from '@main/ipc/router'; - -export interface UserSession { - userId: string; - email: string; -} - -export interface UserSessionManager { - /** Get current session, or null if not logged in. */ - getCurrentSession(): UserSession | null; - /** Called when user logs in successfully. */ - setSession(session: UserSession): void; - /** Called when user logs out. */ - clearSession(): void; - /** Subscribe to session changes. Returns unsubscribe function. */ - onSessionChange(callback: (session: UserSession | null) => void): () => void; -} - -export function createUserSessionManager(router: IpcRouter): UserSessionManager { - let currentSession: UserSession | null = null; - const listeners = new Set<(session: UserSession | null) => void>(); - - function notifyListeners(): void { - for (const listener of listeners) { - listener(currentSession); - } - } - - return { - getCurrentSession() { - return currentSession; - }, - - setSession(session) { - currentSession = session; - router.emit('event:user.sessionChanged', { userId: session.userId, email: session.email }); - notifyListeners(); - }, - - clearSession() { - currentSession = null; - router.emit('event:user.sessionChanged', { userId: null, email: null }); - notifyListeners(); - }, - - onSessionChange(callback) { - listeners.add(callback); - return () => listeners.delete(callback); - }, - }; -} -``` - -**Step 2: Create barrel export** - -```typescript -// src/main/services/auth/index.ts -export { - createUserSessionManager, - type UserSessionManager, - type UserSession, -} from './user-session-manager'; -``` - -**Step 3: Add IPC event contract** - -Add to `src/shared/ipc/auth/contract.ts` in the events section: - -```typescript -'event:user.sessionChanged': { - payload: z.object({ - userId: z.string().nullable(), - email: z.string().nullable(), - }), -}, -``` - -**Step 4: Commit** - -```bash -git add src/main/services/auth/ src/shared/ipc/auth/contract.ts -git commit -m "feat(auth): add user session manager with session change events" -``` - ---- - -## Task 3: Create Reinitializable Service Pattern - -**Files:** - -- Create: `src/main/services/data-management/reinitializable-service.ts` -- Modify: `src/main/services/data-management/index.ts` - -**Step 1: Create reinitializable service interface** - -```typescript -// src/main/services/data-management/reinitializable-service.ts -/** - * Reinitializable Service Pattern - * - * Services that store user-scoped data implement this interface. - * They reinitialize when the user changes (login/logout). - */ - -export interface ReinitializableService { - /** Reinitialize the service with a new data directory. */ - reinitialize(dataDir: string): void; - /** Clear all in-memory state (called on logout). */ - clearState(): void; -} - -/** - * Type guard to check if a service is reinitializable. - */ -export function isReinitializable(service: unknown): service is ReinitializableService { - return ( - typeof service === 'object' && - service !== null && - 'reinitialize' in service && - 'clearState' in service && - typeof (service as ReinitializableService).reinitialize === 'function' && - typeof (service as ReinitializableService).clearState === 'function' - ); -} -``` - -**Step 2: Export from barrel** - -Add to `src/main/services/data-management/index.ts`: - -```typescript -export { type ReinitializableService, isReinitializable } from './reinitializable-service'; -``` - -**Step 3: Commit** - -```bash -git add src/main/services/data-management/ -git commit -m "feat(storage): add reinitializable service pattern" -``` - ---- - -## Task 4: Update Notes Service for User-Scoping - -**Files:** - -- Modify: `src/main/services/notes/notes-service.ts` - -**Step 1: Add reinitializable interface to notes service** - -Update the `NotesService` interface to extend `ReinitializableService`: - -```typescript -import type { ReinitializableService } from '@main/services/data-management'; - -export interface NotesService extends ReinitializableService { - // ... existing methods -} -``` - -**Step 2: Add state management variables** - -Add near the top of the factory function: - -```typescript -let currentFilePath = join(deps.dataDir, NOTES_FILE); -let notesCache: Note[] | null = null; -``` - -**Step 3: Update all file operations to use `currentFilePath`** - -Replace hardcoded `filePath` references with `currentFilePath`. - -**Step 4: Implement reinitialize and clearState** - -Add to the returned object: - -```typescript -reinitialize(dataDir: string) { - currentFilePath = join(dataDir, NOTES_FILE); - notesCache = null; // Force reload from new path -}, - -clearState() { - notesCache = null; -}, -``` - -**Step 5: Commit** - -```bash -git add src/main/services/notes/notes-service.ts -git commit -m "feat(notes): make notes service reinitializable for user-scoping" -``` - ---- - -## Task 5: Update Captures/Dashboard Service for User-Scoping - -**Files:** - -- Modify: `src/main/services/dashboard/dashboard-service.ts` - -**Step 1: Add reinitializable interface** - -Same pattern as Task 4 - add `ReinitializableService` interface, add state variables, update file operations, implement `reinitialize` and `clearState`. - -**Step 2: Commit** - -```bash -git add src/main/services/dashboard/dashboard-service.ts -git commit -m "feat(dashboard): make dashboard service reinitializable for user-scoping" -``` - ---- - -## Task 6: Update Briefing Service for User-Scoping - -**Files:** - -- Modify: `src/main/services/briefing/briefing-service.ts` - -**Step 1: Add reinitializable interface** - -Same pattern as Task 4. - -**Step 2: Commit** - -```bash -git add src/main/services/briefing/briefing-service.ts -git commit -m "feat(briefing): make briefing service reinitializable for user-scoping" -``` - ---- - -## Task 7: Update Assistant History Service for User-Scoping - -**Files:** - -- Modify: `src/main/services/assistant/history-store.ts` - -**Step 1: Add reinitializable interface** - -Same pattern as Task 4. - -**Step 2: Commit** - -```bash -git add src/main/services/assistant/history-store.ts -git commit -m "feat(assistant): make history store reinitializable for user-scoping" -``` - ---- - -## Task 8: Update Alerts Service for User-Scoping - -**Files:** - -- Modify: `src/main/services/alerts/alert-store.ts` - -**Step 1: Add reinitializable interface** - -Same pattern as Task 4. - -**Step 2: Commit** - -```bash -git add src/main/services/alerts/alert-store.ts -git commit -m "feat(alerts): make alert store reinitializable for user-scoping" -``` - ---- - -## Task 9: Update Ideas Service for User-Scoping - -**Files:** - -- Modify: `src/main/services/ideas/ideas-service.ts` - -**Step 1: Add reinitializable interface** - -Same pattern as Task 4. - -**Step 2: Commit** - -```bash -git add src/main/services/ideas/ideas-service.ts -git commit -m "feat(ideas): make ideas service reinitializable for user-scoping" -``` - ---- - -## Task 10: Update Planner Service for User-Scoping - -**Files:** - -- Modify: `src/main/services/planner/planner-service.ts` - -**Step 1: Add reinitializable interface** - -This service uses a directory (`planner/`) instead of a single file. Update the `plannerDir` variable to be mutable and reinitializable. - -**Step 2: Commit** - -```bash -git add src/main/services/planner/planner-service.ts -git commit -m "feat(planner): make planner service reinitializable for user-scoping" -``` - ---- - -## Task 11: Update Fitness Service for User-Scoping - -**Files:** - -- Modify: `src/main/services/fitness/fitness-service.ts` - -**Step 1: Add reinitializable interface** - -This service uses a directory (`fitness/`). Same pattern as planner. - -**Step 2: Commit** - -```bash -git add src/main/services/fitness/fitness-service.ts -git commit -m "feat(fitness): make fitness service reinitializable for user-scoping" -``` - ---- - -## Task 12: Update Milestones Service for User-Scoping - -**Files:** - -- Modify: `src/main/services/milestones/milestones-service.ts` - -**Step 1: Add reinitializable interface** - -Same pattern as Task 4. - -**Step 2: Commit** - -```bash -git add src/main/services/milestones/milestones-service.ts -git commit -m "feat(milestones): make milestones service reinitializable for user-scoping" -``` - ---- - -## Task 13: Update Changelog Service for User-Scoping - -**Files:** - -- Modify: `src/main/services/changelog/changelog-service.ts` - -**Step 1: Add reinitializable interface** - -Same pattern as Task 4. - -**Step 2: Commit** - -```bash -git add src/main/services/changelog/changelog-service.ts -git commit -m "feat(changelog): make changelog service reinitializable for user-scoping" -``` - ---- - -## Task 14: Wire Up Service Registry with User Session - -**Files:** - -- Modify: `src/main/bootstrap/service-registry.ts` - -**Step 1: Import new modules** - -```typescript -import { createUserDataResolver, isReinitializable } from '@main/services/data-management'; -import { createUserSessionManager } from '@main/services/auth'; -``` - -**Step 2: Create user session manager and data resolver** - -After `const dataDir = app.getPath('userData');`: - -```typescript -const userDataResolver = createUserDataResolver(dataDir); -const userSessionManager = createUserSessionManager(router); -``` - -**Step 3: Collect user-scoped services** - -After all services are created, collect the reinitializable ones: - -```typescript -const userScopedServices = [ - notesService, - dashboardService, - briefingService, - assistantHistoryStore, - alertStore, - ideasService, - plannerService, - fitnessService, - milestonesService, - changelogService, -].filter(isReinitializable); -``` - -**Step 4: Subscribe to session changes** - -```typescript -userSessionManager.onSessionChange((session) => { - if (session) { - // User logged in - reinitialize with user-scoped paths - const userDataDir = userDataResolver.getUserDataDir(session.userId); - for (const service of userScopedServices) { - service.reinitialize(userDataDir); - } - } else { - // User logged out - clear state and reset to global dir - for (const service of userScopedServices) { - service.clearState(); - service.reinitialize(dataDir); // Reset to global (pre-login state) - } - } -}); -``` - -**Step 5: Export userSessionManager** - -Add to the returned services object: - -```typescript -return { - // ... existing - userSessionManager, -}; -``` - -**Step 6: Commit** - -```bash -git add src/main/bootstrap/service-registry.ts -git commit -m "feat(bootstrap): wire user session to service reinitialization" -``` - ---- - -## Task 15: Update Auth Handlers to Trigger Session Changes - -**Files:** - -- Modify: `src/main/ipc/handlers/auth-handlers.ts` - -**Step 1: Accept userSessionManager in dependencies** - -Update the handler factory to accept `userSessionManager`. - -**Step 2: Call setSession on successful login** - -After successful login in `auth.login` handler: - -```typescript -deps.userSessionManager.setSession({ - userId: result.user.id, - email: result.user.email, -}); -``` - -**Step 3: Call clearSession on logout** - -In `auth.logout` handler: - -```typescript -deps.userSessionManager.clearSession(); -``` - -**Step 4: Call setSession on successful session restore** - -In `auth.restore` handler when restore succeeds: - -```typescript -deps.userSessionManager.setSession({ - userId: result.user.id, - email: result.user.email, -}); -``` - -**Step 5: Commit** - -```bash -git add src/main/ipc/handlers/auth-handlers.ts -git commit -m "feat(auth): trigger session changes on login/logout/restore" -``` - ---- - -## Task 16: Update Handler Registration - -**Files:** - -- Modify: `src/main/bootstrap/ipc-wiring.ts` - -**Step 1: Pass userSessionManager to auth handlers** - -Update the call to `registerAuthHandlers` to include `userSessionManager` in deps. - -**Step 2: Commit** - -```bash -git add src/main/bootstrap/ipc-wiring.ts -git commit -m "feat(ipc): pass userSessionManager to auth handlers" -``` - ---- - -## Task 17: Add Data Migration for Existing Users - -**Files:** - -- Create: `src/main/services/data-management/user-data-migrator.ts` -- Modify: `src/main/services/data-management/index.ts` - -**Step 1: Create migrator** - -```typescript -// src/main/services/data-management/user-data-migrator.ts -/** - * User Data Migrator - * - * Migrates existing global data files to user-scoped directories - * on first login by a user. - */ - -import { existsSync, copyFileSync, mkdirSync, readdirSync, statSync } from 'node:fs'; -import { join } from 'node:path'; - -const USER_SCOPED_FILES = [ - 'notes.json', - 'captures.json', - 'briefings.json', - 'assistant-history.json', - 'assistant-watches.json', - 'alerts.json', - 'ideas.json', - 'milestones.json', - 'changelog.json', -]; - -const USER_SCOPED_DIRS = ['planner', 'fitness']; - -export interface UserDataMigrator { - /** Migrate global data to user directory if user dir is empty. */ - migrateIfNeeded(globalDir: string, userDir: string): void; -} - -export function createUserDataMigrator(): UserDataMigrator { - function copyDirRecursive(src: string, dest: string): void { - if (!existsSync(dest)) { - mkdirSync(dest, { recursive: true }); - } - for (const entry of readdirSync(src)) { - const srcPath = join(src, entry); - const destPath = join(dest, entry); - if (statSync(srcPath).isDirectory()) { - copyDirRecursive(srcPath, destPath); - } else { - copyFileSync(srcPath, destPath); - } - } - } - - return { - migrateIfNeeded(globalDir, userDir) { - // Check if user dir already has data - const hasExistingData = USER_SCOPED_FILES.some((f) => existsSync(join(userDir, f))); - if (hasExistingData) { - return; // Already migrated or has data - } - - // Migrate files - for (const file of USER_SCOPED_FILES) { - const src = join(globalDir, file); - const dest = join(userDir, file); - if (existsSync(src) && !existsSync(dest)) { - copyFileSync(src, dest); - } - } - - // Migrate directories - for (const dir of USER_SCOPED_DIRS) { - const src = join(globalDir, dir); - const dest = join(userDir, dir); - if (existsSync(src) && !existsSync(dest)) { - copyDirRecursive(src, dest); - } - } - }, - }; -} -``` - -**Step 2: Export from barrel** - -**Step 3: Call migrator in service-registry before reinitializing services** - -In the `onSessionChange` callback: - -```typescript -if (session) { - const userDataDir = userDataResolver.getUserDataDir(session.userId); - userDataMigrator.migrateIfNeeded(dataDir, userDataDir); - // ... reinitialize services -} -``` - -**Step 4: Commit** - -```bash -git add src/main/services/data-management/ -git commit -m "feat(storage): add user data migrator for first-login migration" -``` - ---- - -## Task 18: Update Renderer to Handle Session Change Events - -**Files:** - -- Modify: `src/renderer/features/auth/store.ts` - -**Step 1: Clear React Query cache on logout** - -The auth store's `clearAuth` should also signal that all user data queries need to be invalidated. This is already handled in `useLogout` hook which calls `queryClient.clear()`. - -**Step 2: Listen for session change events** - -Add a hook in the auth feature that listens for `event:user.sessionChanged` and invalidates all queries: - -```typescript -// In useAuthEvents.ts or similar -useIpcEvent('event:user.sessionChanged', () => { - queryClient.clear(); -}); -``` - -**Step 3: Commit** - -```bash -git add src/renderer/features/auth/ -git commit -m "feat(auth): clear query cache on session change" -``` - ---- - -## Task 19: Run Verification Suite - -**Step 1: Run lint** - -```bash -npm run lint -``` - -Expected: 0 errors - -**Step 2: Run typecheck** - -```bash -npm run typecheck -``` - -Expected: 0 errors - -**Step 3: Run tests** - -```bash -npm run test -``` - -Expected: All tests pass - -**Step 4: Run build** - -```bash -npm run build -``` - -Expected: Build succeeds - -**Step 5: Manual test** - -1. Start dev server: `npm run dev` -2. Log out (if logged in) -3. Log in with test account -4. Verify data is empty (or migrated) -5. Create a note -6. Log out -7. Log in with different account (if available) or check file system -8. Verify note is not visible -9. Check `userData/users/<userId>/notes.json` exists - -**Step 6: Commit all remaining changes** - -```bash -git add . -git commit -m "feat(storage): complete user-scoped data storage implementation" -``` - ---- - -## Task 20: Update Documentation - -**Files:** - -- Modify: `ai-docs/ARCHITECTURE.md` -- Modify: `ai-docs/DATA-FLOW.md` -- Modify: `ai-docs/FEATURES-INDEX.md` - -**Step 1: Document user-scoped storage in ARCHITECTURE.md** - -Add section explaining the user-scoped storage pattern. - -**Step 2: Update DATA-FLOW.md** - -Add diagram showing data flow on login/logout. - -**Step 3: Update FEATURES-INDEX.md** - -Add new services to the inventory. - -**Step 4: Commit** - -```bash -git add ai-docs/ -git commit -m "docs: document user-scoped data storage architecture" -``` - ---- - -## Summary - -This plan implements user-scoped data storage in 20 tasks: - -1. **Tasks 1-3:** Create infrastructure (resolver, session manager, reinitializable pattern) -2. **Tasks 4-13:** Update 10 services to be reinitializable -3. **Tasks 14-16:** Wire everything together in bootstrap -4. **Task 17:** Add migration for existing data -5. **Task 18:** Update renderer to handle session changes -6. **Task 19:** Verification -7. **Task 20:** Documentation - -**Parallel execution opportunities:** - -- Tasks 4-13 (service updates) can run in parallel - each is independent -- Tasks 1-3 must complete before 4-13 -- Tasks 14-16 must complete after 4-13 -- Task 17-18 can run in parallel after 16 diff --git a/docs/features/visualization/plan.md b/docs/features/visualization/plan.md deleted file mode 100644 index 6f952c12..00000000 --- a/docs/features/visualization/plan.md +++ /dev/null @@ -1,133 +0,0 @@ -# Visual Map — Codebase Structure & Agent Activity Graph - -> Interactive graph visualization of project structure and agent activity using React Flow. - ---- - -## Overview - -The Visual Map feature provides a directed acyclic graph (DAG) visualization that combines two layers: - -1. **Codebase Layer** — Shows project file groups and their import dependencies -2. **Agent Layer** — Shows active/completed agent tasks and their file scope connections - -Users can toggle layers independently, select features to focus on, and click nodes to inspect details. - ---- - -## Route - -- **Path**: `/projects/$projectId/visualization` -- **Nav Label**: "Visual Map" (under Pipeline in sidebar) -- **Breadcrumb**: "Visual Map" - ---- - -## Architecture - -``` -Renderer Main Process -──────── ──────────── -VisualizationPage VisualizationService - └─ ReactFlowProvider ├─ buildCodebaseGraph() - ├─ VisualizationCanvas │ └─ import-parser.ts - │ ├─ useCodebaseGraph() ──→ │ (collectSourceFiles, - │ ├─ useAgentTeams() ──→ │ extractImportSpecifiers, - │ ├─ buildCodebaseRFNodes() │ resolveSpecifier) - │ ├─ buildAgentRFNodes() ├─ buildAgentTeamsData() - │ └─ dagre layout │ └─ reads tracking/ dir - ├─ LayerToggleToolbar └─ buildSessionLog() - └─ NodeDetailPanel └─ reads JSONL logs -``` - ---- - -## IPC Channels - -| Channel | Input | Output | -|---------|-------|--------| -| `visualization.getCodebaseGraph` | `{ projectId }` | `CodebaseGraph` (files, edges, groups) | -| `visualization.getAgentTeams` | `{ projectId }` | `AgentTeamsData` (features, tasks, status) | -| `visualization.getSessionLog` | `{ projectId, feature, agentName, cursor? }` | `SessionLogPage` (lines, cursor) | - ---- - -## Node Types - -| Type | Description | Component | -|------|-------------|-----------| -| `fileGroup` | Directory/module group with file count | `FileGroupNode` | -| `file` | Individual source file (used in detail panel) | `FileNode` | -| `featureGroup` | Agent feature group (branch, status) | `FeatureGroupNode` | -| `agentTask` | Individual agent task with status | `AgentTaskNode` | -| `guardian` | QA/guardian agent task | `GuardianNode` | - -## Edge Types - -| Type | Description | Rendering | -|------|-------------|-----------| -| `smoothstep` | Import dependency between groups | Built-in React Flow edge with `MarkerType.ArrowClosed` | -| `default` (bezier) | Agent task to file group scope link | Built-in React Flow edge with `animated: true` for live agents | - -Edge labels (import weight counts) are toggled via `showEdgeLabels` in the store and rendered using the built-in `label` edge prop. - ---- - -## React Flow Built-in Features Used - -| Feature | Purpose | -|---------|---------| -| `useNodesState` / `useEdgesState` | Interactive drag persistence — node positions survive re-renders | -| `<Controls />` | Built-in zoom in/out, fit view, and lock toggle panel | -| `<NodeToolbar>` | Contextual toolbar on node selection (Details, Expand/Collapse) | -| `<NodeResizer>` | Drag-to-resize parent/group nodes | -| `MarkerType.ArrowClosed` / `MarkerType.Arrow` | Arrow markers on edges for directionality | -| `parentId` + `extent: 'parent'` | Native sub-flow grouping — children constrained inside parents | -| `animated` edge prop | Built-in dash animation for live agent scope edges | - ---- - -## Layout - -- **Algorithm**: Dagre (top-to-bottom or left-to-right directed graph layout) -- **Sub-flow grouping**: Architecture layers (Main, Features, Shared, Renderer, Preload) are parent nodes; file groups are children with `parentId` and `extent: 'parent'` -- **Agent grouping**: Feature group is parent; agent task nodes are children inside it -- **Parent sizing**: Computed from bounding box of children plus padding -- **File counts**: Displayed as badges on group nodes -- **Agent offset**: Agent layer is positioned to the right of codebase layer - ---- - -## State Management - -Zustand store (`useVisualizationStore`) manages: -- `showCodebaseLayer` / `showAgentLayer` — layer visibility toggles -- `selectedFeature` — which feature's agents to display -- `selectedNodeId` / `detailPanelOpen` — detail panel state -- `expandedGroups` — which file groups are expanded -- `layoutDirection` — TB or LR dagre layout -- `searchFilter` — node label filter (dims non-matching nodes) -- `showEdgeLabels` — toggle edge weight labels - ---- - -## Key Files - -| File | Purpose | -|------|---------| -| `components/VisualizationPage.tsx` | Page wrapper with ReactFlowProvider | -| `components/canvas/VisualizationCanvas.tsx` | ReactFlow canvas, graph assembly, layout | -| `components/nodes/` | 5 custom node components | -| `components/edges/` | Legacy custom edge components (unused — built-in types used instead) | -| `components/panels/NodeDetailPanel.tsx` | Slide-in detail panel | -| `components/toolbar/LayerToggleToolbar.tsx` | Layer toggles, feature selector, layout, search, edge labels, refresh | -| `lib/graph-builders.ts` | IPC data → React Flow nodes/edges + dagre layout | -| `api/visualization-api.ts` | TanStack Query hooks for IPC | -| `store.ts` | Zustand UI state | - ---- - -## Dependencies - -- `@xyflow/react` ^12.10.2 — React Flow graph library -- `@dagrejs/dagre` ^1.0.4 — Directed graph layout algorithm diff --git a/docs/features/workspace-ui/plan.md b/docs/features/workspace-ui/plan.md deleted file mode 100644 index 3f394818..00000000 --- a/docs/features/workspace-ui/plan.md +++ /dev/null @@ -1,48 +0,0 @@ -# Workspaces Management UI - -> Tracker Key: `workspace-ui` | Status: **DRAFT** | Priority: **P1** | Created: 2026-02-26 - ---- - -## Problem - -Workspace backend is fully wired (IPC channels `workspace.*`, Hub API proxy, Zustand store, React Query hooks) but has **zero UI components**. Users can't see, create, or manage workspaces. - -## Goals - -- Dedicated workspaces page accessible from sidebar navigation -- List workspaces with name, member count, project count -- Create, edit, rename, delete workspaces -- View workspace members and projects -- Switch active workspace context - -## Approach - -### Route - -- Add `/workspaces` route under personal navigation section -- Add to `shared-nav.ts` personal items - -### Components - -| Component | Purpose | -|-----------|---------| -| `WorkspaceList.tsx` | Card grid or table listing all workspaces | -| `WorkspaceCard.tsx` | Individual workspace card with name, stats, actions | -| `CreateWorkspaceDialog.tsx` | Modal form for creating a new workspace | -| `EditWorkspaceDialog.tsx` | Modal form for editing workspace details | -| `WorkspaceDetail.tsx` | Detail view showing members + projects | - -### Data Flow - -Uses existing hooks in `src/renderer/features/workspaces/`: -- `useWorkspaces()` — list query -- `useCreateWorkspace()` — mutation -- `useUpdateWorkspace()` — mutation -- `useDeleteWorkspace()` — mutation - -All IPC channels already wired in `src/shared/ipc/hub/`. - -## Estimated Effort - -~1 day (backend complete, just UI components needed) diff --git a/docs/peers/phase1-dev-harness.md b/docs/peers/phase1-dev-harness.md new file mode 100644 index 00000000..1a66ed72 --- /dev/null +++ b/docs/peers/phase1-dev-harness.md @@ -0,0 +1,67 @@ +# Phase 1 Dev Harness — Two-Instance Peer Sync + +> **Status (2026-04-24):** This Phase 1 harness is historical. Phase 3a/3b +> made `ADC_PEER_PORT > 0` boot the full TLS + pairing stack via +> `PeersService`. Pairing is now driven from Settings → Peers; the legacy +> `ADC_PEER_REMOTE=ws://…` plain-WS path is no longer wired. Use this doc +> only for understanding the original Phase 1 design. + +Two ADC instances on one machine, each with a hardcoded peer address pointing at the other. Writes to `progress_tasks` on one instance appear on the other within ~1 second over WebSocket. + +The `ADC-Dev` and `ADC-Local` channels have isolated `userData` directories, so they don't collide on a single machine. + +## Terminal 1 — ADC-Dev (peer A) + +```bash +ADC_PEER_PORT=7701 \ +ADC_PEER_REMOTE=ws://127.0.0.1:7702 \ +ADC_PEER_ID_SHORT=aaaaaaaa \ +ADC_PEER_ID_FULL=peer-a \ +npm run dev +``` + +## Terminal 2 — ADC-Local (peer B) + +Build once: + +```bash +npm run build:local +``` + +`npm run build:local` invokes `electron-vite build` with `ADC_CHANNEL=local`, then runs `electron-builder --win --dir` with `productName=ADC-Local`. The unpacked binary lands in `release/win-unpacked/ADC-Local.exe` (electron-builder `output: release` from `package.json`). + +There is no `start:local` / `preview:local` script. Launch the binary directly with env vars: + +```bash +ADC_PEER_PORT=7702 \ +ADC_PEER_REMOTE=ws://127.0.0.1:7701 \ +ADC_PEER_ID_SHORT=bbbbbbbb \ +ADC_PEER_ID_FULL=peer-b \ +./release/win-unpacked/ADC-Local.exe +``` + +(macOS/Linux builds will have a different path — `release/mac/ADC-Local.app/Contents/MacOS/ADC-Local` or `release/linux-unpacked/adc-local`. Phase 1 harness was validated on Windows only.) + +## What to verify + +1. Both windows open. Main-process logs should show `WsTransport listening on 770X` on both sides and a successful outbound connect on both sides. +2. In peer A's UI, create a new progress task with slug `sync-test-1` and title "Created on A". +3. Within ~1 second, peer B's progress view should show `sync-test-1`. (You may need to refresh if the renderer doesn't live-subscribe yet — Phase 1 does not wire renderer events, so expect a stale cache until the next service read.) +4. On peer B, update the task title to "Edited on B". Peer A should update within ~1 second. +5. On peer A, change the status to `executing`. On peer B, edit the title to "Title from B". +6. Both peers should converge to `status=executing` AND the latest title. Column-level LWW means neither edit clobbers the other. +7. On peer A, archive the task. On peer B, confirm the archive applied. +8. On peer A, delete the task. On peer B, confirm the row disappears. + +## Troubleshooting + +- **"address already in use"** — another instance bound the port. Kill: `taskkill //F //IM electron.exe` (Windows) or `pkill -f electron` (macOS/Linux). Per the project's "always kill before start" convention. +- **Peer does not connect** — verify both env vars are exported in the shell that ran the launch. Env vars set after launch don't take effect. +- **Write on peer A never reaches peer B** — check peer A's main-process logs for `broadcastOp` (if logged at debug level) or any `applyRemoteOp threw` messages. If absent, the service might not be calling `recordLocalWrite`. Grep `progress-service.ts` for `recordLocalWrite` — there should be 4 call sites. +- **Schema mismatch** — Phase 1 does not validate schema hash (Phase 2). Both instances must be on the same `drizzle/` migration set. If you switch branches, re-build and re-launch both. + +## Scope reminders + +- Phase 1 replicates ONLY `progress_tasks` (see `src/shared/replication/sync-tables.ts`). +- No mDNS discovery, no PIN pairing, no TLS. Hardcoded peer addresses only. +- The renderer does not auto-refresh on incoming sync events — Phase 4 work. Triggering a re-fetch (navigating away and back) shows the updated state. diff --git a/docs/plans/2026-04-02-workspace-and-assistant-redesign.md b/docs/plans/2026-04-02-workspace-and-assistant-redesign.md deleted file mode 100644 index ee366aad..00000000 --- a/docs/plans/2026-04-02-workspace-and-assistant-redesign.md +++ /dev/null @@ -1,1939 +0,0 @@ -# Workspace & Assistant Redesign Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the empty-state agent dashboard with always-on Primary Claude + Team Lead sessions per project, strip the assistant widget to a direct Claude CLI subprocess, and remove 40px of redundant chrome from every view. - -**Architecture:** WorkspaceSessionManager (new main-process service) owns session lifecycle via the existing AgentManagerService. Renderer is purely state-based — sessions persist across project tab switches. The ContentHeader bar is deleted and its SidebarTrigger migrates into TopBar. - -**Tech Stack:** Electron 39 + React 19, Zustand 5, TanStack Query, TanStack Router, Zod, `electron-vite` (IPC via typed `ipcInvokeContract` / `ipcEventContract`), existing `AgentManagerService` - ---- - -## File Map - -**New files (workspace domain):** -- `src/shared/ipc/workspace/schemas.ts` — SessionKey, WorkspaceSession Zod schemas -- `src/shared/ipc/workspace/contract.ts` — workspaceInvoke + workspaceEvents -- `src/shared/ipc/workspace/index.ts` — barrel -- `src/main/services/workspace/workspace-session-manager.ts` — session lifecycle service -- `src/main/ipc/handlers/workspace-handlers.ts` — thin IPC handlers -- `src/renderer/features/workspace/store.ts` — view-only Zustand store -- `src/renderer/features/workspace/api/useWorkspace.ts` — React Query + event hooks -- `src/renderer/features/workspace/components/WorkspacePage.tsx` — root page component -- `src/renderer/features/workspace/components/PrimarySessionPanel.tsx` — left 55% panel -- `src/renderer/features/workspace/components/TeamLeadPanel.tsx` — single TL card -- `src/renderer/features/workspace/components/TeamLeadPanelList.tsx` — right column -- `src/renderer/features/workspace/index.ts` — public barrel - -**Modified files:** -- `src/shared/ipc/index.ts` — add workspace barrel import + spread -- `src/main/bootstrap/service-registry.ts` — register WorkspaceSessionManager + handlers -- `src/renderer/app/layouts/LayoutWrapper.tsx` — remove ContentHeader -- `src/renderer/app/layouts/TopBar.tsx` — add SidebarTrigger leftmost slot -- `src/renderer/app/routes/project.routes.ts` — swap AgentDashboardPage → WorkspacePage -- `src/renderer/app/layouts/Sidebar.tsx` — "Agents" → "Workspace" label -- `src/renderer/app/layouts/sidebar-layouts/shared-nav.ts` — same label update -- `src/main/services/assistant/assistant-service.ts` — strip to direct Claude CLI -- `src/renderer/features/assistant/components/WidgetPanel.tsx` — remove quick actions -- `src/shared/ipc/assistant/schemas.ts` — remove IntentTypeSchema, AssistantActionSchema -- `src/shared/ipc/assistant/contract.ts` — simplify sendCommand input/output -- `src/shared/ipc/index.ts` — remove intent/action schema re-exports - -**Deleted directories:** -- `src/main/services/assistant/intent-classifier/` — entire directory -- `src/main/services/assistant/executors/` — entire directory - ---- - -## Task 1: Workspace IPC Domain — Schemas + Contract + Barrel - -**Files:** -- Create: `src/shared/ipc/workspace/schemas.ts` -- Create: `src/shared/ipc/workspace/contract.ts` -- Create: `src/shared/ipc/workspace/index.ts` -- Modify: `src/shared/ipc/index.ts` - -- [ ] **Step 1: Create `src/shared/ipc/workspace/schemas.ts`** - -```typescript -/** - * Workspace IPC Schemas - * - * Zod schemas for always-on Primary Claude + Team Lead sessions per project. - */ - -import { z } from 'zod'; - -export const SessionTypeSchema = z.enum(['primary', 'team-lead']); - -export const SessionKeySchema = z.object({ - projectId: z.string(), - type: SessionTypeSchema, - /** 0 = always-on Team Lead, 1+ = user-spawned */ - index: z.number().int().nonnegative(), -}); - -export const WorkspaceSessionStatusSchema = z.enum([ - 'starting', - 'live', - 'crashed', - 'restarting', -]); - -export const WorkspaceSessionSchema = z.object({ - key: SessionKeySchema, - agentSessionId: z.string(), - projectPath: z.string(), - model: z.string(), - status: WorkspaceSessionStatusSchema, - startedAt: z.number(), - crashCount: z.number().int().nonnegative(), -}); - -export type SessionKey = z.infer<typeof SessionKeySchema>; -export type WorkspaceSession = z.infer<typeof WorkspaceSessionSchema>; -export type WorkspaceSessionStatus = z.infer<typeof WorkspaceSessionStatusSchema>; -``` - -- [ ] **Step 2: Create `src/shared/ipc/workspace/contract.ts`** - -```typescript -/** - * Workspace IPC Contract - * - * Invoke and event channel definitions for always-on project workspace sessions. - */ - -import { z } from 'zod'; - -import { SuccessResponseSchema } from '../common/schemas'; -import { SessionKeySchema, WorkspaceSessionSchema } from './schemas'; - -export const workspaceInvoke = { - /** Auto-spawn Primary + Team Lead sessions when a project tab opens. Idempotent. */ - 'workspace.initProject': { - input: z.object({ - projectId: z.string(), - projectPath: z.string(), - }), - output: z.object({ - primarySessionId: z.string(), - teamLeadSessionId: z.string(), - }), - }, - - /** Get all active workspace sessions for a project. */ - 'workspace.getSessions': { - input: z.object({ projectId: z.string() }), - output: z.array(WorkspaceSessionSchema), - }, - - /** Spawn an additional (mortal) Team Lead. Optionally pass a plan file path. */ - 'workspace.spawnTeamLead': { - input: z.object({ - projectId: z.string(), - planPath: z.string().optional(), - }), - output: WorkspaceSessionSchema, - }, - - /** Stop a mortal Team Lead (index ≥ 1). Immortal sessions cannot be stopped this way. */ - 'workspace.stopTeamLead': { - input: z.object({ - projectId: z.string(), - index: z.number().int().min(1), - }), - output: SuccessResponseSchema, - }, - - /** Send a message to a workspace session (proxies to agent-dashboard.sendMessage). */ - 'workspace.sendMessage': { - input: z.object({ - sessionId: z.string(), - message: z.string(), - }), - output: SuccessResponseSchema, - }, -} as const; - -export const workspaceEvents = { - /** A workspace session is live and ready to receive messages. */ - 'event:workspace.sessionReady': { - payload: z.object({ - projectId: z.string(), - sessionKey: SessionKeySchema, - sessionId: z.string(), - }), - }, - - /** A workspace session crashed. */ - 'event:workspace.sessionCrashed': { - payload: z.object({ - projectId: z.string(), - sessionKey: SessionKeySchema, - crashCount: z.number(), - }), - }, - - /** An immortal workspace session restarted after a crash. */ - 'event:workspace.sessionRestarted': { - payload: z.object({ - projectId: z.string(), - sessionKey: SessionKeySchema, - sessionId: z.string(), - }), - }, -} as const; -``` - -- [ ] **Step 3: Create `src/shared/ipc/workspace/index.ts`** - -```typescript -export { workspaceInvoke, workspaceEvents } from './contract'; -export { - SessionKeySchema, - SessionTypeSchema, - WorkspaceSessionSchema, - WorkspaceSessionStatusSchema, -} from './schemas'; -export type { SessionKey, WorkspaceSession, WorkspaceSessionStatus } from './schemas'; -``` - -- [ ] **Step 4: Wire workspace into root barrel `src/shared/ipc/index.ts`** - -Add import at top of file (after the existing agent-dashboard import): - -```typescript -import { workspaceEvents, workspaceInvoke } from './workspace'; -``` - -In `ipcInvokeContract` spread (after `...agentDashboardInvoke`): - -```typescript - ...workspaceInvoke, -``` - -In `ipcEventContract` spread (after `...agentDashboardEvents`): - -```typescript - ...workspaceEvents, -``` - -Add schema re-exports at the bottom of the file: - -```typescript -export { - SessionKeySchema, - SessionTypeSchema, - WorkspaceSessionSchema, - WorkspaceSessionStatusSchema, -} from './workspace'; -``` - -- [ ] **Step 5: Typecheck** - -```bash -npm run typecheck -``` - -Expected: no errors in `src/shared/ipc/` - -- [ ] **Step 6: Commit** - -```bash -git add src/shared/ipc/workspace/ src/shared/ipc/index.ts -git commit -m "feat(workspace): add workspace IPC domain — schemas, contract, barrel" -``` - ---- - -## Task 2: WorkspaceSessionManager Service - -**Files:** -- Create: `src/main/services/workspace/workspace-session-manager.ts` - -- [ ] **Step 1: Create the service file** - -```typescript -/** - * WorkspaceSessionManager — always-on session lifecycle per project. - * - * Rules: - * - primary + team-lead[0] are IMMORTAL: auto-restart on crash, never user-terminated. - * - team-lead[1..N] are MORTAL: user can stop them, no auto-restart. - * - initProject() is idempotent — safe to call every time a project tab opens. - * - Sessions are keyed by projectId — switching tabs never touches sessions. - */ - -import type { BrowserWindow } from 'electron'; - -import type { AgentManagerService } from '../agent-manager'; -import type { SessionKey, WorkspaceSession, WorkspaceSessionStatus } from '@shared/ipc/workspace'; - -const PRIMARY_MODEL = 'claude-sonnet-4-6'; -const TEAM_LEAD_MODEL = 'claude-sonnet-4-6'; -const RESTART_DELAY_MS = 2000; - -type SessionKeyString = string; - -function keyToString(key: SessionKey): SessionKeyString { - return `${key.projectId}:${key.type}:${key.index}`; -} - -function isImmortal(key: SessionKey): boolean { - return key.type === 'primary' || (key.type === 'team-lead' && key.index === 0); -} - -export interface WorkspaceSessionManager { - initProject(projectId: string, projectPath: string): Promise<{ primarySessionId: string; teamLeadSessionId: string }>; - getSessions(projectId: string): WorkspaceSession[]; - spawnTeamLead(projectId: string, planPath?: string): Promise<WorkspaceSession>; - stopTeamLead(projectId: string, index: number): Promise<{ success: boolean }>; - sendMessage(sessionId: string, message: string): Promise<{ success: boolean }>; - dispose(): void; -} - -export function createWorkspaceSessionManager( - agentManager: AgentManagerService, - getWindow: () => BrowserWindow | null, -): WorkspaceSessionManager { - const sessions = new Map<SessionKeyString, WorkspaceSession>(); - - function sendEvent<T>(channel: string, payload: T): void { - getWindow()?.webContents.send(channel, payload); - } - - function updateSession(key: SessionKey, patch: Partial<WorkspaceSession>): void { - const keyStr = keyToString(key); - const existing = sessions.get(keyStr); - if (existing) sessions.set(keyStr, { ...existing, ...patch }); - } - - async function spawnPrimary(projectId: string, projectPath: string): Promise<string> { - const key: SessionKey = { projectId, type: 'primary', index: 0 }; - const keyStr = keyToString(key); - - const existing = sessions.get(keyStr); - if (existing && (existing.status === 'live' || existing.status === 'starting')) { - return existing.agentSessionId; - } - - const session: WorkspaceSession = { - key, - agentSessionId: '', - projectPath, - model: PRIMARY_MODEL, - status: 'starting', - startedAt: Date.now(), - crashCount: existing?.crashCount ?? 0, - }; - sessions.set(keyStr, session); - - const result = await agentManager.spawnProjectOwner({ - projectPath, - prompt: 'You are the primary Claude session for this project. Await instructions.', - model: PRIMARY_MODEL, - name: `workspace-primary-${projectId}`, - }); - - updateSession(key, { agentSessionId: result.sessionId, status: 'live' }); - sendEvent('event:workspace.sessionReady', { projectId, sessionKey: key, sessionId: result.sessionId }); - return result.sessionId; - } - - async function spawnImmortalTeamLead(projectId: string, projectPath: string): Promise<string> { - const key: SessionKey = { projectId, type: 'team-lead', index: 0 }; - const keyStr = keyToString(key); - - const existing = sessions.get(keyStr); - if (existing && (existing.status === 'live' || existing.status === 'starting')) { - return existing.agentSessionId; - } - - const session: WorkspaceSession = { - key, - agentSessionId: '', - projectPath, - model: TEAM_LEAD_MODEL, - status: 'starting', - startedAt: Date.now(), - crashCount: existing?.crashCount ?? 0, - }; - sessions.set(keyStr, session); - - const result = await agentManager.spawnTeamLead({ - projectPath, - teamName: `workspace-tl-${projectId}`, - prompt: 'You are Team Lead 1 for this project. Await a plan file or instructions.', - model: TEAM_LEAD_MODEL, - name: `workspace-tl-${projectId}`, - }); - - updateSession(key, { agentSessionId: result.sessionId, status: 'live' }); - sendEvent('event:workspace.sessionReady', { projectId, sessionKey: key, sessionId: result.sessionId }); - return result.sessionId; - } - - // Listen for agent session end events to handle immortal restart - agentManager.onEvent((event) => { - if (event.type !== 'session-ended') return; - - for (const [keyStr, session] of sessions.entries()) { - if (session.agentSessionId !== event.sessionId) continue; - - const { key } = session; - if (!isImmortal(key)) { - // Mortal: just mark as crashed - sessions.set(keyStr, { ...session, status: 'crashed' }); - return; - } - - // Immortal: increment crash count, emit event, restart after delay - const crashCount = session.crashCount + 1; - sessions.set(keyStr, { ...session, status: 'restarting', crashCount }); - sendEvent('event:workspace.sessionCrashed', { projectId: key.projectId, sessionKey: key, crashCount }); - - setTimeout(() => { - const current = sessions.get(keyStr); - if (!current) return; - - // Clear agentSessionId so spawnPrimary/spawnImmortalTeamLead will re-spawn - sessions.set(keyStr, { ...current, agentSessionId: '', status: 'starting' }); - - void (key.type === 'primary' - ? spawnPrimary(key.projectId, session.projectPath) - : spawnImmortalTeamLead(key.projectId, session.projectPath) - ).then((newSessionId) => { - sendEvent('event:workspace.sessionRestarted', { - projectId: key.projectId, - sessionKey: key, - sessionId: newSessionId, - }); - }); - }, RESTART_DELAY_MS); - - return; - } - }); - - return { - async initProject(projectId, projectPath) { - const [primarySessionId, teamLeadSessionId] = await Promise.all([ - spawnPrimary(projectId, projectPath), - spawnImmortalTeamLead(projectId, projectPath), - ]); - return { primarySessionId, teamLeadSessionId }; - }, - - getSessions(projectId) { - return [...sessions.values()].filter((s) => s.key.projectId === projectId); - }, - - async spawnTeamLead(projectId, planPath) { - // Find next available mortal index - const existing = [...sessions.values()].filter( - (s) => s.key.projectId === projectId && s.key.type === 'team-lead', - ); - const nextIndex = existing.length; // immortal is index 0, mortals start at 1 - - const projectPath = existing[0]?.projectPath ?? ''; - const key: SessionKey = { projectId, type: 'team-lead', index: nextIndex }; - const keyStr = keyToString(key); - - const prompt = planPath - ? `You are Team Lead ${nextIndex + 1}. Your plan file is at: ${planPath}. Read it and begin.` - : `You are Team Lead ${nextIndex + 1} for this project. Await a plan file or instructions.`; - - const session: WorkspaceSession = { - key, - agentSessionId: '', - projectPath, - model: TEAM_LEAD_MODEL, - status: 'starting', - startedAt: Date.now(), - crashCount: 0, - }; - sessions.set(keyStr, session); - - const result = await agentManager.spawnTeamLead({ - projectPath, - teamName: `workspace-tl-${projectId}-${nextIndex}`, - prompt, - model: TEAM_LEAD_MODEL, - name: `workspace-tl-${projectId}-${nextIndex}`, - }); - - const ready: WorkspaceSession = { ...session, agentSessionId: result.sessionId, status: 'live' }; - sessions.set(keyStr, ready); - sendEvent('event:workspace.sessionReady', { projectId, sessionKey: key, sessionId: result.sessionId }); - return ready; - }, - - async stopTeamLead(projectId, index) { - if (index < 1) return { success: false }; // Cannot stop immortal - const key: SessionKey = { projectId, type: 'team-lead', index }; - const keyStr = keyToString(key); - const session = sessions.get(keyStr); - if (!session) return { success: false }; - - await agentManager.stopSession(session.agentSessionId); - sessions.delete(keyStr); - return { success: true }; - }, - - async sendMessage(sessionId, message) { - return agentManager.sendMessage(sessionId, message); - }, - - dispose() { - sessions.clear(); - }, - }; -} -``` - -- [ ] **Step 2: Typecheck** - -```bash -npm run typecheck -``` - -Expected: no errors. If `agentManager.onEvent` callback shape mismatches, inspect `src/main/services/agent-manager/agent-manager-service.ts` and adjust the event guard (`event.type !== 'session-ended'` and `event.sessionId`) to match the actual event shape. - -- [ ] **Step 3: Commit** - -```bash -git add src/main/services/workspace/ -git commit -m "feat(workspace): add WorkspaceSessionManager service with immortal session lifecycle" -``` - ---- - -## Task 3: Workspace IPC Handlers + Bootstrap Wiring - -**Files:** -- Create: `src/main/ipc/handlers/workspace-handlers.ts` -- Modify: `src/main/bootstrap/service-registry.ts` - -- [ ] **Step 1: Create `src/main/ipc/handlers/workspace-handlers.ts`** - -```typescript -/** - * Workspace IPC Handlers - * - * Thin handlers — one service call per handler, no business logic. - */ - -import type { IpcRouter } from '../../ipc/router'; -import type { WorkspaceSessionManager } from '../../services/workspace/workspace-session-manager'; - -export function registerWorkspaceHandlers( - router: IpcRouter, - workspace: WorkspaceSessionManager, -): void { - router.handle('workspace.initProject', async ({ projectId, projectPath }) => { - return workspace.initProject(projectId, projectPath); - }); - - router.handle('workspace.getSessions', ({ projectId }) => { - return Promise.resolve(workspace.getSessions(projectId)); - }); - - router.handle('workspace.spawnTeamLead', async ({ projectId, planPath }) => { - return workspace.spawnTeamLead(projectId, planPath); - }); - - router.handle('workspace.stopTeamLead', async ({ projectId, index }) => { - return workspace.stopTeamLead(projectId, index); - }); - - router.handle('workspace.sendMessage', async ({ sessionId, message }) => { - return workspace.sendMessage(sessionId, message); - }); -} -``` - -- [ ] **Step 2: Add to `src/main/bootstrap/service-registry.ts`** - -Find the import block and add after the agent-manager import: - -```typescript -import { createWorkspaceSessionManager } from '../services/workspace/workspace-session-manager'; -import type { WorkspaceSessionManager } from '../services/workspace/workspace-session-manager'; -``` - -Find the `ServiceRegistryResult` interface and add after `agentManagerService`: - -```typescript - workspaceSessionManager: WorkspaceSessionManager; -``` - -Find where `agentManagerService` is instantiated in the function body and add immediately after it: - -```typescript - const workspaceSessionManager = createWorkspaceSessionManager(agentManagerService, getMainWindow); -``` - -Find where handlers are registered (look for `registerAgentDashboardHandlers`) and add: - -```typescript - registerWorkspaceHandlers(router, workspaceSessionManager); -``` - -Add the import for `registerWorkspaceHandlers` near the other handler imports: - -```typescript -import { registerWorkspaceHandlers } from '../ipc/handlers/workspace-handlers'; -``` - -Include `workspaceSessionManager` in the returned object at the end of the function. - -- [ ] **Step 3: Typecheck + build** - -```bash -npm run typecheck && npm run build -``` - -Expected: clean. If the `IpcRouter.handle` signature differs from what's shown (e.g., requires schema passing), inspect an existing handler file and match the exact call pattern. - -- [ ] **Step 4: Commit** - -```bash -git add src/main/ipc/handlers/workspace-handlers.ts src/main/bootstrap/service-registry.ts -git commit -m "feat(workspace): register workspace IPC handlers and wire service in bootstrap" -``` - ---- - -## Task 4: Chrome Cleanup — Remove ContentHeader, Add Sidebar Toggle to TopBar - -**Files:** -- Modify: `src/renderer/app/layouts/LayoutWrapper.tsx` -- Modify: `src/renderer/app/layouts/TopBar.tsx` - -- [ ] **Step 1: Remove ContentHeader from `src/renderer/app/layouts/LayoutWrapper.tsx`** - -Current file (lines 17–97). Remove the `ContentHeader` import and usage: - -```typescript -// BEFORE LayoutWrapper.tsx -import { ContentHeader } from './ContentHeader'; -// ... -export function LayoutWrapper({ children, layoutId }: LayoutWrapperProps) { - const Layout = LAYOUT_MAP[layoutId]; - return ( - <SidebarProvider> - <Suspense fallback={<SidebarSkeleton />}> - <Layout /> - </Suspense> - <SidebarInset> - <ContentHeader /> {/* ← REMOVE this line */} - {children} - </SidebarInset> - </SidebarProvider> - ); -} -``` - -Delete line `import { ContentHeader } from './ContentHeader';` and the `<ContentHeader />` JSX node. File becomes: - -```typescript -/** - * LayoutWrapper — Switches between 16 sidebar layout variants - * - * Reads the selected sidebarLayout from the layout store and - * lazy-loads the corresponding SidebarLayoutXX component. - * Wraps children in SidebarProvider + SidebarInset. - */ - -import { Suspense, lazy } from 'react'; - -import { Loader2 } from 'lucide-react'; - -import type { SidebarLayoutId } from '@shared/types/layout'; - -import { SidebarInset, SidebarProvider } from '@ui/sidebar'; - -const LAYOUT_MAP: Record<SidebarLayoutId, React.LazyExoticComponent<React.ComponentType>> = { - 'sidebar-01': lazy(() => - import('./sidebar-layouts/SidebarLayout01').then((m) => ({ default: m.SidebarLayout01 })), - ), - 'sidebar-02': lazy(() => - import('./sidebar-layouts/SidebarLayout02').then((m) => ({ default: m.SidebarLayout02 })), - ), - 'sidebar-03': lazy(() => - import('./sidebar-layouts/SidebarLayout03').then((m) => ({ default: m.SidebarLayout03 })), - ), - 'sidebar-04': lazy(() => - import('./sidebar-layouts/SidebarLayout04').then((m) => ({ default: m.SidebarLayout04 })), - ), - 'sidebar-05': lazy(() => - import('./sidebar-layouts/SidebarLayout05').then((m) => ({ default: m.SidebarLayout05 })), - ), - 'sidebar-06': lazy(() => - import('./sidebar-layouts/SidebarLayout06').then((m) => ({ default: m.SidebarLayout06 })), - ), - 'sidebar-07': lazy(() => - import('./sidebar-layouts/SidebarLayout07').then((m) => ({ default: m.SidebarLayout07 })), - ), - 'sidebar-08': lazy(() => - import('./sidebar-layouts/SidebarLayout08').then((m) => ({ default: m.SidebarLayout08 })), - ), - 'sidebar-09': lazy(() => - import('./sidebar-layouts/SidebarLayout09').then((m) => ({ default: m.SidebarLayout09 })), - ), - 'sidebar-10': lazy(() => - import('./sidebar-layouts/SidebarLayout10').then((m) => ({ default: m.SidebarLayout10 })), - ), - 'sidebar-11': lazy(() => - import('./sidebar-layouts/SidebarLayout11').then((m) => ({ default: m.SidebarLayout11 })), - ), - 'sidebar-12': lazy(() => - import('./sidebar-layouts/SidebarLayout12').then((m) => ({ default: m.SidebarLayout12 })), - ), - 'sidebar-13': lazy(() => - import('./sidebar-layouts/SidebarLayout13').then((m) => ({ default: m.SidebarLayout13 })), - ), - 'sidebar-14': lazy(() => - import('./sidebar-layouts/SidebarLayout14').then((m) => ({ default: m.SidebarLayout14 })), - ), - 'sidebar-15': lazy(() => - import('./sidebar-layouts/SidebarLayout15').then((m) => ({ default: m.SidebarLayout15 })), - ), - 'sidebar-16': lazy(() => - import('./sidebar-layouts/SidebarLayout16').then((m) => ({ default: m.SidebarLayout16 })), - ), -}; - -function SidebarSkeleton() { - return ( - <div className="bg-sidebar flex h-full w-[16rem] items-center justify-center"> - <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" /> - </div> - ); -} - -interface LayoutWrapperProps { - children: React.ReactNode; - layoutId: SidebarLayoutId; -} - -export function LayoutWrapper({ children, layoutId }: LayoutWrapperProps) { - const Layout = LAYOUT_MAP[layoutId]; - - return ( - <SidebarProvider> - <Suspense fallback={<SidebarSkeleton />}> - <Layout /> - </Suspense> - <SidebarInset> - {children} - </SidebarInset> - </SidebarProvider> - ); -} -``` - -- [ ] **Step 2: Add SidebarTrigger to `src/renderer/app/layouts/TopBar.tsx`** - -Current TopBar renders project tabs inside a flex container. Add the `SidebarTrigger` as the leftmost slot: - -```typescript -/** - * TopBar — Project tabs bar with sidebar toggle - * - * Leftmost slot: SidebarTrigger (collapsed sidebar toggle). - * Remaining: project tabs + open project button. - * Consolidates two chrome bars into one 40px strip. - */ - -import { useNavigate } from '@tanstack/react-router'; -import { FolderOpen, Plus, X } from 'lucide-react'; - -import { ROUTES, PROJECT_VIEWS, projectViewPath } from '@shared/constants'; - -import { cn } from '@renderer/shared/lib/utils'; -import { useLayoutStore } from '@renderer/shared/stores'; -import { SidebarTrigger } from '@ui/sidebar'; - -import { useProjects } from '@features/projects'; - -export function TopBar() { - // 1. Hooks - const navigate = useNavigate(); - const { activeProjectId, projectTabOrder, setActiveProject, removeProjectTab } = useLayoutStore(); - const { data: projects } = useProjects(); - - // 2. Derived state - const openProjects = projectTabOrder - .map((id) => projects?.find((p) => p.id === id)) - .filter(Boolean); - - // 3. Handlers - function handleSelectProject(projectId: string) { - setActiveProject(projectId); - void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); - } - - function handleCloseTab(e: React.MouseEvent | React.KeyboardEvent, projectId: string) { - e.stopPropagation(); - removeProjectTab(projectId); - } - - function handleAddProject() { - void navigate({ to: ROUTES.PROJECTS }); - } - - // 4. Render - return ( - <div className="border-border bg-card flex h-10 items-center gap-px border-b px-1"> - {/* Sidebar toggle — replaces ContentHeader bar */} - <SidebarTrigger className="-ml-1 mr-1 shrink-0" /> - <div className="bg-border mr-1 h-4 w-px shrink-0" /> - - {/* Project tabs */} - <div className="flex min-w-0 flex-1 items-center gap-px overflow-hidden"> - {openProjects.map((project) => { - if (!project) return null; - const isActive = project.id === activeProjectId; - return ( - <button - key={project.id} - className={cn( - 'group flex items-center gap-2 rounded-t-md px-3 py-1.5 text-sm transition-colors', - isActive - ? 'bg-background text-foreground border-primary border-b-2' - : 'text-muted-foreground hover:bg-accent hover:text-foreground', - )} - onClick={() => handleSelectProject(project.id)} - > - <FolderOpen className="h-3.5 w-3.5 shrink-0" /> - <span className="max-w-32 truncate">{project.name}</span> - <span - aria-label={`Close ${project.name} tab`} - className="hover:bg-muted ml-1 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100" - role="button" - tabIndex={0} - onClick={(e) => handleCloseTab(e, project.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') handleCloseTab(e, project.id); - }} - > - <X className="h-3 w-3" /> - </span> - </button> - ); - })} - - <button - className="text-muted-foreground hover:bg-accent hover:text-foreground ml-1 rounded-md p-1.5" - title="Open project" - onClick={handleAddProject} - > - <Plus className="h-4 w-4" /> - </button> - </div> - </div> - ); -} -``` - -- [ ] **Step 3: Lint + typecheck** - -```bash -npm run lint && npm run typecheck -``` - -Expected: clean. If `SidebarTrigger` import path differs, check existing `ContentHeader.tsx` for the correct import. - -- [ ] **Step 4: Commit** - -```bash -git add src/renderer/app/layouts/LayoutWrapper.tsx src/renderer/app/layouts/TopBar.tsx -git commit -m "feat(chrome): remove ContentHeader bar, move SidebarTrigger into TopBar (save 40px)" -``` - ---- - -## Task 5: Workspace Zustand Store - -**Files:** -- Create: `src/renderer/features/workspace/store.ts` - -- [ ] **Step 1: Create `src/renderer/features/workspace/store.ts`** - -```typescript -/** - * Workspace View Store - * - * View-only state — no session lifecycle. Session data lives in React Query. - * Persists panel collapse state + input drafts per session. - */ - -import { create } from 'zustand'; - -interface WorkspaceStore { - /** Which project's sessions are currently displayed */ - viewingProjectId: string | null; - /** Collapse state per team lead (keyed by sessionId) */ - teamLeadCollapsed: Record<string, boolean>; - /** Input draft text per session (keyed by sessionId) */ - inputDrafts: Record<string, string>; - - setViewingProject(projectId: string | null): void; - toggleTeamLeadCollapsed(sessionId: string): void; - setInputDraft(sessionId: string, draft: string): void; - clearInputDraft(sessionId: string): void; -} - -export const useWorkspaceStore = create<WorkspaceStore>((set) => ({ - viewingProjectId: null, - teamLeadCollapsed: {}, - inputDrafts: {}, - - setViewingProject(projectId) { - set({ viewingProjectId: projectId }); - }, - - toggleTeamLeadCollapsed(sessionId) { - set((state) => ({ - teamLeadCollapsed: { - ...state.teamLeadCollapsed, - [sessionId]: !state.teamLeadCollapsed[sessionId], - }, - })); - }, - - setInputDraft(sessionId, draft) { - set((state) => ({ - inputDrafts: { ...state.inputDrafts, [sessionId]: draft }, - })); - }, - - clearInputDraft(sessionId) { - set((state) => { - const { [sessionId]: _, ...rest } = state.inputDrafts; - return { inputDrafts: rest }; - }); - }, -})); -``` - -- [ ] **Step 2: Typecheck** - -```bash -npm run typecheck -``` - -Expected: clean. - -- [ ] **Step 3: Commit** - -```bash -git add src/renderer/features/workspace/store.ts -git commit -m "feat(workspace): add workspace Zustand view store" -``` - ---- - -## Task 6: React Query Hooks - -**Files:** -- Create: `src/renderer/features/workspace/api/useWorkspace.ts` - -- [ ] **Step 1: Create `src/renderer/features/workspace/api/useWorkspace.ts`** - -```typescript -/** - * Workspace React Query Hooks - * - * useWorkspaceSessions — polls workspace.getSessions, invalidates on IPC events. - * useWorkspaceInit — calls workspace.initProject when a project tab opens. - * useWorkspaceSend — mutation to send a message to a session. - * useWorkspaceSpawn — mutation to spawn an additional Team Lead. - * useWorkspaceStop — mutation to stop a mortal Team Lead. - * - * Message streaming for each session reuses useAgentStream from agent-dashboard. - */ - -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect } from 'react'; - -import { window as ipcWindow } from '@renderer/shared/lib/ipc'; -import { useIpcEvent } from '@renderer/shared/hooks'; - -export const workspaceKeys = { - all: ['workspace'] as const, - sessions: (projectId: string) => ['workspace', 'sessions', projectId] as const, -}; - -/** Poll active sessions for a project. Invalidates on session lifecycle events. */ -export function useWorkspaceSessions(projectId: string | null) { - const queryClient = useQueryClient(); - - // Invalidate on session lifecycle events - useIpcEvent('event:workspace.sessionReady', () => { - if (!projectId) return; - void queryClient.invalidateQueries({ queryKey: workspaceKeys.sessions(projectId) }); - }); - useIpcEvent('event:workspace.sessionCrashed', () => { - if (!projectId) return; - void queryClient.invalidateQueries({ queryKey: workspaceKeys.sessions(projectId) }); - }); - useIpcEvent('event:workspace.sessionRestarted', () => { - if (!projectId) return; - void queryClient.invalidateQueries({ queryKey: workspaceKeys.sessions(projectId) }); - }); - - return useQuery({ - queryKey: workspaceKeys.sessions(projectId ?? ''), - queryFn: () => ipcWindow.invoke('workspace.getSessions', { projectId: projectId ?? '' }), - enabled: projectId !== null, - refetchInterval: 5000, - }); -} - -/** Call initProject when a project tab first opens. Idempotent on the backend. */ -export function useWorkspaceInit(projectId: string | null, projectPath: string | null) { - const queryClient = useQueryClient(); - - useEffect(() => { - if (!projectId || !projectPath) return; - - void ipcWindow - .invoke('workspace.initProject', { projectId, projectPath }) - .then(() => { - void queryClient.invalidateQueries({ queryKey: workspaceKeys.sessions(projectId) }); - }); - }, [projectId, projectPath, queryClient]); -} - -/** Send a message to a workspace session. */ -export function useWorkspaceSend() { - return useMutation({ - mutationFn: ({ sessionId, message }: { sessionId: string; message: string }) => - ipcWindow.invoke('workspace.sendMessage', { sessionId, message }), - }); -} - -/** Spawn an additional mortal Team Lead. */ -export function useSpawnTeamLead(projectId: string) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ planPath }: { planPath?: string }) => - ipcWindow.invoke('workspace.spawnTeamLead', { projectId, planPath }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: workspaceKeys.sessions(projectId) }); - }, - }); -} - -/** Stop a mortal Team Lead by index. */ -export function useStopTeamLead(projectId: string) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ index }: { index: number }) => - ipcWindow.invoke('workspace.stopTeamLead', { projectId, index }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: workspaceKeys.sessions(projectId) }); - }, - }); -} -``` - -**Note:** Check the actual IPC invoke pattern used in this codebase. If the import is `import { ipc } from '@renderer/shared/lib/ipc'` and calls are `ipc.invoke(...)`, adjust accordingly. Look at any existing `api/use*.ts` file (e.g., `src/renderer/features/agent-dashboard/api/`) to find the exact import and call pattern. - -- [ ] **Step 2: Typecheck** - -```bash -npm run typecheck -``` - -Expected: clean. Fix any import path issues by checking an existing feature's `api/` folder. - -- [ ] **Step 3: Commit** - -```bash -git add src/renderer/features/workspace/api/ -git commit -m "feat(workspace): add React Query hooks for workspace sessions" -``` - ---- - -## Task 7: Workspace UI Components - -**Files:** -- Create: `src/renderer/features/workspace/components/WorkspacePage.tsx` -- Create: `src/renderer/features/workspace/components/PrimarySessionPanel.tsx` -- Create: `src/renderer/features/workspace/components/TeamLeadPanel.tsx` -- Create: `src/renderer/features/workspace/components/TeamLeadPanelList.tsx` -- Create: `src/renderer/features/workspace/index.ts` - -**Important:** Before writing these, look at `src/renderer/features/agent-dashboard/hooks/useAgentStream.ts` (or equivalent) to confirm the exact hook name and import path for message streaming. The plan uses `useAgentStream` from `@features/agent-dashboard` — verify this is the correct export name. - -- [ ] **Step 1: Create `PrimarySessionPanel.tsx`** - -```typescript -/** - * PrimarySessionPanel — Left panel showing the always-on Primary Claude session. - * - * Takes 55-60% of the workspace width. - * Renders streamed messages and a text input to send commands. - */ - -import { useRef, useState } from 'react'; - -import { Send } from 'lucide-react'; - -import { Button } from '@ui/button'; -import { Input } from '@ui/input'; - -import { useAgentStream } from '@features/agent-dashboard'; - -import { useWorkspaceSend } from '../api/useWorkspace'; -import { useWorkspaceStore } from '../store'; - -interface PrimarySessionPanelProps { - sessionId: string; - projectId: string; - projectName: string; - status: string; -} - -export function PrimarySessionPanel({ - sessionId, - projectId, - projectName, - status, -}: PrimarySessionPanelProps) { - const { events } = useAgentStream(sessionId); - const send = useWorkspaceSend(); - const draft = useWorkspaceStore((s) => s.inputDrafts[sessionId] ?? ''); - const setDraft = useWorkspaceStore((s) => s.setInputDraft); - const clearDraft = useWorkspaceStore((s) => s.clearInputDraft); - const scrollRef = useRef<HTMLDivElement>(null); - - function handleSend() { - const message = draft.trim(); - if (!message || status !== 'live') return; - send.mutate({ sessionId, message }); - clearDraft(sessionId); - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - } - - const statusColor = - status === 'live' - ? 'bg-green-500' - : status === 'restarting' || status === 'starting' - ? 'bg-yellow-500 animate-pulse' - : 'bg-red-500'; - - return ( - <div className="border-border flex h-full flex-col border-r"> - {/* Header */} - <div className="border-border flex items-center gap-2 border-b px-4 py-2"> - <span className={`h-2 w-2 rounded-full ${statusColor}`} /> - <span className="text-muted-foreground text-xs font-medium uppercase tracking-wide"> - Primary · {projectName} - </span> - <span className="text-muted-foreground ml-auto text-xs opacity-60">claude-sonnet-4-6</span> - </div> - - {/* Message stream */} - <div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-3"> - {events.length === 0 && ( - <p className="text-muted-foreground text-sm"> - {status === 'starting' ? 'Starting session…' : 'Session ready. Send a message.'} - </p> - )} - {events.map((event, i) => ( - <div key={i} className="text-sm whitespace-pre-wrap"> - {typeof event.content === 'string' ? event.content : null} - </div> - ))} - </div> - - {/* Input */} - <div className="border-border flex gap-2 border-t p-3"> - <Input - className="flex-1" - placeholder={status === 'live' ? 'Ask Claude or give a command…' : `Session ${status}…`} - value={draft} - disabled={status !== 'live'} - onChange={(e) => setDraft(sessionId, e.target.value)} - onKeyDown={handleKeyDown} - /> - <Button - size="icon" - disabled={!draft.trim() || status !== 'live' || send.isPending} - onClick={handleSend} - > - <Send className="h-4 w-4" /> - </Button> - </div> - </div> - ); -} -``` - -- [ ] **Step 2: Create `TeamLeadPanel.tsx`** - -```typescript -/** - * TeamLeadPanel — A single Team Lead session card in the right column. - * - * Collapsible. Shows session status, message stream, and input. - * Mortal Team Leads (index ≥ 1) show a stop button. - */ - -import { ChevronDown, ChevronUp, Send, X } from 'lucide-react'; - -import { Button } from '@ui/button'; -import { Input } from '@ui/input'; - -import { useAgentStream } from '@features/agent-dashboard'; - -import type { WorkspaceSession } from '@shared/ipc/workspace'; - -import { useWorkspaceSend, useStopTeamLead } from '../api/useWorkspace'; -import { useWorkspaceStore } from '../store'; - -interface TeamLeadPanelProps { - session: WorkspaceSession; -} - -export function TeamLeadPanel({ session }: TeamLeadPanelProps) { - const { key, agentSessionId, status } = session; - const { projectId, index } = key; - const isImmortal = index === 0; - const label = index === 0 ? 'Team Lead 1' : `Team Lead ${index + 1}`; - - const { events } = useAgentStream(agentSessionId); - const send = useWorkspaceSend(); - const stop = useStopTeamLead(projectId); - const isCollapsed = useWorkspaceStore((s) => s.teamLeadCollapsed[agentSessionId] ?? false); - const toggle = useWorkspaceStore((s) => s.toggleTeamLeadCollapsed); - const draft = useWorkspaceStore((s) => s.inputDrafts[agentSessionId] ?? ''); - const setDraft = useWorkspaceStore((s) => s.setInputDraft); - const clearDraft = useWorkspaceStore((s) => s.clearInputDraft); - - function handleSend() { - const message = draft.trim(); - if (!message || status !== 'live') return; - send.mutate({ sessionId: agentSessionId, message }); - clearDraft(agentSessionId); - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - } - - function handleStop() { - stop.mutate({ index }); - } - - const statusColor = - status === 'live' - ? 'bg-green-500' - : status === 'restarting' || status === 'starting' - ? 'bg-yellow-500 animate-pulse' - : 'bg-red-500'; - - return ( - <div className="border-border rounded-lg border"> - {/* Card header */} - <div className="flex items-center gap-2 px-3 py-2"> - <span className={`h-2 w-2 rounded-full ${statusColor}`} /> - <span className="text-xs font-medium">{label}</span> - {status === 'restarting' && ( - <span className="text-muted-foreground text-xs">restarting…</span> - )} - <div className="ml-auto flex items-center gap-1"> - {!isImmortal && ( - <Button - variant="ghost" - size="icon" - className="h-6 w-6" - title="Stop team lead" - onClick={handleStop} - disabled={stop.isPending} - > - <X className="h-3 w-3" /> - </Button> - )} - <Button - variant="ghost" - size="icon" - className="h-6 w-6" - onClick={() => toggle(agentSessionId)} - > - {isCollapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />} - </Button> - </div> - </div> - - {/* Collapsible body */} - {!isCollapsed && ( - <> - <div className="border-border max-h-48 overflow-y-auto border-t p-3 space-y-2"> - {events.length === 0 && ( - <p className="text-muted-foreground text-xs"> - {status === 'starting' ? 'Starting…' : 'Ready for a plan or instructions.'} - </p> - )} - {events.map((event, i) => ( - <p key={i} className="text-xs whitespace-pre-wrap"> - {typeof event.content === 'string' ? event.content : null} - </p> - ))} - </div> - - {/* Input */} - <div className="border-border flex gap-2 border-t p-2"> - <Input - className="h-7 flex-1 text-xs" - placeholder={status === 'live' ? 'Send to team lead…' : `${status}…`} - value={draft} - disabled={status !== 'live'} - onChange={(e) => setDraft(agentSessionId, e.target.value)} - onKeyDown={handleKeyDown} - /> - <Button - size="icon" - className="h-7 w-7" - disabled={!draft.trim() || status !== 'live' || send.isPending} - onClick={handleSend} - > - <Send className="h-3 w-3" /> - </Button> - </div> - </> - )} - </div> - ); -} -``` - -- [ ] **Step 3: Create `TeamLeadPanelList.tsx`** - -```typescript -/** - * TeamLeadPanelList — Right column: list of team lead cards + spawn button. - */ - -import { Plus } from 'lucide-react'; - -import { Button } from '@ui/button'; - -import type { WorkspaceSession } from '@shared/ipc/workspace'; - -import { useSpawnTeamLead } from '../api/useWorkspace'; - -import { TeamLeadPanel } from './TeamLeadPanel'; - -interface TeamLeadPanelListProps { - sessions: WorkspaceSession[]; - projectId: string; -} - -export function TeamLeadPanelList({ sessions, projectId }: TeamLeadPanelListProps) { - const spawn = useSpawnTeamLead(projectId); - - const teamLeadSessions = sessions - .filter((s) => s.key.type === 'team-lead') - .sort((a, b) => a.key.index - b.key.index); - - return ( - <div className="flex h-full flex-col gap-3 overflow-y-auto p-3"> - {teamLeadSessions.map((session) => ( - <TeamLeadPanel key={`${session.key.type}-${session.key.index}`} session={session} /> - ))} - - <Button - variant="outline" - className="mt-auto w-full text-xs" - disabled={spawn.isPending} - onClick={() => spawn.mutate({})} - > - <Plus className="mr-2 h-3 w-3" /> - Spawn Team Lead - </Button> - </div> - ); -} -``` - -- [ ] **Step 4: Create `WorkspacePage.tsx`** - -```typescript -/** - * WorkspacePage — Primary work area for each project. - * - * Left panel (55%): Primary Claude session. - * Right column (45%): Team Lead sessions + spawn button. - * - * Calls initProject on mount. View is purely state-based — sessions - * persist in WorkspaceSessionManager regardless of which project is displayed. - */ - -import { useParams } from '@tanstack/react-router'; - -import { useProjects } from '@features/projects'; - -import { useWorkspaceInit, useWorkspaceSessions } from '../api/useWorkspace'; -import { useWorkspaceStore } from '../store'; - -import { PrimarySessionPanel } from './PrimarySessionPanel'; -import { TeamLeadPanelList } from './TeamLeadPanelList'; - -export function WorkspacePage() { - const { projectId } = useParams({ strict: false }); - const { data: projects } = useProjects(); - const setViewing = useWorkspaceStore((s) => s.setViewingProject); - - const project = projects?.find((p) => p.id === projectId); - const projectPath = project?.path ?? null; - const projectName = project?.name ?? 'Project'; - - // Trigger session init for this project (idempotent) - useWorkspaceInit(projectId ?? null, projectPath); - - // Set which project we're viewing (no session teardown — purely view state) - if (projectId) setViewing(projectId); - - const { data: sessions = [] } = useWorkspaceSessions(projectId ?? null); - - const primarySession = sessions.find((s) => s.key.type === 'primary'); - - if (!projectId) { - return ( - <div className="text-muted-foreground flex h-full items-center justify-center text-sm"> - Open a project to start a workspace session. - </div> - ); - } - - return ( - <div className="flex h-full min-h-0"> - {/* Primary Claude — left 55% */} - <div className="min-w-0 flex-[55]"> - {primarySession ? ( - <PrimarySessionPanel - sessionId={primarySession.agentSessionId} - projectId={projectId} - projectName={projectName} - status={primarySession.status} - /> - ) : ( - <div className="text-muted-foreground flex h-full items-center justify-center text-sm"> - Starting primary session… - </div> - )} - </div> - - {/* Team Leads — right 45% */} - <div className="border-border min-w-0 flex-[45] border-l"> - <TeamLeadPanelList sessions={sessions} projectId={projectId} /> - </div> - </div> - ); -} -``` - -- [ ] **Step 5: Create `src/renderer/features/workspace/index.ts`** - -```typescript -/** Workspace feature — public API */ -export { WorkspacePage } from './components/WorkspacePage'; -``` - -- [ ] **Step 6: Lint + typecheck** - -```bash -npm run lint && npm run typecheck -``` - -Expected: clean. If `useAgentStream` doesn't exist or has a different name, check `src/renderer/features/agent-dashboard/hooks/` and `src/renderer/features/agent-dashboard/index.ts` for the correct export name. Adjust the import accordingly. - -If `events` from `useAgentStream` has a different shape (not `{ content: string }`), adjust the event rendering in `PrimarySessionPanel` and `TeamLeadPanel` to match the actual event type (check `StreamJsonEventSchema` in `src/shared/ipc/agent-dashboard/schemas.ts`). - -- [ ] **Step 7: Commit** - -```bash -git add src/renderer/features/workspace/ -git commit -m "feat(workspace): add WorkspacePage, PrimarySessionPanel, TeamLeadPanel components" -``` - ---- - -## Task 8: Route Update + Sidebar Label - -**Files:** -- Modify: `src/renderer/app/routes/project.routes.ts` -- Modify: `src/renderer/app/layouts/Sidebar.tsx` -- Modify: `src/renderer/app/layouts/sidebar-layouts/shared-nav.ts` - -- [ ] **Step 1: Swap `AgentDashboardPage` → `WorkspacePage` in `project.routes.ts`** - -In `src/renderer/app/routes/project.routes.ts`, find the `agentsRoute` definition (lines 60–69) and replace it: - -```typescript - const agentsRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: ROUTE_PATTERNS.PROJECT_AGENTS, - staticData: { breadcrumbLabel: 'Workspace' }, - pendingComponent: ProjectSkeleton, - component: lazyRouteComponent( - () => import('@features/workspace'), - 'WorkspacePage', - ), - }); -``` - -- [ ] **Step 2: Update sidebar label in `src/renderer/app/layouts/Sidebar.tsx`** - -Find line 67: -```typescript - { label: 'Agents', icon: Bot, path: PROJECT_VIEWS.AGENTS }, -``` -Change to: -```typescript - { label: 'Workspace', icon: Bot, path: PROJECT_VIEWS.AGENTS }, -``` - -- [ ] **Step 3: Update shared nav in `src/renderer/app/layouts/sidebar-layouts/shared-nav.ts`** - -Find line 55: -```typescript - { label: 'Agents', icon: Bot, path: PROJECT_VIEWS.AGENTS }, -``` -Change to: -```typescript - { label: 'Workspace', icon: Bot, path: PROJECT_VIEWS.AGENTS }, -``` - -- [ ] **Step 4: Lint + typecheck + build** - -```bash -npm run lint && npm run typecheck && npm run build -``` - -Expected: clean. The lazy import `() => import('@features/workspace')` needs the `@features` alias to resolve. Verify `@features` points to `src/renderer/features` in `electron.vite.config.ts`. - -- [ ] **Step 5: Commit** - -```bash -git add src/renderer/app/routes/project.routes.ts src/renderer/app/layouts/Sidebar.tsx src/renderer/app/layouts/sidebar-layouts/shared-nav.ts -git commit -m "feat(workspace): wire WorkspacePage to agents route, rename sidebar label to Workspace" -``` - ---- - -## Task 9: Assistant Service Simplification - -**Files:** -- Modify: `src/main/services/assistant/assistant-service.ts` -- Delete: `src/main/services/assistant/intent-classifier/` (entire directory) -- Delete: `src/main/services/assistant/executors/` (entire directory — keep if you want reference, but don't import from it) - -**Before writing:** Read `src/main/services/assistant/assistant-service.ts` in full to understand which executors and deps are currently wired. The plan below replaces the entire service body. - -- [ ] **Step 1: Read the current assistant service** - -```bash -cat src/main/services/assistant/assistant-service.ts -``` - -Note: which deps are passed in (so you know what to remove from service-registry), and where `sendCommand` currently calls the classifier. - -- [ ] **Step 2: Replace `assistant-service.ts` with simplified version** - -```typescript -/** - * AssistantService — simplified direct Claude CLI subprocess. - * - * Fire-and-forget: sends input to `claude --print` and streams the response - * back to the renderer via event:assistant.response chunks. - * - * No intent classification. No executors. No routing. - */ - -import { spawn } from 'node:child_process'; - -import type { BrowserWindow } from 'electron'; - -export interface AssistantService { - sendCommand(input: string, projectPath: string): void; - getHistory(): CommandHistoryEntry[]; - clearHistory(): void; -} - -interface CommandHistoryEntry { - id: string; - input: string; - responseSummary: string; - timestamp: string; -} - -export function createAssistantService(getWindow: () => BrowserWindow | null): AssistantService { - const history: CommandHistoryEntry[] = []; - - function sendEvent(channel: string, payload: unknown): void { - getWindow()?.webContents.send(channel, payload); - } - - return { - sendCommand(input, projectPath) { - const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - sendEvent('event:assistant.thinking', { isThinking: true }); - - const child = spawn('claude', ['--print', '-p', input], { - cwd: projectPath, - shell: true, - }); - - let responseBuffer = ''; - - child.stdout.on('data', (chunk: Buffer) => { - const text = chunk.toString(); - responseBuffer += text; - sendEvent('event:assistant.response', { content: text, type: 'text' }); - }); - - child.stderr.on('data', (chunk: Buffer) => { - sendEvent('event:assistant.response', { content: chunk.toString(), type: 'error' }); - }); - - child.on('close', () => { - sendEvent('event:assistant.thinking', { isThinking: false }); - history.push({ - id, - input, - responseSummary: responseBuffer.slice(0, 200), - timestamp: new Date().toISOString(), - }); - }); - }, - - getHistory() { - return [...history]; - }, - - clearHistory() { - history.length = 0; - }, - }; -} -``` - -- [ ] **Step 3: Update assistant handler to match new signature** - -Find `src/main/ipc/handlers/assistant-handlers.ts`. Update the `sendCommand` handler to pass `projectPath` instead of the full context object. The handler should read `input` and `projectPath` from the IPC input (after contract is updated in Task 11): - -```typescript -router.handle('assistant.sendCommand', ({ input, projectPath }) => { - assistantService.sendCommand(input, projectPath ?? ''); - return Promise.resolve({ success: true }); -}); -``` - -- [ ] **Step 4: Update service-registry.ts — remove assistant deps** - -In `src/main/bootstrap/service-registry.ts`, find where `createAssistantService` is called. It currently takes many deps (mcpManager, notesService, etc.). Replace the call with just: - -```typescript -const assistantService = createAssistantService(getMainWindow); -``` - -Remove any imports that were only used by the old assistant service constructor (watch-store, watch-evaluator, cross-device-query, etc.) IF they are not used by any other service in the registry. Check each before deleting. - -- [ ] **Step 5: Delete old assistant sub-directories** - -```bash -rm -rf src/main/services/assistant/intent-classifier -rm -rf src/main/services/assistant/executors -``` - -If you want to keep them for reference, leave them but remove all imports from `assistant-service.ts`. - -- [ ] **Step 6: Typecheck + build** - -```bash -npm run typecheck && npm run build -``` - -Expected: clean. If there are references to removed deps elsewhere, trace the import chain and clean up. The most likely issue is service-registry importing a helper that was only used by the old assistant service — just remove those imports. - -- [ ] **Step 7: Commit** - -```bash -git add src/main/services/assistant/ src/main/ipc/handlers/assistant-handlers.ts src/main/bootstrap/service-registry.ts -git commit -m "feat(assistant): strip assistant service to direct Claude CLI subprocess, remove intent classifier and executors" -``` - ---- - -## Task 10: WidgetPanel Cleanup — Remove Quick Actions - -**Files:** -- Modify: `src/renderer/features/assistant/components/WidgetPanel.tsx` - -**Before writing:** Read `src/renderer/features/assistant/components/WidgetPanel.tsx` in full. Note: all the quick action chips, confirmation cards, and context enrichment logic need to be removed. - -- [ ] **Step 1: Read WidgetPanel.tsx** - -```bash -cat src/renderer/features/assistant/components/WidgetPanel.tsx -``` - -- [ ] **Step 2: Remove quick actions from WidgetPanel** - -Locate and delete: -1. The `QUICK_ACTIONS` constant (array of `{ label, icon, command }` objects) -2. The `AssistantContext` enrichment code (reading `activeProjectId`, `currentPage` to build context) -3. The quick action chip JSX block (renders buttons from `QUICK_ACTIONS`) -4. Any confirmation/preview card JSX -5. The `context` parameter passed to `useSendCommand` (or equivalent mutation call) - -What remains: -- The floating FAB button + panel open/close toggle -- The text input -- The response area with markdown rendering (keep this) -- The clear history button -- `useSendCommand` mutation but simplified: passes `input` + `projectPath` (read from active project store) - -The `useSendCommand` call changes from: -```typescript -// OLD -sendCommand({ input, context: { activeProjectId, currentPage, ... } }) -// NEW -sendCommand({ input, projectPath: activeProject?.path ?? '' }) -``` - -After editing, the JSX should be: FAB → panel → [response area] + [clear button] + [input + send button]. - -- [ ] **Step 3: Lint + typecheck** - -```bash -npm run lint && npm run typecheck -``` - -Expected: clean. If `useSendCommand` type signature doesn't match the new IPC schema yet (that comes in Task 11), you may need to do Tasks 10 and 11 together or use a temporary `as unknown` cast. - -- [ ] **Step 4: Commit** - -```bash -git add src/renderer/features/assistant/components/WidgetPanel.tsx -git commit -m "feat(assistant): remove quick action chips and intent context from widget panel" -``` - ---- - -## Task 11: IPC Schema Cleanup — Remove Intent and Action Types - -**Files:** -- Modify: `src/shared/ipc/assistant/schemas.ts` -- Modify: `src/shared/ipc/assistant/contract.ts` -- Modify: `src/shared/ipc/index.ts` - -- [ ] **Step 1: Simplify schemas in `src/shared/ipc/assistant/schemas.ts`** - -**Delete** the following exports entirely from the file: -- `IntentTypeSchema` (lines 12–29) -- `AssistantActionSchema` (lines 31–64) - -**Replace** `AssistantContextSchema`: -```typescript -// OLD -export const AssistantContextSchema = z.object({ - activeProjectId: z.string().nullable(), - activeProjectName: z.string().nullable(), - currentPage: z.string(), - todayDate: z.string(), -}); - -// NEW -export const AssistantContextSchema = z.object({ - projectPath: z.string(), -}); -``` - -**Replace** `AssistantResponseSchema`: -```typescript -export const AssistantResponseSchema = z.object({ - type: z.enum(['text', 'error']), - content: z.string(), -}); -``` - -**Replace** `CommandHistoryEntrySchema` (remove `intent` and `action` fields): -```typescript -export const CommandHistoryEntrySchema = z.object({ - id: z.string(), - input: z.string(), - responseSummary: z.string(), - timestamp: z.string(), -}); -``` - -- [ ] **Step 2: Simplify `src/shared/ipc/assistant/contract.ts`** - -Replace the `assistant.sendCommand` channel: -```typescript -export const assistantInvoke = { - 'assistant.sendCommand': { - input: z.object({ - input: z.string(), - projectPath: z.string(), - }), - output: z.object({ success: z.boolean() }), - }, - 'assistant.getHistory': { - input: z.object({ limit: z.number().optional() }), - output: z.array(CommandHistoryEntrySchema), - }, - 'assistant.clearHistory': { - input: z.object({}), - output: z.object({ success: z.boolean() }), - }, -} as const; -``` - -Remove `'event:assistant.commandCompleted'` and `'event:assistant.proactive'` from `assistantEvents` if the assistant service no longer emits them. Keep `'event:assistant.response'` and `'event:assistant.thinking'`. - -- [ ] **Step 3: Remove schema re-exports from `src/shared/ipc/index.ts`** - -Find the assistant re-export block (around lines 159–167): -```typescript -export { - AssistantActionSchema, // ← DELETE - AssistantContextSchema, - AssistantResponseSchema, - CommandHistoryEntrySchema, - IntentTypeSchema, // ← DELETE - WebhookCommandSchema, - WebhookCommandSourceContextSchema, -} from './assistant'; -``` - -Remove `AssistantActionSchema` and `IntentTypeSchema` from that block. - -- [ ] **Step 4: Fix any remaining usages** - -```bash -npm run typecheck 2>&1 | grep -i "intent\|action\|AssistantAction\|IntentType" -``` - -Fix any remaining references. These will typically be in: -- `src/renderer/features/assistant/hooks/useSendCommand.ts` (if it passes the old context shape) -- Any component that reads `intent` or `action` from responses - -- [ ] **Step 5: Lint + typecheck + build** - -```bash -npm run lint && npm run typecheck && npm run build -``` - -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -git add src/shared/ipc/assistant/ src/shared/ipc/index.ts -git commit -m "chore(assistant): remove IntentTypeSchema, AssistantActionSchema, simplify IPC contract to direct CLI shape" -``` - ---- - -## Task 12: Final Verification Pass - -**No new files. Runs all checks across the full diff.** - -- [ ] **Step 1: Full lint pass** - -```bash -npm run lint -``` - -Expected: 0 errors. Fix any remaining issues before continuing. - -- [ ] **Step 2: Full typecheck** - -```bash -npm run typecheck -``` - -Expected: 0 errors. - -- [ ] **Step 3: Build** - -```bash -npm run build -``` - -Expected: successful compilation with no errors. - -- [ ] **Step 4: Smoke-check ContentHeader is gone** - -```bash -grep -r "ContentHeader" src/renderer/ -``` - -Expected: only `ContentHeader.tsx` itself (it can remain as an unused file — it's just not imported anymore). If any layout still imports it, remove that import. - -- [ ] **Step 5: Smoke-check intent classifier is gone** - -```bash -grep -r "intent-classifier\|IntentType\|AssistantAction" src/ --include="*.ts" --include="*.tsx" -``` - -Expected: no hits (except the deleted directories if still present on disk). - -- [ ] **Step 6: Smoke-check workspace barrel is wired** - -```bash -grep -n "workspaceInvoke\|workspaceEvents" src/shared/ipc/index.ts -``` - -Expected: 2 hits (one in invoke spread, one in events spread). - -- [ ] **Step 7: Final commit** - -```bash -git add -A -git commit -m "chore(workspace): final verification — lint, typecheck, build all pass" -``` - ---- - -## Spec Coverage Checklist - -| Spec Requirement | Task | -|---|---| -| Primary Claude session auto-spawns when project opens | Tasks 2, 3, 8 | -| Team Lead 1 auto-spawns when project opens | Tasks 2, 3, 8 | -| Switching project tabs doesn't terminate sessions | Task 2 (idempotent initProject, Map keyed by projectId) | -| Immortal sessions auto-restart on crash | Task 2 (onEvent → restart after delay) | -| Spawn additional Team Leads on demand | Tasks 2, 3, 6, 7 | -| Stop mortal Team Leads | Tasks 2, 3, 6, 7 | -| Primary panel takes 55-60% width | Task 7 (flex-[55] / flex-[45]) | -| Team Lead right column, scrollable | Task 7 (TeamLeadPanelList) | -| Sessions accept freeform input + stream responses | Tasks 6, 7 | -| Chrome cleanup: remove 40px title bar | Task 4 | -| Sidebar toggle moves to TopBar | Task 4 | -| Route label "Workspace" | Task 8 | -| Assistant widget: direct Claude CLI, no intent | Tasks 9, 10 | -| No quick action chips | Task 10 | -| No confirmation cards | Task 10 | -| IPC schemas cleaned up | Task 11 | -| npm run lint ∧ typecheck ∧ build pass | Task 12 | diff --git a/docs/plans/2026-04-03-p0-critical-fixes.md b/docs/plans/2026-04-03-p0-critical-fixes.md deleted file mode 100644 index c694de3a..00000000 --- a/docs/plans/2026-04-03-p0-critical-fixes.md +++ /dev/null @@ -1,694 +0,0 @@ -# P0 Critical Fixes Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix four confirmed data-loss and security bugs: plaintext password caching, planner goal-completion not persisting, pipeline plan feedback silently discarded, and notification rules lost on restart. - -**Architecture:** Four independent surgical fixes — each touches 1–5 files with no cross-task dependencies. Implement in any order. Two fixes are renderer-only; one spans shared types + renderer; one is a store-layer persistence addition. - -**Tech Stack:** TypeScript strict, React 19, Zustand, TanStack Query, Vitest (unit/integration for main-process code), `npm run lint` + `npm run typecheck` + `npm run build` as verification gates for renderer changes. - -**Verification note:** Unit tests in this repo live under `tests/unit/services/` (main process). Renderer-side logic has no test harness yet. For renderer-only tasks (1, 3, 4) the verification gate is typecheck + lint + build. For task 2 (shared types + IPC contract), write a unit test for the schema change. - ---- - -## Audit correction (verified before writing this plan) - -The audit incorrectly flagged three items as broken. They are already implemented: -- **Logout button**: `UserMenu.tsx` in the sidebar footer calls `useLogout()` correctly -- **`useUpdateIdea()`**: exported from `useIdeas.ts` and wired in `IdeaEditForm.tsx` -- **`useVoteIdea()`**: exported from `useIdeas.ts` and used in `IdeationPage.tsx` - -Do not attempt to re-implement these. - ---- - -## File Map - -| File | Task | Change | -|------|------|--------| -| `src/renderer/features/auth/hooks/useSavedLogins.ts` | 1 | Remove `password` field; save/read email only | -| `src/renderer/features/auth/components/LoginPage.tsx` | 1 | Update badge click to pre-fill email only, no auto-submit | -| `src/shared/types/planner.ts` | 2 | Add `completedGoals?: string[]` to `DailyPlan` | -| `src/shared/ipc/planner/schemas.ts` | 2 | Add `completedGoals` to `DailyPlanSchema` | -| `src/shared/ipc/planner/contract.ts` | 2 | Add `completedGoals` to `planner.updateDay` input | -| `src/renderer/features/planner/components/GoalsList.tsx` | 2 | Accept `completedGoals` + `onToggle` props; remove local `completed` state | -| `src/renderer/features/planner/components/PlannerPage.tsx` | 2 | Pass `completedGoals` from plan; handle toggle | -| `src/renderer/features/workflow-pipeline/components/step-panels/PlanReadyPanel.tsx` | 3 | Import + call `useReplanWithFeedback`; replace `void feedback;` | -| `src/renderer/features/communications/store.ts` | 4 | Persist `notificationRules` to localStorage on every write | - ---- - -## Task 1: Remove plaintext password from SavedLogins - -**Context:** `useSavedLogins.ts` saves `{ email, password }` to `localStorage` key `adc:saved-logins`. Anyone who can read localStorage (other renderer scripts, DevTools, any XSS) gets the user's plaintext password. The fix: store email only. Badge clicks pre-fill email and focus the password field — the user types their own password. - -**Files:** -- Modify: `src/renderer/features/auth/hooks/useSavedLogins.ts` -- Modify: `src/renderer/features/auth/components/LoginPage.tsx` - -- [ ] **Step 1.1: Rewrite `useSavedLogins.ts` — email only** - -Replace the entire file content with: - -```typescript -/** - * useSavedLogins — persists email addresses in localStorage - * so the login page can show click-to-fill badges. - * - * Passwords are intentionally NOT stored. - */ - -const STORAGE_KEY = 'adc:saved-logins'; - -export interface SavedLogin { - email: string; -} - -function readFromStorage(): SavedLogin[] { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw === null) return []; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (item): item is SavedLogin => - typeof item === 'object' && - item !== null && - typeof (item as Record<string, unknown>).email === 'string', - ); - } catch { - return []; - } -} - -function writeToStorage(logins: SavedLogin[]): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(logins)); -} - -export function useSavedLogins() { - const logins = readFromStorage(); - - function saveLogin(email: string): void { - const existing = readFromStorage().filter((l) => l.email !== email); - writeToStorage([{ email }, ...existing]); - } - - function removeLogin(email: string): void { - writeToStorage(readFromStorage().filter((l) => l.email !== email)); - } - - return { logins, saveLogin, removeLogin }; -} -``` - -- [ ] **Step 1.2: Update `LoginPage.tsx` — remove password from badge behaviour** - -In `src/renderer/features/auth/components/LoginPage.tsx`: - -1. In `onSubmit`, change `saveLogin(value.email, value.password)` → `saveLogin(value.email)`: - -```typescript -onSuccess: () => { - saveLogin(value.email); // was: saveLogin(value.email, value.password) - onSuccess(); -}, -``` - -2. Remove the `handleBadgeClick` function entirely (lines 72-76 in the current file). - -3. Replace the badge `<button>` onClick so it only fills the email field and does **not** submit: - -```tsx -<button - className="text-foreground hover:text-primary transition-colors" - type="button" - onClick={() => { - form.setFieldValue('email', saved.email); - }} -> - {saved.email} -</button> -``` - -The remove button (`<X>`) stays unchanged. - -- [ ] **Step 1.3: Verify** - -```bash -npm run typecheck -npm run lint -``` - -Expected: no errors. Confirm `LoginPage.tsx` no longer references `handleBadgeClick` or `saved.password`. - -- [ ] **Step 1.4: Commit** - -```bash -git add src/renderer/features/auth/hooks/useSavedLogins.ts src/renderer/features/auth/components/LoginPage.tsx -git commit -m "fix(auth): remove plaintext password from localStorage saved logins" -``` - ---- - -## Task 2: Persist planner goal completion - -**Context:** `GoalsList.tsx` tracks completed goals with a local `Set<number>` (index-based). This resets on every page navigation and is lost on refresh. The fix adds `completedGoals?: string[]` — an array of completed goal *text values* — to the `DailyPlan` type, IPC schema, and IPC contract, then wires it through the component tree. Text-based tracking (not index-based) is correct because goals can be added/removed without breaking existing completions. - -**Files:** -- Modify: `src/shared/types/planner.ts` -- Modify: `src/shared/ipc/planner/schemas.ts` -- Modify: `src/shared/ipc/planner/contract.ts` -- Modify: `src/renderer/features/planner/components/GoalsList.tsx` -- Modify: `src/renderer/features/planner/components/PlannerPage.tsx` -- Create: `tests/unit/services/planner-schema.test.ts` - -- [ ] **Step 2.1: Write failing test for schema** - -Create `tests/unit/services/planner-schema.test.ts`: - -```typescript -/** - * Unit test for DailyPlanSchema completedGoals field. - */ -import { describe, expect, it } from 'vitest'; - -import { DailyPlanSchema } from '../../../src/shared/ipc/planner/schemas'; - -describe('DailyPlanSchema', () => { - it('accepts a plan with completedGoals', () => { - const result = DailyPlanSchema.safeParse({ - date: '2026-04-03', - goals: ['Write tests', 'Ship feature'], - completedGoals: ['Write tests'], - scheduledTasks: [], - timeBlocks: [], - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.completedGoals).toEqual(['Write tests']); - } - }); - - it('accepts a plan without completedGoals (backwards compatible)', () => { - const result = DailyPlanSchema.safeParse({ - date: '2026-04-03', - goals: ['Write tests'], - scheduledTasks: [], - timeBlocks: [], - }); - expect(result.success).toBe(true); - }); -}); -``` - -- [ ] **Step 2.2: Run test — confirm it fails** - -```bash -npm run test:unit -- --reporter=verbose tests/unit/services/planner-schema.test.ts -``` - -Expected: FAIL — `completedGoals` is not a recognised field in schema, parse likely strips it (strictness depends on Zod mode). Confirm test output shows the failure you expect. - -- [ ] **Step 2.3: Add `completedGoals` to shared type** - -In `src/shared/types/planner.ts`, update `DailyPlan`: - -```typescript -export interface DailyPlan { - date: string; - goals: string[]; - completedGoals?: string[]; // ← add this line - scheduledTasks: ScheduledTask[]; - timeBlocks: TimeBlock[]; - reflection?: string; -} -``` - -- [ ] **Step 2.4: Add `completedGoals` to Zod schema** - -In `src/shared/ipc/planner/schemas.ts`, update `DailyPlanSchema`: - -```typescript -export const DailyPlanSchema = z.object({ - date: z.string(), - goals: z.array(z.string()), - completedGoals: z.array(z.string()).optional(), // ← add this line - scheduledTasks: z.array(ScheduledTaskSchema), - timeBlocks: z.array(TimeBlockSchema), - reflection: z.string().optional(), -}); -``` - -- [ ] **Step 2.5: Add `completedGoals` to IPC contract** - -In `src/shared/ipc/planner/contract.ts`, update `planner.updateDay` input: - -```typescript -'planner.updateDay': { - input: z.object({ - date: z.string(), - goals: z.array(z.string()).optional(), - completedGoals: z.array(z.string()).optional(), // ← add this line - scheduledTasks: z.array(ScheduledTaskSchema).optional(), - reflection: z.string().optional(), - }), - output: DailyPlanSchema, -}, -``` - -- [ ] **Step 2.6: Run test — confirm it passes** - -```bash -npm run test:unit -- --reporter=verbose tests/unit/services/planner-schema.test.ts -``` - -Expected: PASS — both test cases green. - -- [ ] **Step 2.7: Rewrite `GoalsList.tsx` — remove local completion state** - -Replace the entire file content with: - -```tsx -/** - * GoalsList — Daily goals checklist. - * Completion is persisted via the onToggle callback (not local state). - */ - -import { useState } from 'react'; - -import { Check, Plus, Trash2, X } from 'lucide-react'; - -import { cn } from '@renderer/shared/lib/utils'; - -interface GoalsListProps { - goals: string[]; - completedGoals: string[]; - onUpdate: (goals: string[]) => void; - onToggle: (goalText: string) => void; -} - -export function GoalsList({ goals, completedGoals, onUpdate, onToggle }: GoalsListProps) { - const [newGoal, setNewGoal] = useState(''); - const completedSet = new Set(completedGoals); - - function handleAdd() { - const trimmed = newGoal.trim(); - if (trimmed.length === 0) return; - onUpdate([...goals, trimmed]); - setNewGoal(''); - } - - function handleKeyDown(event: React.KeyboardEvent) { - if (event.key === 'Enter') { - handleAdd(); - } - } - - function handleRemove(index: number) { - const removedText = goals[index]; - const updated = goals.filter((_g, idx) => idx !== index); - onUpdate(updated); - // If the removed goal was completed, clean it up too - if (completedSet.has(removedText)) { - onToggle(removedText); // parent will remove it from completedGoals - } - } - - return ( - <div className="space-y-3"> - <h3 className="text-foreground text-sm font-semibold">Daily Goals</h3> - - {goals.length === 0 ? ( - <p className="text-muted-foreground text-xs">No goals set for today.</p> - ) : ( - <ul className="space-y-1.5"> - {goals.map((goal, index) => { - const isComplete = completedSet.has(goal); - return ( - <li key={`goal-${String(index)}`} className="group flex items-center gap-2"> - <button - aria-label={isComplete ? 'Mark incomplete' : 'Mark complete'} - className={cn( - 'flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors', - isComplete - ? 'border-success bg-success text-success-foreground' - : 'border-border hover:border-primary', - )} - onClick={() => onToggle(goal)} - > - {isComplete ? <Check className="h-3 w-3" /> : null} - </button> - <span - className={cn( - 'flex-1 text-sm', - isComplete ? 'text-muted-foreground line-through' : 'text-foreground', - )} - > - {goal} - </span> - <button - aria-label="Remove goal" - className="text-muted-foreground hover:text-destructive opacity-0 transition-opacity group-hover:opacity-100" - onClick={() => handleRemove(index)} - > - <Trash2 className="h-3.5 w-3.5" /> - </button> - </li> - ); - })} - </ul> - )} - - <div className="flex items-center gap-2"> - <input - className="border-input bg-background text-foreground placeholder:text-muted-foreground focus:ring-ring flex-1 rounded-md border px-2.5 py-1.5 text-sm outline-none focus:ring-1" - placeholder="Add a goal..." - type="text" - value={newGoal} - onChange={(event) => setNewGoal(event.target.value)} - onKeyDown={handleKeyDown} - /> - {newGoal.trim().length > 0 ? ( - <> - <button - aria-label="Add goal" - className="text-primary hover:text-primary/80 transition-colors" - onClick={handleAdd} - > - <Plus className="h-4 w-4" /> - </button> - <button - aria-label="Cancel" - className="text-muted-foreground hover:text-foreground transition-colors" - onClick={() => setNewGoal('')} - > - <X className="h-4 w-4" /> - </button> - </> - ) : null} - </div> - </div> - ); -} -``` - -- [ ] **Step 2.8: Update `PlannerPage.tsx` — wire completedGoals** - -In `src/renderer/features/planner/components/PlannerPage.tsx`: - -1. Add `handleGoalToggle` function after `handleGoalsUpdate`: - -```typescript -function handleGoalToggle(goalText: string) { - const current = plan?.completedGoals ?? []; - const next = current.includes(goalText) - ? current.filter((g) => g !== goalText) - : [...current, goalText]; - updateDay.mutate({ date: selectedDate, completedGoals: next }); -} -``` - -2. Update the `<GoalsList>` JSX call (currently around line 189): - -```tsx -<GoalsList - goals={plan?.goals ?? []} - completedGoals={plan?.completedGoals ?? []} - onUpdate={handleGoalsUpdate} - onToggle={handleGoalToggle} -/> -``` - -- [ ] **Step 2.9: Verify** - -```bash -npm run typecheck -npm run lint -npm run test:unit -- tests/unit/services/planner-schema.test.ts -``` - -Expected: all pass. Confirm `GoalsList` no longer has a `completed` local state. - -- [ ] **Step 2.10: Commit** - -```bash -git add src/shared/types/planner.ts src/shared/ipc/planner/schemas.ts src/shared/ipc/planner/contract.ts src/renderer/features/planner/components/GoalsList.tsx src/renderer/features/planner/components/PlannerPage.tsx tests/unit/services/planner-schema.test.ts -git commit -m "fix(planner): persist goal completion via completedGoals field on DailyPlan" -``` - ---- - -## Task 3: Wire pipeline plan feedback to `useReplanWithFeedback` - -**Context:** `PlanReadyPanel.tsx` opens a feedback dialog when the user clicks "Request Changes." The `handleFeedbackSubmit` function receives the feedback string but then runs `void feedback;` — discarding it silently. The `useReplanWithFeedback` mutation already exists in `useAgentMutations.ts` and calls `agent.replanWithFeedback` IPC. This task wires the two together. - -**Files:** -- Modify: `src/renderer/features/workflow-pipeline/components/step-panels/PlanReadyPanel.tsx` - -- [ ] **Step 3.1: Import and instantiate `useReplanWithFeedback`** - -In `PlanReadyPanel.tsx`, add the import alongside the existing agent imports: - -```typescript -import { useReplanWithFeedback, useStartExecution } from '@features/tasks/api/useAgentMutations'; -``` - -Then inside the component body, add after `const updateStatus = useUpdateTaskStatus();`: - -```typescript -const replanWithFeedback = useReplanWithFeedback(); -``` - -- [ ] **Step 3.2: Replace `handleFeedbackSubmit`** - -Replace the current `handleFeedbackSubmit` function (lines 67–72): - -```typescript -// BEFORE (broken): -function handleFeedbackSubmit(feedback: string) { - setFeedbackDialogOpen(false); - // Re-plan with feedback is handled by the parent or a dedicated mutation; - // for now, we close the dialog. The parent can wire onRequestChanges if needed. - void feedback; -} -``` - -With: - -```typescript -function handleFeedbackSubmit(feedback: string) { - setFeedbackDialogOpen(false); - replanWithFeedback.mutate({ - taskId: task.id, - projectPath: (task.metadata?.worktreePath as string | undefined) ?? '', - taskDescription: task.description, - feedback, - previousPlanPath: task.metadata?.planPath as string | undefined, - }); -} -``` - -- [ ] **Step 3.3: Disable "Request Changes" button while mutation is pending** - -Update the "Request Changes" button to show a pending state: - -```tsx -<button - className={cn(ACTION_BUTTON_BASE, 'bg-warning/10 text-warning hover:bg-warning/20')} - disabled={replanWithFeedback.isPending} - type="button" - onClick={() => { - setFeedbackDialogOpen(true); - }} -> - <MessageSquare className="h-3.5 w-3.5" /> - {replanWithFeedback.isPending ? 'Requesting...' : 'Request Changes'} -</button> -``` - -- [ ] **Step 3.4: Verify** - -```bash -npm run typecheck -npm run lint -``` - -Expected: no errors. Confirm `void feedback;` no longer appears in the file: - -```bash -grep -n "void feedback" src/renderer/features/workflow-pipeline/components/step-panels/PlanReadyPanel.tsx -``` - -Expected: no output. - -- [ ] **Step 3.5: Commit** - -```bash -git add src/renderer/features/workflow-pipeline/components/step-panels/PlanReadyPanel.tsx -git commit -m "fix(pipeline): wire plan feedback dialog to useReplanWithFeedback mutation" -``` - ---- - -## Task 4: Persist communications notification rules across restarts - -**Context:** `useCommunicationsStore` stores `notificationRules` in Zustand memory only. Every app restart clears them. The fix manually persists `notificationRules` to `localStorage` key `adc:notification-rules`, using the same read/write pattern already established by `useSavedLogins` and `useAuthStore`. Only rules are persisted — `slackStatus`, `discordStatus`, and `activeTab` remain ephemeral (they're re-established from real service state on mount). - -**Files:** -- Modify: `src/renderer/features/communications/store.ts` - -- [ ] **Step 4.1: Rewrite `store.ts` with localStorage persistence for rules** - -Replace the entire file content with: - -```typescript -/** - * Communications Store — UI state for the communications feature. - * Notification rules are persisted to localStorage so they survive restarts. - * Service connection status is ephemeral and re-established on mount. - */ - -import { create } from 'zustand'; - -type ServiceStatus = 'connected' | 'disconnected' | 'error'; - -interface NotificationRule { - id: string; - service: 'slack' | 'discord'; - pattern: string; - enabled: boolean; -} - -interface CommunicationsState { - slackStatus: ServiceStatus; - discordStatus: ServiceStatus; - notificationRules: NotificationRule[]; - activeTab: 'overview' | 'slack' | 'discord' | 'rules'; - setSlackStatus: (status: ServiceStatus) => void; - setDiscordStatus: (status: ServiceStatus) => void; - setActiveTab: (tab: CommunicationsState['activeTab']) => void; - addNotificationRule: (rule: Omit<NotificationRule, 'id'>) => void; - removeNotificationRule: (id: string) => void; - toggleNotificationRule: (id: string) => void; -} - -const RULES_STORAGE_KEY = 'adc:notification-rules'; - -function loadRules(): NotificationRule[] { - try { - const raw = localStorage.getItem(RULES_STORAGE_KEY); - if (raw === null) return []; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (item): item is NotificationRule => - typeof item === 'object' && - item !== null && - typeof (item as Record<string, unknown>).id === 'string' && - typeof (item as Record<string, unknown>).service === 'string' && - typeof (item as Record<string, unknown>).pattern === 'string' && - typeof (item as Record<string, unknown>).enabled === 'boolean', - ); - } catch { - return []; - } -} - -function saveRules(rules: NotificationRule[]): void { - localStorage.setItem(RULES_STORAGE_KEY, JSON.stringify(rules)); -} - -export const useCommunicationsStore = create<CommunicationsState>((set) => ({ - slackStatus: 'disconnected', - discordStatus: 'disconnected', - notificationRules: loadRules(), - activeTab: 'overview', - - setSlackStatus: (status) => set({ slackStatus: status }), - - setDiscordStatus: (status) => set({ discordStatus: status }), - - setActiveTab: (tab) => set({ activeTab: tab }), - - addNotificationRule: (rule) => - set((state) => { - const next = [...state.notificationRules, { ...rule, id: crypto.randomUUID() }]; - saveRules(next); - return { notificationRules: next }; - }), - - removeNotificationRule: (id) => - set((state) => { - const next = state.notificationRules.filter((r) => r.id !== id); - saveRules(next); - return { notificationRules: next }; - }), - - toggleNotificationRule: (id) => - set((state) => { - const next = state.notificationRules.map((r) => - r.id === id ? { ...r, enabled: !r.enabled } : r, - ); - saveRules(next); - return { notificationRules: next }; - }), -})); -``` - -- [ ] **Step 4.2: Verify** - -```bash -npm run typecheck -npm run lint -``` - -Expected: no errors. The public API of `useCommunicationsStore` is unchanged (same methods, same types) so no other files need updating. - -- [ ] **Step 4.3: Commit** - -```bash -git add src/renderer/features/communications/store.ts -git commit -m "fix(communications): persist notification rules to localStorage across restarts" -``` - ---- - -## Final verification - -- [ ] **Step 5.1: Full build** - -```bash -npm run build -``` - -Expected: exits 0, no TypeScript or bundler errors. - -- [ ] **Step 5.2: Full lint** - -```bash -npm run lint -``` - -Expected: zero violations on modified files. - -- [ ] **Step 5.3: Unit tests** - -```bash -npm run test:unit -``` - -Expected: all existing tests pass + new `planner-schema.test.ts` passes. - -- [ ] **Step 5.4: Manual smoke test checklist** - -After launching the app (`npm run dev`): - -| Check | How to verify | -|-------|--------------| -| Badge click pre-fills email only | Login page → saved badge → click → email fills, password field stays empty | -| No password stored | DevTools → Application → localStorage → `adc:saved-logins` → entries have `email` only, no `password` key | -| Goal completion persists | Planner → add goal → check it → navigate away → return → checkbox stays checked | -| Goal uncomplete persists | Planner → uncheck a completed goal → navigate away → return → checkbox stays unchecked | -| Feedback triggers re-plan | Pipeline → task in Plan Ready state → Request Changes → type feedback → submit → task moves to Planning status | -| Notification rules survive restart | Communications → Rules tab → add a rule → restart app → rule still appears | diff --git a/docs/plans/2026-04-03-plan-2-core-ux-hardening.md b/docs/plans/2026-04-03-plan-2-core-ux-hardening.md deleted file mode 100644 index 5aa26cb8..00000000 --- a/docs/plans/2026-04-03-plan-2-core-ux-hardening.md +++ /dev/null @@ -1,309 +0,0 @@ -# Research Doc: Plan 2 — Core UX Hardening - -**Goal:** Audit and harden every feature's UI for design-system compliance, correct empty states, loading skeletons, missing CRUD operations, and raw HTML element violations. No new features — this plan makes the existing features actually feel finished. - ---- - -## Summary of Issues Found - -Five categories of violations across 14 features: - -1. **Raw HTML elements** — `<button>`, `<input>`, `<textarea>`, `<select>` used directly instead of `@ui` primitives -2. **Empty states missing or wrong** — some features show nothing when there's no data; others show confusing copy -3. **Missing CRUD wiring** — edit/delete actions exist in the UI but are not connected to mutations -4. **Loading states** — some pages show no loading indicator at all; others use inconsistent patterns -5. **Design token violations** — hardcoded Tailwind color literals (`text-warning`, `bg-warning/10`) that may not exist in the theme - ---- - -## Feature-by-Feature Scope - -### 1. Roadmap (`RoadmapPage.tsx` + `MilestoneCard`) - -**Files:** -- `src/renderer/features/roadmap/components/RoadmapPage.tsx` -- (no additional files needed) - -**Current state:** -- Form inputs (`<input>`, `<textarea>`, `<select>`) are raw HTML — not `@ui` primitives -- `<button>` elements are raw HTML with inline Tailwind classes — should be `<Button>` from `@ui` -- `<select>` for status change uses raw HTML -- Milestone edit is NOT implemented — there is no way to edit a milestone's title/description/date after creation. `useUpdateMilestone()` exists and only handles `status` changes from the status `<select>` -- Empty state: Exists and shows icon + copy — this is fine - -**Target state:** -- Replace all raw `<input>`, `<textarea>`, `<select>`, `<button>` with `@ui` primitives: `Input`, `Textarea`, `Select`, `Button` -- Add milestone edit modal/inline editing: clicking a milestone title opens edit mode for title, description, targetDate -- Wire `useUpdateMilestone({ id, title, description, targetDate })` to the edit form - -**IPC changes:** None — `milestones.update` already accepts title/description/targetDate - -**Exact changes:** -| Element | Current | Target | -|---------|---------|--------| -| `RoadmapPage.tsx:216` | `<button>` New Milestone | `<Button variant="primary">` | -| `RoadmapPage.tsx:250,257` | `<button>` Create/Cancel | `<Button variant="primary">` / `<Button variant="outline">` | -| `RoadmapPage.tsx:229,234,243` | `<input>`, `<textarea>`, `<input type=date>` | `<Input>`, `<Textarea>`, `<Input type="date">` | -| `MilestoneCard:88` | `<select>` status | `<Select>` from `@ui` | -| `MilestoneCard:97` | `<button>` delete | `<Button variant="ghost" size="icon">` | -| `MilestoneCard:154,160` | `<input>` add task, `<button>` | `<Input>`, `<Button variant="ghost" size="icon">` | - ---- - -### 2. Planner (`PlannerPage.tsx`, `GoalsList.tsx`, `DayView.tsx`, `TimeBlockEditor.tsx`) - -**Files:** -- `src/renderer/features/planner/components/PlannerPage.tsx` -- `src/renderer/features/planner/components/GoalsList.tsx` -- `src/renderer/features/planner/components/DayView.tsx` -- `src/renderer/features/planner/components/TimeBlockEditor.tsx` - -**Current state:** -- `PlannerPage.tsx:117,130` — navigation `<button>` elements are raw HTML -- `PlannerPage.tsx:207,215,221` — reflection `<textarea>`, `<button>` Save/Cancel are raw HTML -- `GoalsList.tsx` — the entire component uses raw `<input>`, `<button>` elements (after Plan 1 rewrite, these will still be raw per the Plan 1 code) -- `DayView.tsx` — need to verify; likely uses raw elements too -- No empty state for a day with no time blocks - -**Target state:** -- Replace all raw elements in planner with `@ui` primitives -- Add a visible empty state to the time block column: "No time blocks scheduled — click + to add one" - -**IPC changes:** None - ---- - -### 3. Ideation (`IdeationPage.tsx`, `IdeaEditForm.tsx`) - -**Files:** -- `src/renderer/features/ideation/components/IdeationPage.tsx` -- `src/renderer/features/ideation/components/IdeaEditForm.tsx` - -**Current state (need to verify exact violations):** -- Need to read `IdeationPage.tsx` to confirm raw element usage -- `useCreateIdea`, `useUpdateIdea`, `useDeleteIdea`, `useVoteIdea` all exist — wiring confirmed in prior audit -- Empty state: unknown until file read - -**Target state:** -- `@ui` compliance pass -- Confirm empty state copy is clear: "No ideas yet — add your first idea or ask the assistant to generate some" - ---- - -### 4. Notes (`NotesList.tsx`, `NoteEditor.tsx`, `QuickNote.tsx`) - -**Files:** -- `src/renderer/features/notes/components/NotesList.tsx` -- `src/renderer/features/notes/components/NoteEditor.tsx` -- `src/renderer/features/notes/components/QuickNote.tsx` - -**Current state:** -- `NotesPage.tsx` has a good empty state: "Select a note or create a new one" — this is fine -- Need to read `NoteEditor.tsx` and `NotesList.tsx` to verify compliance - -**Target state:** -- `@ui` compliance pass on editor buttons and list actions - ---- - -### 5. Fitness (`FitnessPage.tsx`, `WorkoutForm.tsx`, `GoalsPanel.tsx`, `BodyComposition.tsx`) - -**Files:** -- `src/renderer/features/fitness/components/FitnessPage.tsx` -- `src/renderer/features/fitness/components/WorkoutForm.tsx` -- `src/renderer/features/fitness/components/GoalsPanel.tsx` -- `src/renderer/features/fitness/components/BodyComposition.tsx` -- `src/renderer/features/fitness/components/StatsOverview.tsx` - -**Current state:** -- `FitnessPage.tsx:46` — "Log Workout" `<button>` is raw HTML -- Tabs use raw `<button>` elements -- `WorkoutForm.tsx` — almost certainly uses raw `<input>`, `<select>` for exercise fields -- `GoalsPanel.tsx` — fitness goals likely using raw elements -- `StatsOverview.tsx` — display-only, likely fine - -**Target state:** -- `<Button>` for Log Workout and all action buttons -- Tab component or `@ui` equivalent for the tab bar -- `Input`, `Select`, `Button` in WorkoutForm and GoalsPanel - ---- - -### 6. Communications (`CommunicationsPage.tsx`, `SlackPanel.tsx`, `DiscordPanel.tsx`, `NotificationRules.tsx`) - -**Files:** -- `src/renderer/features/communications/components/CommunicationsPage.tsx` -- `src/renderer/features/communications/components/SlackPanel.tsx` -- `src/renderer/features/communications/components/DiscordPanel.tsx` -- `src/renderer/features/communications/components/NotificationRules.tsx` - -**Current state:** -- Service connection status shown as pills — need to verify element type -- `NotificationRules.tsx` — rules add/remove form uses unknown elements -- `SlackPanel.tsx` / `DiscordPanel.tsx` — action buttons - -**Target state:** -- `@ui` compliance pass across all 4 components - ---- - -### 7. Dashboard (`DashboardPage.tsx`, `TodayView.tsx`, `GreetingHeader.tsx`, `RecentProjects.tsx`, `QuickCapture.tsx`, `DailyStats.tsx`) - -**Files:** -- `src/renderer/features/dashboard/components/TodayView.tsx` -- `src/renderer/features/dashboard/components/GreetingHeader.tsx` -- `src/renderer/features/dashboard/components/RecentProjects.tsx` -- `src/renderer/features/dashboard/components/QuickCapture.tsx` -- `src/renderer/features/dashboard/components/DailyStats.tsx` - -**Current state:** -- Dashboard layout exists but content is mostly static/stub data -- `GreetingHeader.tsx` — time-of-day greeting, need to check if it uses real data -- `TodayView.tsx` — shows today's schedule/goals; need to verify it reads from planner IPC -- `RecentProjects.tsx` — need to verify it calls `projects.list` or similar IPC -- `QuickCapture.tsx` — inline note/task capture; need to verify it calls `notes.create` / `tasks.create` -- `DailyStats.tsx` — need to verify it has real data vs hardcoded - -**Target state:** -- `TodayView` reads from planner IPC (`useDay(today)`) and shows goals + time blocks -- `RecentProjects` reads from projects IPC with `updatedAt` sort -- `QuickCapture` creates a note or task via mutation on submit -- `DailyStats` shows real counts from React Query (tasks completed, notes created, etc.) - ---- - -### 8. Alerts (`CreateAlertModal.tsx`, `RecurringAlerts.tsx`) - -**Files:** -- `src/renderer/features/alerts/components/CreateAlertModal.tsx` -- `src/renderer/features/alerts/components/RecurringAlerts.tsx` - -**Current state:** Need to read to verify -**Target state:** `@ui` compliance, verify CRUD wiring complete - ---- - -### 9. GitHub (`NotificationList.tsx`, `PrList.tsx`, `PrDetailModal.tsx`) - -**Files:** -- `src/renderer/features/github/components/NotificationList.tsx` -- `src/renderer/features/github/components/PrList.tsx` - -**Current state:** GitHub feature reads from `github.listNotifications`, `github.listPRs` IPC — need to verify IPC handlers exist -**Target state:** Empty states, loading states, `@ui` compliance - ---- - -## Cross-Cutting Issues - -### `text-warning` / `bg-warning` Token - -Multiple components use `text-warning`, `bg-warning/10` (e.g., `LoginPage.tsx:195`). This token may not be defined in the Tailwind theme. Need to: -1. Check `tailwind.config.ts` for `warning` token definition -2. If missing: add `warning` to theme OR replace with `text-yellow-600`/`text-amber-600` - -**File:** `tailwind.config.ts` (or equivalent Tailwind v4 config) - -### Loading State Pattern - -Current inconsistency: -- Some pages: `isLoading ? <Loader2 animate-spin> : null` — correct -- Some pages: no loading state at all -- Target: every page that fetches data should have a loading skeleton or spinner - -**Standard pattern to enforce:** -```tsx -if (isLoading) { - return ( - <div className="flex h-full items-center justify-center"> - <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" /> - </div> - ); -} -``` - -### Empty State Pattern - -Standard empty state: -```tsx -<div className="border-border rounded-lg border border-dashed p-12 text-center"> - <IconComponent className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> - <p className="text-lg font-medium">No [items] yet</p> - <p className="text-muted-foreground mt-1 text-sm">[Actionable copy]</p> - <Button className="mt-4" onClick={onCreate}>Add [Item]</Button> -</div> -``` - ---- - -## Files to Create - -None — this is a compliance/hardening pass only. - -## Files to Modify (Full List) - -``` -src/renderer/features/roadmap/components/RoadmapPage.tsx -src/renderer/features/planner/components/PlannerPage.tsx -src/renderer/features/planner/components/GoalsList.tsx (after Plan 1) -src/renderer/features/planner/components/DayView.tsx -src/renderer/features/planner/components/TimeBlockEditor.tsx -src/renderer/features/ideation/components/IdeationPage.tsx -src/renderer/features/ideation/components/IdeaEditForm.tsx -src/renderer/features/notes/components/NotesList.tsx -src/renderer/features/notes/components/NoteEditor.tsx -src/renderer/features/notes/components/QuickNote.tsx -src/renderer/features/fitness/components/FitnessPage.tsx -src/renderer/features/fitness/components/WorkoutForm.tsx -src/renderer/features/fitness/components/GoalsPanel.tsx -src/renderer/features/fitness/components/BodyComposition.tsx -src/renderer/features/communications/components/CommunicationsPage.tsx -src/renderer/features/communications/components/SlackPanel.tsx -src/renderer/features/communications/components/DiscordPanel.tsx -src/renderer/features/communications/components/NotificationRules.tsx -src/renderer/features/dashboard/components/TodayView.tsx -src/renderer/features/dashboard/components/GreetingHeader.tsx -src/renderer/features/dashboard/components/RecentProjects.tsx -src/renderer/features/dashboard/components/QuickCapture.tsx -src/renderer/features/dashboard/components/DailyStats.tsx -src/renderer/features/alerts/components/CreateAlertModal.tsx -src/renderer/features/alerts/components/RecurringAlerts.tsx -src/renderer/features/github/components/NotificationList.tsx -src/renderer/features/github/components/PrList.tsx -tailwind.config.ts (or CSS variables file for warning token) -``` - ---- - -## IPC Changes - -None. This plan touches only renderer components. No new IPC channels. No schema changes. - ---- - -## Risk / Complexity - -| Risk | Level | Notes | -|------|-------|-------| -| Breaking UI during `@ui` migration | Low | `@ui` primitives are drop-in; Tailwind classes from raw elements move to `className` prop | -| Dashboard widgets reading stale/missing IPC | Medium | `TodayView` / `DailyStats` may need IPC channels verified before wiring | -| `text-warning` token missing | Low | Compile-time check will catch it; easy to add to theme | -| Regression in existing CRUD flows | Low | Mutations stay the same; only form element type changes | - ---- - -## Out of Scope - -- New features or new data fields -- AI generation (Plan 3) -- Performance optimization -- Animations beyond existing transitions -- Mobile/responsive layout -- Any IPC handler changes - ---- - -## Dependencies - -- Plan 1 must be complete first (GoalsList rewrite will conflict if done in parallel) -- No dependency on Plans 3-5 diff --git a/docs/plans/2026-04-03-plan-3-ai-connectivity-engine.md b/docs/plans/2026-04-03-plan-3-ai-connectivity-engine.md deleted file mode 100644 index a8a0368d..00000000 --- a/docs/plans/2026-04-03-plan-3-ai-connectivity-engine.md +++ /dev/null @@ -1,304 +0,0 @@ -# Research Doc: Plan 3 — AI Connectivity Engine - -**Goal:** Replace the fire-and-forget Claude CLI subprocess in `assistant-service.ts` with Anthropic SDK tool_use calls, so that when the user types "create a roadmap for my project" the assistant actually creates a Milestone record in the database. Wire per-feature AI generation buttons (Generate Roadmap, Generate Ideas, Summarize Briefing, etc.) to produce structured output that pre-fills forms for user review. - ---- - -## The Core Problem - -**Current flow:** -``` -user types → assistant.sendCommand IPC → spawn('claude --print') → text streams back → nothing created -``` - -**Target flow:** -``` -user types → assistant.sendCommand IPC → Anthropic SDK messages.create({ tools: [...] }) → - Claude returns tool_call → main process executes tool (= calls service) → - tool_result sent back → Claude confirms → renderer invalidates React Query cache → - new record appears in UI -``` - -There is already an `@anthropic-ai/sdk` wrapper at `src/main/services/claude/claude-client.ts` that does `sendMessage` and `streamMessage` via the SDK. The assistant-service ignores it entirely and uses `spawn('claude', ...)` instead. Plan 3 wires them together with tool_use support. - ---- - -## Architecture - -### Tool Definition Pattern - -Each app capability becomes an Anthropic tool. Tools map 1:1 to IPC channels that already exist. - -```typescript -// Example tool definition -{ - name: 'create_milestone', - description: 'Create a new roadmap milestone with title, description, and target date', - input_schema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Milestone title' }, - description: { type: 'string', description: 'What this milestone represents' }, - targetDate: { type: 'string', description: 'ISO date string, e.g. 2026-06-01' }, - projectId: { type: 'string', description: 'Optional project ID to associate with' }, - }, - required: ['title', 'description', 'targetDate'], - }, -} -``` - -### Tool Execution Pattern - -When Claude returns a `tool_use` block: -1. Main process identifies the tool name -2. Calls the corresponding service method directly (same path as IPC handlers use) -3. Returns the result as `tool_result` to Claude -4. Claude's final text response is streamed to renderer -5. Renderer receives `event:assistant.toolExecuted` event → invalidates relevant React Query cache - -### Context Injection Pattern - -When the user sends a message, the assistant service receives `projectPath` and now also `context`: -```typescript -sendCommand(input: string, projectPath: string, context?: AssistantContext) - -interface AssistantContext { - activeView: 'roadmap' | 'planner' | 'ideation' | 'notes' | 'fitness' | ... - activeProjectId?: string -} -``` - -This context is injected into the system prompt so Claude knows what view the user is on. - ---- - -## Files to Create - -| File | Purpose | -|------|---------| -| `src/main/services/assistant/tool-definitions.ts` | All Anthropic tool definitions as a typed array | -| `src/main/services/assistant/tool-executor.ts` | Maps tool name → service call, returns tool_result | -| `src/main/services/assistant/tool-registry.ts` | `registerTool(name, handler)` + lookup | - ---- - -## Files to Modify - -| File | Change | -|------|--------| -| `src/main/services/assistant/assistant-service.ts` | Replace `spawn('claude', ...)` with `claudeClient.streamMessageWithTools(...)` | -| `src/shared/ipc/agents/contract.ts` | Add `context?: AssistantContext` to `assistant.sendCommand` input | -| `src/shared/types/assistant.ts` (create if needed) | `AssistantContext` type, `AssistantToolEvent` type | -| `src/renderer/features/assistant/api/useAssistant.ts` | Pass `context` (active view + project) in `useSendCommand` mutation | -| `src/renderer/features/assistant/components/WidgetPanel.tsx` | Read `activeView` from layout store; pass as context | -| `src/renderer/features/assistant/hooks/useAssistantEvents.ts` | Handle new `event:assistant.toolExecuted` event → invalidate correct query keys | - ---- - -## Tool Inventory - -### Core CRUD Tools (must have for v1) - -| Tool Name | Maps To | IPC Channel | -|-----------|---------|-------------| -| `create_note` | `NotesService.createNote` | `notes.create` | -| `create_idea` | `IdeasService.createIdea` | `ideas.create` | -| `create_milestone` | `MilestonesService.createMilestone` | `milestones.create` | -| `create_task` | `ProgressService.createTask` | `progress.create.task` | -| `update_daily_plan` | `PlannerService.updateDay` | `planner.updateDay` | -| `add_time_block` | `PlannerService.addTimeBlock` | `planner.addTimeBlock` | -| `log_workout` | `FitnessService.logWorkout` | `fitness.logWorkout` | - -### Read/Query Tools (context for Claude) - -| Tool Name | Maps To | Purpose | -|-----------|---------|---------| -| `get_today_plan` | `PlannerService.getDay` | Let Claude see today's goals before modifying | -| `list_milestones` | `MilestonesService.listMilestones` | Let Claude see existing roadmap | -| `list_ideas` | `IdeasService.listIdeas` | Let Claude see existing ideas before adding | -| `list_notes` | `NotesService.listNotes` | Let Claude find notes by search | -| `get_project_context` | `ProjectService.getProject` | Active project info | - ---- - -## IPC Changes - -### `assistant.sendCommand` input (current) -```typescript -z.object({ - input: z.string(), - projectPath: z.string(), -}) -``` - -### `assistant.sendCommand` input (target) -```typescript -z.object({ - input: z.string(), - projectPath: z.string(), - context: z.object({ - activeView: z.string().optional(), - activeProjectId: z.string().optional(), - }).optional(), -}) -``` - -### New event: `event:assistant.toolExecuted` -```typescript -// Payload -{ - toolName: string; // e.g. 'create_milestone' - queryKeysToInvalidate: string[][]; // e.g. [['milestones', 'list']] - result: unknown; // The created/updated record -} -``` - -Renderer `useAssistantEvents.ts` listens for this event and calls `queryClient.invalidateQueries` for the listed keys. - ---- - -## `assistant-service.ts` Rewrite (Key Logic) - -The new `sendCommand` function: - -```typescript -async sendCommand(input, projectPath, context?) { - const id = generateId(); - const conversationId = getOrCreateConversation(id); - - sendEvent('event:assistant.thinking', { isThinking: true }); - - const systemPrompt = buildSystemPrompt(context); - const tools = toolRegistry.getAllDefinitions(); - - // Stream with tools enabled - const stream = anthropicClient.messages.stream({ - model: 'claude-sonnet-4-20250514', - max_tokens: 4096, - system: systemPrompt, - tools, - messages: conversationStore.buildMessages(conversationId, input), - }); - - for await (const event of stream) { - if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { - sendEvent('event:assistant.response', { content: event.delta.text, type: 'text' }); - } - - if (event.type === 'content_block_stop' && event.content_block.type === 'tool_use') { - const toolBlock = event.content_block; - // Execute the tool - const result = await toolExecutor.execute(toolBlock.name, toolBlock.input); - // Emit to renderer for cache invalidation - sendEvent('event:assistant.toolExecuted', { - toolName: toolBlock.name, - queryKeysToInvalidate: toolExecutor.getQueryKeys(toolBlock.name), - result, - }); - // Continue the conversation with tool_result - conversationStore.addToolResult(conversationId, toolBlock.id, result); - } - } - - sendEvent('event:assistant.thinking', { isThinking: false }); -} -``` - ---- - -## Per-Feature AI Generation Buttons - -Beyond the chat assistant, individual feature pages need "Generate with AI" buttons that pre-fill forms. These use a **dedicated generation IPC channel** (not the chat assistant) to keep concerns separate. - -### New IPC Channel: `assistant.generate` - -```typescript -'assistant.generate': { - input: z.object({ - prompt: z.string(), // Constructed by the UI, not the user - schema: z.record(z.unknown()), // JSON Schema for the expected output - context: z.record(z.unknown()).optional(), - }), - output: z.object({ - fields: z.record(z.unknown()), // Parsed structured output - raw: z.string(), // Raw Claude response - }), -} -``` - -This channel uses `claude.sendMessage` (non-streaming, structured output mode) to return a JSON object matching the `schema`. The renderer uses the returned `fields` to pre-fill a form. - -### Generation Buttons Per Feature - -| Feature | Button Label | Prompt Template | Fields Returned | -|---------|-------------|-----------------|-----------------| -| Roadmap | "Generate Roadmap" | "Create 3-5 milestones for a project called {projectName} focused on {description}" | `Array<{title, description, targetDate}>` | -| Ideation | "Generate Ideas" | "Generate 5 ideas for {prompt} in category {category}" | `Array<{title, description, tags}>` | -| Planner | "Plan My Day" | "Given my goals: {goals}, create a time-blocked schedule for today" | `Array<{startTime, endTime, label}>` | -| Notes | "Generate Note" | "Write a note about {topic} for {projectName}" | `{title, content}` | -| Briefing | "Summarize" | "Summarize the following for a morning briefing: {content}" | `{summary, keyPoints, actions}` | - -**Files to create/modify for generation buttons:** -- `src/shared/ipc/claude/contract.ts` — add `assistant.generate` channel -- `src/main/ipc/handlers/assistant-handlers.ts` — add handler that calls `claude-client.sendMessage` with JSON mode -- `src/renderer/features/roadmap/components/RoadmapPage.tsx` — add Generate Roadmap button + preview modal -- `src/renderer/features/ideation/components/IdeationPage.tsx` — add Generate Ideas button + preview list -- `src/renderer/features/planner/components/PlannerPage.tsx` — add Plan My Day button -- `src/renderer/features/notes/components/NotesList.tsx` or `NotesPage.tsx` — add Generate Note button -- New shared hook: `src/renderer/shared/hooks/useAIGenerate.ts` — wraps `ipc('assistant.generate', ...)` mutation with loading/error state - ---- - -## System Prompt Strategy - -```typescript -function buildSystemPrompt(context?: AssistantContext): string { - const base = `You are a personal developer assistant embedded in ADC (a developer OS desktop app). -You have access to tools that can create and update records in the app. -When the user asks you to create something, use the appropriate tool — do NOT just describe what you would do. -Always confirm what you created and show the key details.`; - - const viewContext = context?.activeView - ? `\nCurrent view: ${context.activeView}. Prefer tools related to this view.` - : ''; - - const projectContext = context?.activeProjectId - ? `\nActive project ID: ${context.activeProjectId}. Use this for projectId in tool calls.` - : ''; - - return base + viewContext + projectContext; -} -``` - ---- - -## Risk / Complexity - -| Risk | Level | Notes | -|------|-------|-------| -| Anthropic API key not configured | Medium | Need graceful error in UI: "Claude API key not set — go to Settings" | -| Tool execution failure mid-stream | Medium | Need try/catch in tool executor; emit error event to renderer | -| Context window overflow from long tool result chains | Low | 4096 token limit is generous for CRUD tool results | -| Agentic loops (Claude keeps calling tools) | Low | Set `tool_choice: "auto"` with a 10-iteration guard | -| Rate limiting | Low | Already handled in `mapApiError` in claude-client | -| Breaking existing `spawn` behavior | Low | Swap is clean — assistant-service is self-contained | - ---- - -## Out of Scope - -- Multi-step agentic workflows (spawning Claude agents) -- Prompt chaining / conversation branching -- Streaming tool results to renderer (show progress while tool runs) -- Tool use in the `claude.*` conversation channels (those stay as plain messages) -- Writing files to disk via tools -- Running code via tools - ---- - -## Dependencies - -- Plan 1 should be complete (no conflict, but good hygiene) -- Plan 2 is independent (can run parallel to Plan 3) -- Requires Anthropic API key configured in settings (existing settings IPC handles this) -- `@anthropic-ai/sdk` already installed — verify version supports streaming with tools diff --git a/docs/plans/2026-04-03-plan-4-assistant-copilot.md b/docs/plans/2026-04-03-plan-4-assistant-copilot.md deleted file mode 100644 index 929323d8..00000000 --- a/docs/plans/2026-04-03-plan-4-assistant-copilot.md +++ /dev/null @@ -1,266 +0,0 @@ -# Research Doc: Plan 4 — Assistant as Co-Pilot - -**Goal:** Transform the assistant widget from a generic chatbot into a context-aware co-pilot. Quick Actions become per-view smart prompts. The assistant knows what page you're on and what project is active. The assistant can fill out and submit forms on behalf of the user, showing them a preview to confirm before saving. - -This plan builds on Plan 3's tool_use infrastructure and focuses on the UX layer: how the assistant presents itself, how quick actions work per view, and how generated content flows into forms. - ---- - -## Current State of Assistant UX - -### Widget (`WidgetPanel.tsx`, `QuickActions.tsx`, `WidgetInput.tsx`, `WidgetMessageArea.tsx`) - -- Floating FAB in bottom-right corner → opens/closes panel -- Panel: fixed width 380px, max 70vh, pinned bottom-right -- Header: "Assistant" title, voice toggle, clear, close -- Message area: scrollable stream of assistant responses -- Input: textarea + send button -- Quick Actions: 4 hardcoded static buttons above the input area - -**QuickActions problem:** -```typescript -const quickActions: QuickAction[] = [ - { label: 'New Note', icon: StickyNote, command: 'create a new note' }, - { label: 'New Task', icon: ClipboardList, command: 'create a new task' }, - { label: 'Run Agent', icon: Play, command: 'run agent on current task' }, - { label: 'Remind Me', icon: Bell, command: 'set a reminder' }, -]; -``` -These inject text into the chat input — they don't execute anything. After Plan 3, typing "create a new note" will work. But the UX is still wrong: Quick Actions should be *contextual* — if you're on the Roadmap page, they should offer roadmap-specific actions. - -### AssistantWidget (`AssistantWidget.tsx`) - -- Renders `WidgetFab` + conditionally `WidgetPanel` -- No context awareness — doesn't know what view is active - ---- - -## Target UX - -### Context-Aware Quick Actions - -Quick Actions are computed based on `activeView` from the layout store. Each view has 4 tailored actions. - -```typescript -const VIEW_QUICK_ACTIONS: Record<string, QuickAction[]> = { - roadmap: [ - { label: 'Generate Roadmap', icon: Map, command: 'Generate a product roadmap for my current project' }, - { label: 'Add Milestone', icon: Plus, command: 'Add a milestone to my roadmap' }, - { label: 'Summarize Progress', icon: TrendingUp, command: 'Summarize my roadmap progress' }, - { label: 'Next Steps', icon: ArrowRight, command: 'What are the next steps on my roadmap?' }, - ], - planner: [ - { label: 'Plan My Day', icon: Calendar, command: 'Plan my day based on my goals' }, - { label: 'Add Goal', icon: Target, command: 'Add a goal to today\'s plan' }, - { label: 'Schedule Block', icon: Clock, command: 'Schedule a time block for me' }, - { label: 'Review Yesterday', icon: History, command: 'Help me write a reflection for yesterday' }, - ], - ideation: [ - { label: 'Generate Ideas', icon: Lightbulb, command: 'Generate 5 ideas for my current project' }, - { label: 'Refine Idea', icon: Pencil, command: 'Help me refine my best idea' }, - { label: 'Group Ideas', icon: Layers, command: 'Group my ideas by theme' }, - { label: 'Pick Top 3', icon: Star, command: 'Which of my ideas has the most potential?' }, - ], - notes: [ - { label: 'New Note', icon: StickyNote, command: 'Create a new note about' }, - { label: 'Summarize', icon: FileText, command: 'Summarize the selected note' }, - { label: 'Find Note', icon: Search, command: 'Find my note about' }, - { label: 'Meeting Notes', icon: Users, command: 'Create meeting notes for' }, - ], - fitness: [ - { label: 'Log Workout', icon: Dumbbell, command: 'Log my workout for today' }, - { label: 'Suggest Workout', icon: Zap, command: 'Suggest a workout for today' }, - { label: 'Track Goal', icon: Target, command: 'Update my fitness goal' }, - { label: 'Weekly Summary', icon: BarChart, command: 'Summarize my fitness this week' }, - ], - dashboard: [ - { label: 'Morning Briefing', icon: Sun, command: 'Give me a morning briefing' }, - { label: 'Quick Capture', icon: Zap, command: 'Capture a quick note or task' }, - { label: 'What\'s Next', icon: ArrowRight, command: 'What should I focus on next?' }, - { label: 'End of Day', icon: Moon, command: 'Help me wrap up my day' }, - ], - // fallback - default: [ - { label: 'New Note', icon: StickyNote, command: 'create a new note' }, - { label: 'New Task', icon: ClipboardList, command: 'create a new task' }, - { label: 'Remind Me', icon: Bell, command: 'set a reminder' }, - { label: 'Search', icon: Search, command: 'search for' }, - ], -}; -``` - -### Form Pre-Fill Flow (Plan 3 + Plan 4 combined) - -When a Quick Action triggers AI generation (e.g., "Generate Roadmap"), instead of just inserting text into chat: - -1. Quick Action sends a structured generation request via `assistant.generate` IPC (Plan 3) -2. Claude returns structured fields (e.g., array of milestone objects) -3. Renderer shows a **preview modal** before saving: - - Title: "Generated Roadmap — Review & Confirm" - - List of proposed milestones with editable title/date - - "Save All" button → calls `createMilestone.mutate(...)` for each - - "Edit" inline → user can modify before saving - - "Discard" → closes without saving -4. On confirmation, records are created and the page refreshes - -This flow keeps the human in control: Claude generates, human reviews, human confirms. - ---- - -## Files to Create - -| File | Purpose | -|------|---------| -| `src/renderer/features/assistant/components/GenerationPreviewModal.tsx` | Reusable modal showing AI-generated content for review before save | -| `src/renderer/shared/hooks/useAIGenerate.ts` | Shared mutation hook wrapping `assistant.generate` IPC | -| `src/renderer/features/assistant/lib/view-quick-actions.ts` | `VIEW_QUICK_ACTIONS` map + `getQuickActionsForView(view)` helper | - ---- - -## Files to Modify - -| File | Change | -|------|--------| -| `src/renderer/features/assistant/components/QuickActions.tsx` | Accept `activeView?: string` prop; render view-specific actions from `view-quick-actions.ts` | -| `src/renderer/features/assistant/components/WidgetPanel.tsx` | Read `activeView` from `useLayoutStore`; pass to `<QuickActions>` | -| `src/renderer/features/assistant/components/AssistantWidget.tsx` | Pass `activeView` down from layout store | -| `src/renderer/features/assistant/hooks/useAssistantEvents.ts` | Handle `event:assistant.toolExecuted` → invalidate query cache (Plan 3 supplies this event) | -| `src/renderer/features/roadmap/components/RoadmapPage.tsx` | Add "Generate Roadmap" button → `useAIGenerate` → `<GenerationPreviewModal>` | -| `src/renderer/features/ideation/components/IdeationPage.tsx` | Add "Generate Ideas" button → `useAIGenerate` → `<GenerationPreviewModal>` | -| `src/renderer/features/planner/components/PlannerPage.tsx` | Add "Plan My Day" button → `useAIGenerate` → pre-fills time blocks | -| `src/renderer/features/notes/components/NotesPage.tsx` or `NotesList.tsx` | Add "Generate Note" button → `useAIGenerate` → opens NoteEditor pre-filled | - ---- - -## `GenerationPreviewModal` Component Design - -```typescript -interface GenerationPreviewModalProps<T> { - isOpen: boolean; - title: string; - items: T[]; - renderItem: (item: T, onChange: (updated: T) => void) => React.ReactNode; - onConfirm: (items: T[]) => void; - onClose: () => void; - isSubmitting?: boolean; -} -``` - -Generic enough to handle milestone arrays, idea arrays, or single-note objects. Renders with: -- Modal overlay (using `@ui` Dialog or sheet) -- Scrollable list of pre-filled items, each editable inline -- Footer: Cancel + "Save [N] items" button - ---- - -## Assistant Context in Layout Store - -The layout store (`src/renderer/shared/stores/layout-store.ts`) already tracks `activeProjectId`. We need to add `activeView`: - -```typescript -// Add to layout store: -activeView: string; // matches route segment: 'roadmap', 'planner', 'ideation', etc. -setActiveView: (view: string) => void; -``` - -**Where `setActiveView` is called:** In the route component for each feature (or in each page's `useEffect` on mount). Alternatively, derive it from TanStack Router's active route — `useRouter().state.location.pathname`. - -The simpler approach: read `window.location.pathname` in `WidgetPanel.tsx` to derive the active view without modifying the layout store. - -```typescript -function getActiveViewFromPath(pathname: string): string { - if (pathname.includes('/roadmap')) return 'roadmap'; - if (pathname.includes('/planner')) return 'planner'; - if (pathname.includes('/ideation')) return 'ideation'; - if (pathname.includes('/notes')) return 'notes'; - if (pathname.includes('/fitness')) return 'fitness'; - if (pathname.includes('/dashboard') || pathname === '/') return 'dashboard'; - return 'default'; -} -``` - ---- - -## Wiring the "Generate Roadmap" Button (Concrete Example) - -**In `RoadmapPage.tsx`:** - -```tsx -// Add alongside existing state: -const [isGenerating, setIsGenerating] = useState(false); -const [generatedMilestones, setGeneratedMilestones] = useState<GeneratedMilestone[]>([]); -const [showPreview, setShowPreview] = useState(false); -const generateContent = useAIGenerate(); - -async function handleGenerateRoadmap() { - const result = await generateContent.mutateAsync({ - prompt: `Generate 3-5 roadmap milestones for a software project${ - activeProject ? ` called "${activeProject.name}"` : '' - }. Focus on major deliverables with realistic target dates starting from today.`, - schema: { - type: 'array', - items: { - type: 'object', - properties: { - title: { type: 'string' }, - description: { type: 'string' }, - targetDate: { type: 'string', format: 'date' }, - }, - required: ['title', 'description', 'targetDate'], - }, - }, - }); - setGeneratedMilestones(result.fields as GeneratedMilestone[]); - setShowPreview(true); -} - -function handleConfirmGenerated(milestones: GeneratedMilestone[]) { - for (const m of milestones) { - createMilestone.mutate(m); - } - setShowPreview(false); -} -``` - ---- - -## Assistant Voice Notes - -The assistant already has voice input (`useAssistantVoice.ts`) and voice output toggle (speaker button in `WidgetPanel.tsx`). These are already wired. No changes needed here — they'll naturally work once the assistant can actually take actions. - ---- - -## IPC Changes - -None beyond Plan 3's `assistant.generate` channel. Plan 4 is purely a renderer-layer change. - ---- - -## Risk / Complexity - -| Risk | Level | Notes | -|------|-------|-------| -| Quick action commands don't map well to tools | Low | Commands are natural language; Claude + tools handles ambiguity | -| Preview modal complexity | Medium | Generic modal with inline editing needs careful prop design | -| `activeView` derivation from pathname | Low | Simple string matching; no store changes needed if using pathname | -| Generated dates being in wrong format | Low | System prompt specifies ISO date format | -| User dismisses preview, AI created records anyway | N/A | Generation is separate from creation — `assistant.generate` does NOT create records; only `onConfirm` creates them | - ---- - -## Out of Scope - -- Saving conversation history to disk -- Multiple chat sessions / conversation switching -- Assistant personality configuration -- "Memory" of past user preferences across sessions -- Proactive suggestions without user prompting -- Assistant in non-widget views (full-page assistant view) - ---- - -## Dependencies - -- **Plan 3 must be complete first** — this plan requires `assistant.generate` IPC channel -- Plan 2 is independent -- Layout store must expose route/view info (minimal change or pathname derivation) diff --git a/docs/plans/2026-04-03-plan-5-polish-and-enhancement.md b/docs/plans/2026-04-03-plan-5-polish-and-enhancement.md deleted file mode 100644 index 0d4c75b0..00000000 --- a/docs/plans/2026-04-03-plan-5-polish-and-enhancement.md +++ /dev/null @@ -1,327 +0,0 @@ -# Research Doc: Plan 5 — Polish & Enhancement - -**Goal:** Final pass to make the app feel like a cohesive, well-crafted product. Focus on keyboard shortcuts, navigation improvements, dashboard as the true home screen, briefing feature as a daily habit, and removing dead/deprecated code. This plan runs after Plans 1-4 are complete and the core functionality is solid. - ---- - -## Scope Overview - -Six areas of work: - -1. **Dashboard as Home** — make the dashboard truly useful as the first screen you see -2. **Briefing feature** — complete the morning briefing UX so it's a daily ritual -3. **Keyboard shortcuts** — add global hotkeys for common actions -4. **Dead code removal** — delete `terminal-service.ts`, deprecated types, unused routes -5. **Navigation cleanup** — consistent header patterns, breadcrumbs where needed -6. **Sidebar improvements** — active state, badge counts, collapsed state - ---- - -## 1. Dashboard as Home - -### Current State - -`DashboardPage.tsx` renders 6 widgets in a grid: -- `GreetingHeader` — time-of-day greeting with user name -- `TodayView` — today's schedule/goals (need to verify if it reads real data) -- `RecentProjects` — list of recently accessed projects -- `ActiveAgents` — running agent tasks -- `QuickCapture` — inline note/task capture -- `DailyStats` — completion stats - -The dashboard is the first route (`/`) but may feel empty if data is sparse. - -### Issues to Resolve - -**`TodayView.tsx`** — needs to read from `planner.getDay(today)` and show: -- Goals checklist (with completion state, read-only view linking to Planner) -- Today's time blocks as a mini timeline -- "Open Planner" link to the full planner view - -**`DailyStats.tsx`** — need to verify what it actually queries. Target: show real counts: -- Notes created today -- Tasks completed today -- Goals completed today -- Workouts logged this week - -**`RecentProjects.tsx`** — need to verify it calls a real IPC and sorts by `updatedAt`. If it shows a hard-coded list or empty state with no IPC call, that's a bug. - -**`GreetingHeader.tsx`** — currently shows time-of-day greeting. Could show: -- Quick weather (if available via an existing service, not a new integration) -- Motivational streak data (if tracked anywhere) -- Keep simple if data isn't there - -### Files to Modify - -``` -src/renderer/features/dashboard/components/TodayView.tsx -src/renderer/features/dashboard/components/DailyStats.tsx -src/renderer/features/dashboard/components/RecentProjects.tsx -src/renderer/features/dashboard/components/GreetingHeader.tsx -src/renderer/features/dashboard/components/QuickCapture.tsx -``` - -### IPC Channels Needed - -These should already exist; verify handlers are implemented: -- `planner.getDay` (for TodayView) -- `projects.list` sorted by updatedAt (for RecentProjects) -- `notes.list` filtered by today (for DailyStats) -- `tasks.list` filtered by completedAt today (for DailyStats) - ---- - -## 2. Briefing Feature - -### Current State - -Files: -- `src/renderer/features/briefing/api/useBriefing.ts` -- `src/renderer/features/briefing/index.ts` -- No `components/` folder visible in file listing - -The briefing feature has API hooks but no UI components. It's effectively invisible. - -### What the Briefing Should Be - -A "morning briefing" view that the user sees when they open the app each morning: -- AI-generated summary of: - - What's on the agenda today (planner) - - Pending tasks / in-progress agents - - GitHub notifications summary (if connected) - - Any overdue milestones -- Dismissible with a "Got it, let's go" button -- Shown once per day (or on first open after midnight) - -### Files to Create - -``` -src/renderer/features/briefing/components/BriefingPage.tsx -src/renderer/features/briefing/components/BriefingSection.tsx -src/renderer/features/briefing/hooks/useBriefingTrigger.ts -``` - -### Files to Modify - -``` -src/renderer/features/briefing/api/useBriefing.ts — verify hooks are correct -src/renderer/app/layouts/AppLayout.tsx — trigger briefing check on mount -``` - -### IPC Channels to Verify - -- `briefing.generate` — triggers server-side briefing generation -- `briefing.dismiss` — marks today's briefing as seen -- `briefing.getLatest` — retrieves current briefing data - ---- - -## 3. Keyboard Shortcuts - -### Current State - -`src/shared/ipc/misc/hotkeys.contract.ts` exists — hotkeys IPC contract is defined. Need to verify if any global shortcuts are registered. - -### Target Shortcuts - -| Shortcut | Action | -|----------|--------| -| `Cmd/Ctrl + K` | Open assistant widget | -| `Cmd/Ctrl + N` | New note (context-sensitive) | -| `Cmd/Ctrl + Shift + T` | Open task from any view | -| `Cmd/Ctrl + /` | Focus assistant input | -| `Esc` | Close modals / assistant widget | -| `G D` | Go to Dashboard | -| `G P` | Go to Planner | -| `G R` | Go to Roadmap | -| `G I` | Go to Ideation | -| `G N` | Go to Notes | - -### Implementation Approach - -Two layers: -1. **Electron main process** — register global shortcuts via `globalShortcut` for `Cmd+K` (open assistant from tray when app is minimized) -2. **Renderer** — `useEffect` with `keydown` listener on `document` for in-app navigation shortcuts - -### Files to Create/Modify - -``` -src/renderer/shared/hooks/useGlobalHotkeys.ts — registers in-app shortcuts -src/renderer/app/layouts/AppLayout.tsx — mount useGlobalHotkeys -src/main/ipc/handlers/hotkeys-handlers.ts — verify exists; add global shortcut registration -``` - ---- - -## 4. Dead Code Removal - -### Deprecated: `terminal-service.ts` - -Per CLAUDE.md: "Do NOT build on `terminal-service` or xterm.js — they are deprecated." - -``` -src/main/services/terminal/terminal-service.ts — DELETE -src/shared/ipc/terminals/ — verify if still referenced; if not, DELETE -src/renderer/features/terminals/ — verify if still referenced; if not, DELETE -``` - -**Verification before deletion:** -```bash -grep -r "terminal-service" src/ --include="*.ts" --include="*.tsx" -grep -r "terminals" src/ --include="*.ts" --include="*.tsx" -``` - -Only delete if the count of references is zero after accounting for the files themselves. - -### Deprecated Types in `hub-protocol.ts` - -Per memory notes: `Computer*` and `DeviceAuth*` types in `hub-protocol.ts` are deprecated. Scan for usages; if zero, remove. - -### Unused Routes - -Audit `src/renderer/app/router.ts` (or equivalent) for routes that have no corresponding component or that lead to blank/stub pages. Remove or stub with "Coming soon" if the route is needed for navigation. - -### Files to Audit - -``` -src/main/services/terminal/terminal-service.ts -src/shared/ipc/terminals/ -src/renderer/features/terminals/ -src/shared/types/hub-protocol.ts (Computer*, DeviceAuth* types) -src/renderer/app/router/ (unused routes) -``` - ---- - -## 5. Navigation Cleanup - -### Header Pattern Inconsistency - -Currently each feature page has its own header with different styling: -- `RoadmapPage` uses `<Map>` icon + `<h1 class="text-2xl font-bold">` -- `FitnessPage` uses `<h1 class="text-foreground text-2xl font-bold">` + subtitle -- `PlannerPage` uses `<header>` tag with icon + h1 - -Target: standardize to a `PageHeader` component: - -```typescript -interface PageHeaderProps { - icon: React.ComponentType<{ className?: string }>; - title: string; - subtitle?: string; - actions?: React.ReactNode; -} -``` - -**File to create:** `src/renderer/shared/components/PageHeader.tsx` - -**Files to modify (use PageHeader):** -``` -src/renderer/features/roadmap/components/RoadmapPage.tsx -src/renderer/features/planner/components/PlannerPage.tsx -src/renderer/features/fitness/components/FitnessPage.tsx -src/renderer/features/ideation/components/IdeationPage.tsx -src/renderer/features/communications/components/CommunicationsPage.tsx -``` - ---- - -## 6. Sidebar Improvements - -### Current State - -Need to read `src/renderer/app/layouts/Sidebar.tsx` (or equivalent) to understand what's there. Known issues from prior audit: -- Logout button exists in `UserMenu.tsx` in the sidebar footer — this is correct -- Active nav item highlighting — need to verify it uses TanStack Router's active state -- No badge counts on sidebar items (e.g., "3 active agents", "5 unread notifications") - -### Target Improvements - -- Badge counts on: - - Tasks: number of in-progress agent tasks - - GitHub: number of unread notifications - - Alerts: number of active (unacknowledged) alerts -- Sidebar collapse animation (if not already present) -- Active route highlighting using TanStack Router `<Link>` `activeProps` - -### Files to Modify - -``` -src/renderer/app/layouts/Sidebar.tsx (or wherever nav links live) -src/renderer/app/layouts/AppLayout.tsx -``` - ---- - -## Files Full List - -### Create -``` -src/renderer/shared/components/PageHeader.tsx -src/renderer/features/briefing/components/BriefingPage.tsx -src/renderer/features/briefing/components/BriefingSection.tsx -src/renderer/features/briefing/hooks/useBriefingTrigger.ts -src/renderer/shared/hooks/useGlobalHotkeys.ts -``` - -### Modify -``` -src/renderer/features/dashboard/components/TodayView.tsx -src/renderer/features/dashboard/components/DailyStats.tsx -src/renderer/features/dashboard/components/RecentProjects.tsx -src/renderer/features/dashboard/components/GreetingHeader.tsx -src/renderer/features/dashboard/components/QuickCapture.tsx -src/renderer/features/briefing/api/useBriefing.ts -src/renderer/app/layouts/AppLayout.tsx -src/renderer/app/layouts/Sidebar.tsx -src/renderer/features/roadmap/components/RoadmapPage.tsx (PageHeader) -src/renderer/features/planner/components/PlannerPage.tsx (PageHeader) -src/renderer/features/fitness/components/FitnessPage.tsx (PageHeader) -src/renderer/features/ideation/components/IdeationPage.tsx (PageHeader) -``` - -### Delete (after verification) -``` -src/main/services/terminal/terminal-service.ts -src/shared/ipc/terminals/* (if no remaining references) -src/renderer/features/terminals/* (if no remaining references) -``` - ---- - -## IPC Changes - -None new. This plan only: -- Verifies existing IPC channels are implemented (`planner.getDay`, `projects.list`, `briefing.*`) -- Removes dead IPC contracts if their handlers are also removed - ---- - -## Risk / Complexity - -| Risk | Level | Notes | -|------|-------|-------| -| Deleting terminal code that is referenced | Medium | Must grep before deleting — CLAUDE.md says deprecated but file may still have imports | -| Briefing UI design | Low | Keep it simple: text sections + dismiss button | -| Keyboard shortcut conflicts with OS | Low | Test `Cmd+K` on macOS — may conflict with browser shortcuts in webview | -| Dashboard widget data not showing | Medium | Several widgets may need IPC handler verification | -| PageHeader rollout scope | Low | Large surface area but mechanical — just swap the header section | - ---- - -## Out of Scope - -- Electron tray icon changes -- Auto-update flow changes -- Onboarding flow -- Theme customization -- Multi-window support -- Any new data features not listed above - ---- - -## Dependencies - -- Plans 1-4 should be complete -- No new dependencies on external packages -- Briefing IPC handlers must exist before building `BriefingPage` diff --git a/docs/plans/2026-04-04-adc-brand-suite.md b/docs/plans/2026-04-04-adc-brand-suite.md deleted file mode 100644 index 237c6feb..00000000 --- a/docs/plans/2026-04-04-adc-brand-suite.md +++ /dev/null @@ -1,713 +0,0 @@ -# ADC Brand Suite Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace ADC's placeholder brand assets with a production-ready brand suite: a Pinyon Script "ADC" monogram badge as the master SVG, all platform icon sizes (ICO/ICNS/PNG), updated teal design tokens, and Geist typography — all generated from a single SVG source file. - -**Architecture:** A Node.js script (`scripts/generate-logo-svg.js`) uses `opentype.js` to convert Pinyon Script glyphs to pure SVG path geometry, composes the badge mark with teal flourish, then `icon-gen` rasterizes it to every required size. Design tokens in `globals.css` are updated in-place to replace the boilerplate yellow-green palette with the ADC dark + teal system. Geist fonts are installed via `@fontsource-variable`. - -**Tech Stack:** opentype.js, svgo, icon-gen, @fontsource-variable/geist, @fontsource-variable/geist-mono, Node.js ESM scripts - ---- - -## File Map - -| Action | File | Responsibility | -|--------|------|---------------| -| Create | `scripts/fonts/PinyonScript-Regular.ttf` | Font source for opentype.js path extraction | -| Create | `scripts/generate-logo-svg.js` | Converts Pinyon Script glyphs → SVG paths, composes badge mark | -| Create | `scripts/build-icons.js` | Runs icon-gen pipeline: SVG → ICO + ICNS + all PNG sizes | -| Replace | `resources/icon.svg` | Master ADC brand mark (output of generate-logo-svg.js) | -| Replace | `resources/icon.png` + all sizes | Auto-generated by build-icons.js | -| Replace | `resources/icon.ico` | Auto-generated by build-icons.js | -| Create | `resources/icon.icns` | Auto-generated by build-icons.js (was missing) | -| Modify | `package.json` | Add devDeps + `icons:generate` script + mac icns path | -| Modify | `src/renderer/styles/globals.css` | Replace boilerplate colors + update font-family to Geist | -| Modify | `src/main/tray/tray-manager.ts` | Update STATUS_COLORS.idle from yellow-green to teal | -| Create | `brand/logo-badge.svg` | Badge-only export for website/marketing | -| Create | `brand/logo-full.svg` | Badge + wordmark lockup for website header | - ---- - -## Task 1: Install Dependencies + Download Font - -**Files:** -- Modify: `package.json` -- Create: `scripts/fonts/PinyonScript-Regular.ttf` - -- [ ] **Step 1: Install toolchain devDependencies** - -```bash -npm install --save-dev opentype.js svgo icon-gen -``` - -Expected output: 3 packages added, no errors. - -- [ ] **Step 2: Install Geist variable fonts** - -```bash -npm install @fontsource-variable/geist @fontsource-variable/geist-mono -``` - -Expected output: 2 packages added. - -- [ ] **Step 3: Create fonts directory** - -```bash -mkdir -p scripts/fonts -``` - -- [ ] **Step 4: Download Pinyon Script font** - -Go to https://fonts.google.com/specimen/Pinyon+Script, click "Download family", extract the ZIP, and copy `PinyonScript-Regular.ttf` into `scripts/fonts/`. - -Verify: -```bash -ls scripts/fonts/ -# Expected: PinyonScript-Regular.ttf -``` - -- [ ] **Step 5: Add generate script to package.json** - -In `package.json`, inside the `"scripts"` block, add after `"verify"`: - -```json -"icons:generate": "node scripts/generate-logo-svg.js && svgo resources/icon.svg -o resources/icon-optimized.svg && node scripts/build-icons.js" -``` - -- [ ] **Step 6: Update mac icon path in package.json build config to include icns** - -Find this block in `package.json`: -```json -"mac": { - "icon": "resources/icon.png", -``` - -Replace with: -```json -"mac": { - "icon": "resources/icon.icns", -``` - -- [ ] **Step 7: Commit** - -```bash -git add package.json package-lock.json scripts/fonts/PinyonScript-Regular.ttf -git commit -m "chore(brand): install icon toolchain and Pinyon Script font" -``` - ---- - -## Task 2: Write Logo SVG Generation Script - -**Files:** -- Create: `scripts/generate-logo-svg.js` - -- [ ] **Step 1: Create the script** - -Create `scripts/generate-logo-svg.js` with this exact content: - -```javascript -/** - * generate-logo-svg.js - * - * Converts "ADC" in Pinyon Script to pure SVG path geometry using opentype.js. - * Composes the squircle badge mark with teal signature flourish. - * - * Output: - * resources/icon.svg — master brand mark (used by build-icons.js) - * brand/logo-badge.svg — same mark for website/marketing use - */ - -import opentype from 'opentype.js' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const ROOT = path.join(__dirname, '..') - -const SIZE = 1024 -// Squircle corner radius — 22% of size gives the right badge feel -const CORNER_RADIUS = Math.round(SIZE * 0.22) -// Font size as a ratio of badge size — tune this if text feels too large/small -const FONT_SIZE_RATIO = 0.38 - -async function generateMark() { - const fontPath = path.join(__dirname, 'fonts', 'PinyonScript-Regular.ttf') - - if (!fs.existsSync(fontPath)) { - console.error(`✗ Font not found at ${fontPath}`) - console.error(' Download PinyonScript-Regular.ttf from Google Fonts and place it in scripts/fonts/') - process.exit(1) - } - - const font = await opentype.load(fontPath) - const fontSize = SIZE * FONT_SIZE_RATIO - - // Measure text bounding box at origin (x=0, y=0) - const measuredPath = font.getPath('ADC', 0, 0, fontSize) - const bbox = measuredPath.getBoundingBox() - const textW = bbox.x2 - bbox.x1 - const textH = bbox.y2 - bbox.y1 - - // Center text in badge. Nudge upward by 5% of text height because - // Pinyon Script descenders pull the optical center down. - const offsetX = (SIZE - textW) / 2 - bbox.x1 - const offsetY = (SIZE - textH) / 2 - bbox.y1 - textH * 0.05 - - const finalPath = font.getPath('ADC', offsetX, offsetY, fontSize) - const pathData = finalPath.toPathData(2) - const finalBbox = finalPath.getBoundingBox() - - // Teal flourish: 55% of text width, centered, 1.8% of badge size below baseline - const flourishW = (finalBbox.x2 - finalBbox.x1) * 0.55 - const flourishX = (SIZE - flourishW) / 2 - const flourishY = finalBbox.y2 + SIZE * 0.018 - const flourishH = Math.round(SIZE * 0.006) // 6px at 1024px - const flourishR = flourishH / 2 - - const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SIZE} ${SIZE}" width="${SIZE}" height="${SIZE}"> - <!-- Squircle badge background --> - <rect width="${SIZE}" height="${SIZE}" rx="${CORNER_RADIUS}" fill="#141416"/> - <!-- Subtle border --> - <rect width="${SIZE}" height="${SIZE}" rx="${CORNER_RADIUS}" fill="none" stroke="#2A2A2E" stroke-width="3"/> - <!-- ADC lettermark — Pinyon Script converted to pure path geometry, no font runtime dependency --> - <path d="${pathData}" fill="#F0F0F5"/> - <!-- Teal signature flourish — the single color moment in the mark --> - <rect - x="${flourishX.toFixed(1)}" - y="${flourishY.toFixed(1)}" - width="${flourishW.toFixed(1)}" - height="${flourishH}" - rx="${flourishR}" - fill="#2DD4BF" - /> -</svg>`.trim() - - // Write master mark - const iconOut = path.join(ROOT, 'resources', 'icon.svg') - fs.writeFileSync(iconOut, svg) - console.log(`✓ resources/icon.svg`) - - // Write brand folder copy - fs.mkdirSync(path.join(ROOT, 'brand'), { recursive: true }) - const badgeOut = path.join(ROOT, 'brand', 'logo-badge.svg') - fs.writeFileSync(badgeOut, svg) - console.log(`✓ brand/logo-badge.svg`) -} - -generateMark().catch((error) => { - console.error('✗ Generation failed:', error.message) - process.exit(1) -}) -``` - -- [ ] **Step 2: Run it to verify it works** - -```bash -node scripts/generate-logo-svg.js -``` - -Expected output: -``` -✓ resources/icon.svg -✓ brand/logo-badge.svg -``` - -- [ ] **Step 3: Open resources/icon.svg in a browser to visually inspect the mark** - -Open the file in any browser. You should see a dark squircle badge with "ADC" in flowing Pinyon Script letterform (white) and a teal horizontal bar beneath. - -If the text feels too large or too small, adjust `FONT_SIZE_RATIO` (line 22): -- Too large → decrease (try 0.34) -- Too small → increase (try 0.42) - -Re-run `node scripts/generate-logo-svg.js` after any adjustment and refresh the browser. - -- [ ] **Step 4: Commit** - -```bash -git add scripts/generate-logo-svg.js resources/icon.svg brand/logo-badge.svg -git commit -m "feat(brand): generate ADC Pinyon Script monogram badge SVG" -``` - ---- - -## Task 3: Build All Icon Sizes - -**Files:** -- Create: `scripts/build-icons.js` -- Replace: `resources/icon.png`, `resources/icon.ico`, `resources/icon.icns`, `resources/icon-*.png` - -- [ ] **Step 1: Create build-icons.js** - -Create `scripts/build-icons.js`: - -```javascript -/** - * build-icons.js - * - * Reads resources/icon-optimized.svg (output of svgo step in icons:generate script) - * and uses icon-gen to produce: - * - resources/icon.ico (Windows, multi-size) - * - resources/icon.icns (macOS, multi-size) - * - resources/icon-<N>.png (PNG at each required size) - * - * Run via: npm run icons:generate (which runs svgo first) - */ - -import { generate } from 'icon-gen' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const ROOT = path.join(__dirname, '..') - -const INPUT = path.join(ROOT, 'resources', 'icon-optimized.svg') -const OUTPUT = path.join(ROOT, 'resources') - -if (!fs.existsSync(INPUT)) { - console.error(`✗ Optimized SVG not found at ${INPUT}`) - console.error(' Run: svgo resources/icon.svg -o resources/icon-optimized.svg') - process.exit(1) -} - -const PNG_SIZES = [16, 32, 48, 64, 128, 256, 512, 1024] - -await generate(INPUT, OUTPUT, { - report: true, - ico: { - name: 'icon', - sizes: [16, 24, 32, 48, 64, 128, 256], - }, - icns: { - name: 'icon', - sizes: [16, 32, 64, 128, 256, 512, 1024], - }, - favicon: { - name: 'icon', - pngSizes: PNG_SIZES, - }, -}) - -// icon-gen outputs favicon PNGs as favicon/icon-<N>.png — rename to icon-<N>.png in resources/ -const faviconDir = path.join(OUTPUT, 'favicon') -if (fs.existsSync(faviconDir)) { - for (const size of PNG_SIZES) { - const src = path.join(faviconDir, `icon-${size}.png`) - const dest = path.join(OUTPUT, `icon-${size}.png`) - if (fs.existsSync(src)) { - fs.renameSync(src, dest) - } - } - // Copy largest as the default icon.png (used by linux build) - fs.copyFileSync(path.join(OUTPUT, 'icon-512.png'), path.join(OUTPUT, 'icon.png')) - fs.rmdirSync(faviconDir) -} - -console.log('✓ All icon assets generated in resources/') -``` - -- [ ] **Step 2: Run the full pipeline** - -```bash -npm run icons:generate -``` - -This runs: -1. `node scripts/generate-logo-svg.js` — generates `resources/icon.svg` -2. `svgo resources/icon.svg -o resources/icon-optimized.svg` — optimizes it -3. `node scripts/build-icons.js` — produces all sizes - -Expected output ends with: -``` -✓ All icon assets generated in resources/ -``` - -- [ ] **Step 3: Verify all required files exist** - -```bash -ls resources/icon*.png resources/icon.ico resources/icon.icns -``` - -Expected — all these files should be listed: -``` -resources/icon-16.png -resources/icon-32.png -resources/icon-48.png -resources/icon-64.png -resources/icon-128.png -resources/icon-256.png -resources/icon-512.png -resources/icon-1024.png -resources/icon.png -resources/icon.ico -resources/icon.icns -``` - -- [ ] **Step 4: Visually verify at key sizes** - -Open `resources/icon-32.png` and `resources/icon-256.png` in any image viewer. At 256px the script should be clearly legible. At 32px the mark should still read as a badge with letters, even if detail is lost. - -- [ ] **Step 5: Commit** - -```bash -git add scripts/build-icons.js resources/ -git commit -m "feat(brand): generate all platform icon sizes from ADC SVG mark" -``` - ---- - -## Task 4: Update Tray Manager Colors - -**Files:** -- Modify: `src/main/tray/tray-manager.ts:102-106` - -The tray currently draws colored circles for status using the old boilerplate yellow-green for idle. Replace with ADC teal. - -- [ ] **Step 1: Update STATUS_COLORS** - -In `src/main/tray/tray-manager.ts`, find: - -```typescript -const STATUS_COLORS: Record<TrayStatus, { r: number; g: number; b: number }> = { - idle: { r: 214, g: 216, b: 118 }, - working: { r: 59, g: 130, b: 246 }, - notification: { r: 239, g: 68, b: 68 }, -}; -``` - -Replace with: - -```typescript -const STATUS_COLORS: Record<TrayStatus, { r: number; g: number; b: number }> = { - idle: { r: 45, g: 212, b: 191 }, // #2DD4BF — Command teal - working: { r: 45, g: 212, b: 191 }, // #2DD4BF — same teal (pulsing handled in UI) - notification: { r: 239, g: 68, b: 68 }, // #EF4444 — red, unchanged -}; -``` - -- [ ] **Step 2: Also update the fallback color in loadResourceIcon** - -In `src/main/tray/tray-manager.ts`, find: - -```typescript - return createStatusIcon({ r: 214, g: 216, b: 118 }); // primary color -``` - -Replace with: - -```typescript - return createStatusIcon({ r: 45, g: 212, b: 191 }); // Command teal -``` - -- [ ] **Step 3: Run typecheck to verify no errors** - -```bash -npm run typecheck -``` - -Expected: no errors. - -- [ ] **Step 4: Commit** - -```bash -git add src/main/tray/tray-manager.ts -git commit -m "feat(brand): update tray status colors from yellow-green to ADC teal" -``` - ---- - -## Task 5: Update Design Tokens (Dark Theme) - -**Files:** -- Modify: `src/renderer/styles/globals.css:248-299` - -Replace the boilerplate `.dark` CSS variable values with the ADC palette. The `@theme` Tailwind mappings (`--color-primary: var(--primary)` etc.) are already correct — only the `:root` dark values need updating. - -- [ ] **Step 1: Replace the entire .dark block** - -In `src/renderer/styles/globals.css`, find and replace the entire `.dark { ... }` block (lines ~248–299): - -```css -/* ============================================ - ADC DARK THEME - Command — near-monochrome OLED dark + teal signal - ============================================ */ -.dark { - /* Backgrounds — depth without color */ - --background: #0d0d0f; /* Abyss — app bg */ - --card: #141416; /* Void — cards, panels, icon badge */ - --popover: #1c1c1f; /* Lift — dropdowns, tooltips */ - --sidebar: #0d0d0f; /* Abyss — same as bg for flush sidebar */ - - /* Text */ - --foreground: #c4c4cc; /* Fog — primary readable text */ - --card-foreground: #c4c4cc; - --popover-foreground: #c4c4cc; - --sidebar-foreground: #f0f0f5; - - /* Primary accent — Command teal, used only as functional signal */ - --primary: #2dd4bf; /* Command — active, selected, CTA */ - --primary-foreground: #0d0d0f; - - /* Secondary surfaces */ - --secondary: #1c1c1f; /* Lift */ - --secondary-foreground: #f0f0f5; - - /* Muted */ - --muted: #1c1c1f; - --muted-foreground: #6b6b72; /* Ash — secondary labels, placeholders */ - - /* Accent (hover backgrounds, subtle highlights) */ - --accent: #1c1c1f; - --accent-foreground: #2dd4bf; - - /* Destructive */ - --destructive: #ef4444; - --destructive-foreground: #f0f0f5; - - /* Borders and inputs */ - --border: #2a2a2e; /* Rim */ - --input: #2a2a2e; - --ring: #2dd4bf; /* Command teal for focus rings */ - - /* Semantic colors — muted to not compete with Command teal */ - --success: #34d399; /* Emerald — muted green, different hue from teal */ - --success-foreground: #0d0d0f; - --success-light: #0d2a1f; - --warning: #fbbf24; /* Amber */ - --warning-foreground: #0d0d0f; - --warning-light: #1f1a0d; - --info: #60a5fa; /* Blue — clearly distinct from teal */ - --info-foreground: #0d0d0f; - --info-light: #0d1a2a; - --error: #f87171; - --error-light: #2a0d0d; - - /* Shadows — deep for OLED dark */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.7); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.8); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.9); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.95); - --shadow-focus: 0 0 0 2px rgba(45, 212, 191, 0.25); /* Command teal focus glow */ - - --radius: 12px; -} -``` - -- [ ] **Step 2: Run lint to catch any CSS issues** - -```bash -npm run lint -``` - -Expected: no new errors introduced in the CSS file (ESLint doesn't lint CSS, so this checks adjacent TS files are clean). - -- [ ] **Step 3: Start the app in dev mode and visually verify** - -```bash -npm run dev -``` - -The app should now show teal primary elements (active nav item, buttons, focus rings) instead of yellow-green. All primary surfaces should be near-black. - -- [ ] **Step 4: Commit** - -```bash -git add src/renderer/styles/globals.css -git commit -m "feat(brand): replace boilerplate yellow-green palette with ADC dark + teal tokens" -``` - ---- - -## Task 6: Install and Configure Geist Typography - -**Files:** -- Modify: `src/renderer/styles/globals.css:16-19` (font-family declarations in @theme) -- Modify: `src/renderer/main.tsx` (add font imports) - -- [ ] **Step 1: Locate the renderer entry point** - -```bash -cat src/renderer/main.tsx | head -20 -``` - -This is where font CSS imports go (before anything else renders). - -- [ ] **Step 2: Add Geist font imports to the renderer entry** - -At the very top of `src/renderer/main.tsx`, before any existing imports, add: - -```typescript -// ADC typography — Geist variable fonts (loaded before any React render) -import '@fontsource-variable/geist' -import '@fontsource-variable/geist-mono' -``` - -- [ ] **Step 3: Update font-family declarations in globals.css @theme block** - -In `src/renderer/styles/globals.css`, find: - -```css - --font-sans: - var(--app-font-sans, 'Inter'), -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - --font-serif: var(--app-font-serif, 'Georgia'), 'Times New Roman', serif; - --font-mono: var(--app-font-mono, 'JetBrains Mono'), 'Fira Code', 'SF Mono', monospace; -``` - -Replace with: - -```css - --font-sans: - 'Geist Variable', 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-serif: 'Georgia', 'Times New Roman', serif; - --font-mono: - 'Geist Mono Variable', 'Geist Mono', 'JetBrains Mono', 'Fira Code', monospace; -``` - -- [ ] **Step 4: Run typecheck** - -```bash -npm run typecheck -``` - -Expected: no errors (the imports are just CSS side-effects, no type issues). - -- [ ] **Step 5: Start dev and verify Geist is active** - -```bash -npm run dev -``` - -Open DevTools → Elements → pick any text node → check the computed `font-family`. It should resolve to `Geist Variable` or `Geist`. - -- [ ] **Step 6: Commit** - -```bash -git add src/renderer/main.tsx src/renderer/styles/globals.css -git commit -m "feat(brand): install Geist variable fonts and update typography system" -``` - ---- - -## Task 7: Create Brand Wordmark Asset - -**Files:** -- Create: `brand/logo-full.svg` - -The full lockup: badge (left) + wordmark "ADC / AGENTIC DESKTOP COMMAND" (right), used on the website header and marketing pages. - -- [ ] **Step 1: Create brand/logo-full.svg** - -This is a hand-composed SVG — the badge is referenced as an `<image>` (pointing to logo-badge.svg) and the wordmark is set in system fonts (since this is a static export, not a web page): - -```bash -cat > brand/logo-full.svg << 'EOF' -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 120" width="520" height="120"> - <!-- Badge mark (120×120) --> - <image href="./logo-badge.svg" x="0" y="0" width="120" height="120"/> - - <!-- Wordmark group — offset 16px right of badge --> - <g transform="translate(144, 0)"> - <!-- "ADC" in Snow --> - <text - x="0" - y="72" - font-family="'Geist', 'Inter', -apple-system, sans-serif" - font-size="64" - font-weight="600" - letter-spacing="-1" - fill="#F0F0F5" - >ADC</text> - - <!-- "AGENTIC DESKTOP COMMAND" in Ash --> - <text - x="2" - y="98" - font-family="'Geist', 'Inter', -apple-system, sans-serif" - font-size="12" - font-weight="400" - letter-spacing="4" - fill="#6B6B72" - >AGENTIC DESKTOP COMMAND</text> - </g> -</svg> -EOF -``` - -- [ ] **Step 2: Open brand/logo-full.svg in a browser to verify layout** - -Both the badge and the text "ADC / AGENTIC DESKTOP COMMAND" should be visible and aligned. If the "ADC" wordmark is misaligned vertically, adjust the `y="72"` value on that text element. - -- [ ] **Step 3: Commit** - -```bash -git add brand/ -git commit -m "feat(brand): add logo-full wordmark lockup SVG" -``` - ---- - -## Task 8: Build Verification - -- [ ] **Step 1: Run full lint + typecheck** - -```bash -npm run lint && npm run typecheck -``` - -Expected: both pass with no errors on modified files. - -- [ ] **Step 2: Run production build** - -```bash -npm run build -``` - -Expected: compiles successfully. The `resources/` icons are static files — electron-builder packages them as-is. - -- [ ] **Step 3: Verify electron-builder sees the icons** - -```bash -ls -la resources/icon.ico resources/icon.icns resources/icon.png -``` - -All three should exist and be non-zero size. - -- [ ] **Step 4: Start dev and do a final visual check** - -```bash -npm run dev -``` - -Confirm: -- App window titlebar / taskbar icon shows ADC mark (not the old "C") -- System tray icon shows teal circle (not yellow-green) -- All primary UI elements (active nav, buttons, focus rings) are teal not yellow-green -- Font in the UI is Geist (thinner, more refined than Inter — check DevTools) - -- [ ] **Step 5: Final commit** - -```bash -git add -A -git commit -m "feat(brand): ADC brand suite complete — Pinyon Script mark, teal tokens, Geist typography" -``` - ---- - -## Notes for the Implementer - -**If icon-gen's favicon output structure differs from expected:** -The `brand/` subdirectory rename logic in `build-icons.js` assumes icon-gen v5 outputs `favicon/icon-<N>.png`. If you get different filenames, adjust the rename loop in `build-icons.js` to match the actual output structure. Run `node scripts/build-icons.js` alone (after running svgo manually) to inspect the raw output. - -**If the SVG text feels wrong at small sizes:** -At 16–32px the full script will be illegible. That's expected — `tray-manager.ts` uses `createStatusIcon()` to draw a solid teal circle rather than loading the PNG, so the tray is handled separately. The PNG `icon-16.png` is only used as the initial tray fallback (line 87 of tray-manager.ts). - -**If opentype.js getBoundingBox() returns unexpected values:** -Pinyon Script has dramatic ascenders and descenders. If the text is clipped by the badge boundary, decrease `FONT_SIZE_RATIO` in `generate-logo-svg.js` from `0.38` to `0.32` and re-run. - -**Light theme:** -The `:root` (light theme) in `globals.css` is intentionally left unchanged. ADC is a dark-mode application — the light theme exists as a fallback only and is not part of the brand rollout. diff --git a/docs/plans/2026-04-08-command-bus-implementation.md b/docs/plans/2026-04-08-command-bus-implementation.md deleted file mode 100644 index 02a080fb..00000000 --- a/docs/plans/2026-04-08-command-bus-implementation.md +++ /dev/null @@ -1,2732 +0,0 @@ -# Command Bus Implementation Plan - -> **Note (2026-04):** This plan is historical. References to `HUB_TASKS` channels, `TaskRepository`, `TaskService`, and `.adc/specs/` describe the old file-based task system. The task system has since been replaced by `ProgressService` backed by a SQLite `progress_tasks` table, with `PROGRESS` channel constants. - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace scattered JSON stores, in-memory session maps, and dual agent spawn paths with a unified command bus backed by SQLite. - -**Architecture:** A command bus wraps the existing IPC router, adding tracking (SQLite), source attribution, and session lifecycle management. Channel constants eliminate all hardcoded strings. The bus delegates spawning to the surviving agent-manager service. Deprecated systems (agent-orchestrator, task-launcher, crash-recovery stub, progress-watcher-v2) are deleted. - -**Tech Stack:** better-sqlite3 v12, drizzle-orm, ULID, TypeScript strict - -**Spec:** `docs/specs/2026-04-08-command-bus-design.md` - ---- - -## Task Grouping - -| Group | Tasks | Description | -|-------|-------|-------------| -| A: Channel Constants | 1–4 | Builder utility, channel files, contract migration, barrel update | -| B: SQLite Infrastructure | 5–7 | Database setup, Drizzle schema, connection module | -| C: Command Bus Core | 8–10 | Bus service, IPC integration, session management | -| D: Deprecation & Rewiring | 11–13 | Rewire consumers, delete dead systems, bootstrap cleanup | -| E: Verification & Docs | 14–16 | Verify, update documentation, write Phase 2 task files | - ---- - -### Task 1: Channel Builder Utility - -**Files:** -- Create: `src/shared/ipc/channel-builder.ts` -- Test: `tests/unit/shared/channel-builder.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// tests/unit/shared/channel-builder.test.ts -import { describe, expect, it } from 'vitest'; - -import { domain, events } from '@shared/ipc/channel-builder'; - -describe('channel-builder', () => { - describe('domain()', () => { - it('builds nested channel constants from domain + verb/noun map', () => { - const EXAMPLE = domain('example', { - LIST: ['items', 'archived'], - CREATE: ['item'], - DELETE: ['item'], - }); - - expect(EXAMPLE.LIST.ITEMS).toBe('example.list.items'); - expect(EXAMPLE.LIST.ARCHIVED).toBe('example.list.archived'); - expect(EXAMPLE.CREATE.ITEM).toBe('example.create.item'); - expect(EXAMPLE.DELETE.ITEM).toBe('example.delete.item'); - }); - - it('handles single-noun verbs', () => { - const SIMPLE = domain('auth', { - LOGIN: ['user'], - }); - - expect(SIMPLE.LOGIN.USER).toBe('auth.login.user'); - }); - - it('handles hyphenated domain names', () => { - const HYPHEN = domain('agent-dashboard', { - SPAWN: ['owner'], - }); - - expect(HYPHEN.SPAWN.OWNER).toBe('agent-dashboard.spawn.owner'); - }); - - it('handles hyphenated noun names', () => { - const COMPOUND = domain('workflow', { - LIST: ['agent-defs'], - }); - - expect(COMPOUND.LIST['AGENT-DEFS']).toBe('workflow.list.agent-defs'); - }); - }); - - describe('events()', () => { - it('builds event channel constants with event: prefix', () => { - const EXAMPLE_EVENTS = events('example', { - ITEM: ['created', 'updated', 'deleted'], - }); - - expect(EXAMPLE_EVENTS.ITEM.CREATED).toBe('event:example.item.created'); - expect(EXAMPLE_EVENTS.ITEM.UPDATED).toBe('event:example.item.updated'); - expect(EXAMPLE_EVENTS.ITEM.DELETED).toBe('event:example.item.deleted'); - }); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run tests/unit/shared/channel-builder.test.ts` -Expected: FAIL — module not found - -- [ ] **Step 3: Implement channel-builder.ts** - -```typescript -// src/shared/ipc/channel-builder.ts - -/** - * Channel constant builder — eliminates all hardcoded IPC channel strings. - * - * Usage: - * const PROGRESS = domain('progress', { - * LIST: ['tasks', 'archived'], - * CREATE: ['task', 'plan'], - * }); - * // PROGRESS.LIST.TASKS = "progress.list.tasks" (literal type preserved) - */ - -type Uppercase<S extends string> = S extends `${infer F}${infer R}` - ? `${F extends Lowercase<F> ? globalThis.Uppercase<F> : F}${R extends '' ? '' : Uppercase<R>}` - : S; - -type DomainChannels<D extends string, M extends Record<string, readonly string[]>> = { - [V in keyof M & string]: { - [N in M[V][number] & string as globalThis.Uppercase<N>]: `${D}.${Lowercase<V>}.${N}` - } -}; - -type EventChannels<D extends string, M extends Record<string, readonly string[]>> = { - [V in keyof M & string]: { - [N in M[V][number] & string as globalThis.Uppercase<N>]: `event:${D}.${Lowercase<V>}.${N}` - } -}; - -export function domain<D extends string, M extends Record<string, readonly string[]>>( - d: D, - map: M, -): DomainChannels<D, M> { - const result: Record<string, Record<string, string>> = {}; - for (const [verb, nouns] of Object.entries(map)) { - const group: Record<string, string> = {}; - for (const noun of nouns as readonly string[]) { - group[noun.toUpperCase()] = `${d}.${verb.toLowerCase()}.${noun}`; - } - result[verb] = group; - } - return result as DomainChannels<D, M>; -} - -export function events<D extends string, M extends Record<string, readonly string[]>>( - d: D, - map: M, -): EventChannels<D, M> { - const result: Record<string, Record<string, string>> = {}; - for (const [verb, nouns] of Object.entries(map)) { - const group: Record<string, string> = {}; - for (const noun of nouns as readonly string[]) { - group[noun.toUpperCase()] = `event:${d}.${verb.toLowerCase()}.${noun}`; - } - result[verb] = group; - } - return result as EventChannels<D, M>; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run tests/unit/shared/channel-builder.test.ts` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/shared/ipc/channel-builder.ts tests/unit/shared/channel-builder.test.ts -git commit -m "feat(ipc): add channel constant builder utility" -``` - ---- - -### Task 2: Create All Channel Files - -**Files:** -- Create: `src/shared/ipc/<domain>/channels.ts` for each of the ~29 domains -- Create: `src/shared/ipc/misc/<domain>.channels.ts` for each misc sub-domain - -This is mechanical. Each domain gets a `channels.ts` file that uses the `domain()` and `events()` builders. The old channel string values change to the new `domain.verb.noun` format. - -**Channel naming convention (new format):** -- Invoke: `domain.verb.noun` — all lowercase, dot-separated -- Events: `event:domain.subject.past-tense` — all lowercase, dot-separated -- Verbs should be action words: `list`, `get`, `create`, `update`, `delete`, `start`, `stop`, `send`, `spawn`, etc. -- Nouns should be the resource: `task`, `session`, `user`, `config`, etc. - -**Naming decisions for anomalous channels:** - -The old naming was inconsistent. The new format normalizes everything. Key renames: - -| Old | New | Constant | -|-----|-----|----------| -| `auth.login` | `auth.login.user` | `AUTH.LOGIN.USER` | -| `auth.me` | `auth.get.user` | `AUTH.GET.USER` | -| `auth.restore` | `auth.restore.session` | `AUTH.RESTORE.SESSION` | -| `settings.get` | `settings.get.all` | `SETTINGS.GET.ALL` | -| `settings.update` | `settings.update.all` | `SETTINGS.UPDATE.ALL` | -| `app.getVersion` | `app.get.version` | `APP.GET.VERSION` | -| `app.checkClaudeAuth` | `app.check.claude-auth` | `APP.CHECK['CLAUDE-AUTH']` | -| `dashboard.captures.list` | `dashboard.list.captures` | `DASHBOARD.LIST.CAPTURES` | -| `hub.tasks.list` | `hub-tasks.list.all` | `HUB_TASKS.LIST.ALL` | -| `event:user.sessionChanged` | `event:auth.session.changed` | `AUTH_EVENTS.SESSION.CHANGED` | -| `event:project.updated` | `event:projects.project.updated` | `PROJECTS_EVENTS.PROJECT.UPDATED` | -| `event:terminal.output` | `event:terminals.terminal.output` | `TERMINALS_EVENTS.TERMINAL.OUTPUT` | -| `event:task.statusChanged` | `event:tasks.status.changed` | `TASKS_EVENTS.STATUS.CHANGED` | - -**Orchestrator channels to DELETE entirely (not migrate):** - -All `agent.*` orchestrator invoke channels and `event:agent.orchestrator.*` event channels are removed. They will not get channel constants. The handler file `agent-orchestrator-handlers.ts` is deleted in Task 12. - -Channels deleted: `agent.startPlanning`, `agent.startExecution`, `agent.killSession`, `agent.replanWithFeedback`, `agent.restartFromCheckpoint`, `agent.getOrchestratorSession`, `agent.listOrchestratorSessions`, `event:agent.orchestrator.progress`, `event:agent.orchestrator.planReady`, `event:agent.orchestrator.stopped`, `event:agent.orchestrator.error`, `event:agent.orchestrator.heartbeat`, `event:agent.orchestrator.watchdogAlert`. - -The `agents/` IPC domain folder (`src/shared/ipc/agents/`) is renamed to reflect that it no longer contains orchestrator channels. The remaining agent-dashboard channels live in their own domain folder. - -**Health domain anomaly:** The health domain uses `app.*` channel prefixes. These move under the `APP` constant alongside the actual app channels (version, updates, etc.). The `health/` IPC folder is merged into `app/`. - -- [ ] **Step 1: Create channel files for major domains** - -Create one `channels.ts` per domain folder. Below are all files to create, each with the `domain()` and `events()` calls. Every channel file follows the same pattern — import the builders, export the constants. - -File template: -```typescript -// src/shared/ipc/<domain>/channels.ts -import { domain, events } from '../channel-builder'; - -export const DOMAIN_NAME = domain('domain-name', { - VERB: ['noun1', 'noun2'], -}); - -export const DOMAIN_NAME_EVENTS = events('domain-name', { - SUBJECT: ['past-tense1', 'past-tense2'], -}); -``` - -**Complete list of channel files and their contents:** - -`src/shared/ipc/auth/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const AUTH = domain('auth', { - LOGIN: ['user'], - LOGOUT: ['user'], - REGISTER: ['user'], - REFRESH: ['token'], - GET: ['user'], - RESTORE: ['session'], -}); - -export const AUTH_EVENTS = events('auth', { - SESSION: ['changed'], -}); -``` - -`src/shared/ipc/app/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const APP = domain('app', { - GET: ['version', 'login-setting', 'update-status', 'error-log', 'error-stats', 'health-status'], - CHECK: ['claude-auth', 'github-auth', 'oauth-status', 'updates'], - SET: ['login-setting'], - LAUNCH: ['claude-auth', 'github-auth'], - DOWNLOAD: ['update'], - INSTALL: ['update'], - CLEAR: ['error-log'], - REPORT: ['renderer-error'], -}); - -export const APP_EVENTS = events('app', { - UPDATE: ['available', 'downloaded'], - ERROR: ['occurred'], - CAPACITY: ['alert'], - DATA: ['recovery'], - SERVICE: ['unhealthy'], -}); -``` - -`src/shared/ipc/assistant/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const ASSISTANT = domain('assistant', { - START: ['session'], - SEND: ['command'], - GET: ['history'], - CLEAR: ['history'], -}); - -export const ASSISTANT_EVENTS = events('assistant', { - MESSAGE: ['response', 'thinking'], - TOOL: ['executed'], - SESSION: ['autostart'], -}); -``` - -`src/shared/ipc/agent-dashboard/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const AGENT_DASHBOARD = domain('agent-dashboard', { - SPAWN: ['project-owner', 'team-lead'], - LIST: ['sessions', 'qa-sessions', 'sessions-for-task'], - GET: ['session', 'files-changed', 'tasks-for-feature', 'task', 'qa-session', 'session-log', 'git-diff'], - SEND: ['message'], - STOP: ['session'], -}); - -export const AGENT_DASHBOARD_EVENTS = events('agent-dashboard', { - SESSION: ['started', 'ended', 'status-changed'], - MESSAGE: ['received'], - TEAMMATE: ['joined', 'left'], - STREAM: ['event'], - TASK: ['updated'], - QA: ['session-updated'], -}); -``` - -`src/shared/ipc/briefing/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const BRIEFING = domain('briefing', { - GET: ['daily', 'config', 'suggestions'], - GENERATE: ['daily'], - UPDATE: ['config'], -}); - -export const BRIEFING_EVENTS = events('briefing', { - BRIEFING: ['ready'], -}); -``` - -`src/shared/ipc/claude/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const CLAUDE = domain('claude', { - SEND: ['message'], - STREAM: ['message'], - CREATE: ['conversation'], - LIST: ['conversations'], - GET: ['messages'], - CLEAR: ['conversation'], - CHECK: ['configured'], -}); - -export const CLAUDE_EVENTS = events('claude', { - STREAM: ['chunk'], -}); -``` - -`src/shared/ipc/dashboard/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const DASHBOARD = domain('dashboard', { - LIST: ['captures'], - CREATE: ['capture'], - DELETE: ['capture'], -}); - -export const DASHBOARD_EVENTS = events('dashboard', { - CAPTURE: ['changed'], -}); -``` - -`src/shared/ipc/data-management/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const DATA_MANAGEMENT = domain('data-management', { - GET: ['registry', 'usage', 'retention'], - UPDATE: ['retention'], - CLEAR: ['store'], - RUN: ['cleanup'], - EXPORT: ['data'], - IMPORT: ['data'], -}); - -export const DATA_MANAGEMENT_EVENTS = events('data-management', { - CLEANUP: ['complete'], -}); -``` - -`src/shared/ipc/docker/channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const DOCKER = domain('docker', { - GET: ['status'], - SETUP: ['hub'], -}); -``` - -`src/shared/ipc/email/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const EMAIL = domain('email', { - SEND: ['message'], - GET: ['config', 'queue'], - UPDATE: ['config'], - TEST: ['connection'], - RETRY: ['queued'], - REMOVE: ['queued'], -}); - -export const EMAIL_EVENTS = events('email', { - MESSAGE: ['sent', 'failed'], -}); -``` - -`src/shared/ipc/files/channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const FILES = domain('files', { - LIST: ['tree'], -}); -``` - -`src/shared/ipc/fitness/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const FITNESS = domain('fitness', { - LOG: ['workout', 'measurement'], - LIST: ['workouts', 'goals'], - GET: ['measurements', 'stats'], - SET: ['goal'], - UPDATE: ['goal-progress'], - DELETE: ['workout', 'goal'], -}); - -export const FITNESS_EVENTS = events('fitness', { - WORKOUT: ['changed'], - MEASUREMENT: ['changed'], - GOAL: ['changed'], -}); -``` - -`src/shared/ipc/git/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const GIT = domain('git', { - GET: ['status', 'branches', 'remote-url'], - CREATE: ['branch', 'pr', 'worktree'], - REMOVE: ['worktree'], - LIST: ['worktrees'], - COMMIT: ['changes'], - PUSH: ['changes'], - RESOLVE: ['conflict'], - DETECT: ['structure'], -}); - -export const GIT_EVENTS = events('git', { - WORKTREE: ['changed'], -}); -``` - -`src/shared/ipc/github/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const GITHUB = domain('github', { - LIST: ['prs', 'issues', 'repos'], - GET: ['pr', 'notifications', 'auth-status'], - CREATE: ['issue'], -}); - -export const GITHUB_EVENTS = events('github', { - DATA: ['updated'], -}); -``` - -`src/shared/ipc/hub/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const HUB = domain('hub', { - CONNECT: ['server'], - DISCONNECT: ['server'], - GET: ['status', 'config'], - SYNC: ['data'], - REMOVE: ['config'], -}); - -export const HUB_EVENTS = events('hub', { - CONNECTION: ['changed'], - SYNC: ['completed'], - DEVICE: ['online', 'offline'], - WORKSPACE: ['updated'], - PROJECT: ['updated'], -}); -``` - -`src/shared/ipc/notifications/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const NOTIFICATIONS = domain('notifications', { - LIST: ['all'], - MARK: ['read', 'all-read'], - GET: ['config', 'watcher-status'], - UPDATE: ['config'], - START: ['watching'], - STOP: ['watching'], -}); - -export const NOTIFICATIONS_EVENTS = events('notifications', { - NOTIFICATION: ['new'], - WATCHER: ['error', 'status-changed'], -}); -``` - -`src/shared/ipc/oauth/channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const OAUTH = domain('oauth', { - AUTHORIZE: ['provider'], - CHECK: ['authenticated'], - REVOKE: ['provider'], -}); -``` - -`src/shared/ipc/planner/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const PLANNER = domain('planner', { - GET: ['day', 'week'], - UPDATE: ['day', 'weekly-reflection'], - ADD: ['time-block'], - MODIFY: ['time-block'], - REMOVE: ['time-block'], - GENERATE: ['weekly-review'], -}); - -export const PLANNER_EVENTS = events('planner', { - DAY: ['changed'], -}); -``` - -`src/shared/ipc/progress/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const PROGRESS = domain('progress', { - LIST: ['tasks', 'archived'], - GET: ['task'], - CREATE: ['task', 'plan'], - UPDATE: ['task'], - DELETE: ['task'], - ARCHIVE: ['task'], - START: ['research', 'team', 'workflow'], - CANCEL: ['action'], - RUN: ['log-cleanup'], -}); - -export const PROGRESS_EVENTS = events('progress', { - TASK: ['updated', 'created', 'archived'], - ACTION: ['started', 'completed', 'failed'], - WORKFLOW: ['step'], -}); -``` - -`src/shared/ipc/projects/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const PROJECTS = domain('projects', { - LIST: ['all'], - ADD: ['project'], - REMOVE: ['project'], - INITIALIZE: ['project'], - SELECT: ['directory'], - DETECT: ['repo'], - UPDATE: ['project'], - GET: ['sub-projects'], - CREATE: ['sub-project', 'new'], - DELETE: ['sub-project'], - SETUP: ['existing'], - ANALYZE: ['codebase'], -}); - -export const PROJECTS_EVENTS = events('projects', { - PROJECT: ['updated'], - SETUP: ['progress'], -}); -``` - -`src/shared/ipc/qa/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const QA = domain('qa', { - START: ['quiet', 'full'], - GET: ['report', 'session'], - CANCEL: ['session'], -}); - -export const QA_EVENTS = events('qa', { - SESSION: ['started', 'progress', 'completed'], -}); -``` - -`src/shared/ipc/security/channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const SECURITY = domain('security', { - GET: ['settings'], - UPDATE: ['settings'], - EXPORT: ['audit'], -}); -``` - -`src/shared/ipc/settings/channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const SETTINGS = domain('settings', { - GET: ['all', 'profiles', 'oauth-providers', 'webhook-config', 'agent-settings', 'layout'], - UPDATE: ['all', 'profile', 'webhook-config'], - CREATE: ['profile'], - DELETE: ['profile'], - SET: ['default-profile', 'oauth-provider', 'agent-settings'], - SAVE: ['layout'], -}); -``` - -`src/shared/ipc/spotify/channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const SPOTIFY = domain('spotify', { - GET: ['playback'], - PLAY: ['track'], - PAUSE: ['track'], - SKIP: ['next', 'previous'], - SEARCH: ['tracks'], - SET: ['volume'], - ADD: ['to-queue'], -}); -``` - -`src/shared/ipc/tasks/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const TASKS = domain('tasks', { - LIST: ['all', 'every'], - GET: ['task'], - CREATE: ['task'], - UPDATE: ['task', 'status'], - DELETE: ['task'], - EXECUTE: ['task'], - DECOMPOSE: ['task'], - IMPORT: ['github-issues'], - LIST_GITHUB: ['issues'], -}); - -export const HUB_TASKS = domain('hub-tasks', { - LIST: ['all'], - GET: ['task'], - CREATE: ['task'], - UPDATE: ['task', 'status'], - DELETE: ['task'], - EXECUTE: ['task'], - CANCEL: ['task'], -}); - -export const TASKS_EVENTS = events('tasks', { - STATUS: ['changed'], - PROGRESS: ['updated'], - LOG: ['appended'], - PLAN: ['updated'], -}); - -export const HUB_TASKS_EVENTS = events('hub-tasks', { - TASK: ['created', 'updated', 'deleted'], - PROGRESS: ['updated'], - TASK_RUN: ['completed'], -}); -``` - -`src/shared/ipc/terminals/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const TERMINALS = domain('terminals', { - LIST: ['all'], - CREATE: ['session'], - CLOSE: ['session'], - SEND: ['input'], - RESIZE: ['session'], - INVOKE: ['claude-cli'], -}); - -export const TERMINALS_EVENTS = events('terminals', { - TERMINAL: ['output', 'closed', 'title-changed'], -}); -``` - -`src/shared/ipc/tracker/channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const TRACKER = domain('tracker', { - LIST: ['all'], - GET: ['plan'], - UPDATE: ['plan'], -}); -``` - -`src/shared/ipc/visualization/channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const VISUALIZATION = domain('visualization', { - GET: ['codebase-graph', 'agent-teams', 'session-log'], -}); -``` - -`src/shared/ipc/window/channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const WINDOW = domain('window', { - MINIMIZE: ['app'], - MAXIMIZE: ['app'], - CLOSE: ['app'], - CHECK: ['maximized'], -}); -``` - -`src/shared/ipc/workflow/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const WORKFLOW = domain('workflow', { - WATCH: ['progress'], - STOP: ['watching', 'running'], - LAUNCH: ['workflow'], - CHECK: ['running'], -}); - -export const WORKFLOW_EVENTS = events('workflow', { - WORKFLOW: ['milestone', 'context', 'permission'], -}); -``` - -`src/shared/ipc/workflow-engine/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const WORKFLOW_ENGINE = domain('workflow-engine', { - APPLY: ['template'], - START: ['run'], - STOP: ['run'], - GET: ['run'], - LIST: ['runs', 'archived', 'agent-defs'], -}); - -export const WORKFLOW_ENGINE_EVENTS = events('workflow-engine', { - STATE: ['changed'], - RUN: ['completed', 'error'], -}); -``` - -`src/shared/ipc/workflow-templates/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const WORKFLOW_TEMPLATES = domain('workflow-templates', { - LIST: ['all'], - GET: ['template'], - CREATE: ['template'], - UPDATE: ['template'], - DELETE: ['template'], - DUPLICATE: ['template'], - SCAN: ['artifacts'], - WRITE: ['artifact'], -}); - -export const WORKFLOW_TEMPLATES_EVENTS = events('workflow-templates', { - TEMPLATE: ['created', 'updated', 'deleted'], -}); -``` - -`src/shared/ipc/workspace/channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const WORKSPACE = domain('workspace', { - INIT: ['project', 'all-projects'], - GET: ['sessions'], - SPAWN: ['team-lead'], - STOP: ['team-lead'], - SEND: ['message'], - HANDOFF: ['plan'], - EXECUTE: ['task'], - PROVISION: ['teammate'], - TEARDOWN: ['teammate'], -}); - -export const WORKSPACE_EVENTS = events('workspace', { - SESSION: ['ready', 'crashed', 'restarted'], - PLAN: ['handed-off'], -}); -``` - -**Misc sub-domain channel files** — create `src/shared/ipc/misc/<name>.channels.ts` for each: - -`src/shared/ipc/misc/alerts.channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const ALERTS = domain('alerts', { - LIST: ['all'], - CREATE: ['alert'], - DISMISS: ['alert'], - DELETE: ['alert'], -}); - -export const ALERTS_EVENTS = events('alerts', { - ALERT: ['triggered', 'changed'], -}); -``` - -`src/shared/ipc/misc/calendar.channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const CALENDAR = domain('calendar', { - LIST: ['events'], - CREATE: ['event'], - DELETE: ['event'], -}); -``` - -`src/shared/ipc/misc/changelog.channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const CHANGELOG = domain('changelog', { - LIST: ['entries'], - ADD: ['entry'], - GENERATE: ['entry'], -}); -``` - -`src/shared/ipc/misc/devices.channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const DEVICES = domain('devices', { - LIST: ['all'], - REGISTER: ['device'], - HEARTBEAT: ['device'], - UPDATE: ['device'], -}); -``` - -`src/shared/ipc/misc/hotkeys.channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const HOTKEYS = domain('hotkeys', { - GET: ['config'], - UPDATE: ['config'], - RESET: ['config'], -}); -``` - -`src/shared/ipc/misc/ideas.channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const IDEAS = domain('ideas', { - LIST: ['all'], - CREATE: ['idea'], - UPDATE: ['idea'], - DELETE: ['idea'], - VOTE: ['idea'], -}); - -export const IDEAS_EVENTS = events('ideas', { - IDEA: ['changed'], -}); -``` - -`src/shared/ipc/misc/insights.channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const INSIGHTS = domain('insights', { - GET: ['metrics', 'time-series', 'task-distribution', 'project-breakdown'], -}); -``` - -`src/shared/ipc/misc/mcp.channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const MCP = domain('mcp', { - CALL: ['tool'], - LIST: ['connected'], - GET: ['connection-state'], -}); -``` - -`src/shared/ipc/misc/merge.channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const MERGE = domain('merge', { - PREVIEW: ['diff'], - GET: ['file-diff'], - CHECK: ['conflicts'], - EXECUTE: ['merge'], - ABORT: ['merge'], -}); -``` - -`src/shared/ipc/misc/milestones.channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const MILESTONES = domain('milestones', { - LIST: ['all'], - CREATE: ['milestone'], - UPDATE: ['milestone'], - DELETE: ['milestone'], - ADD: ['task'], - TOGGLE: ['task'], -}); - -export const MILESTONES_EVENTS = events('milestones', { - MILESTONE: ['changed'], -}); -``` - -`src/shared/ipc/misc/notes.channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const NOTES = domain('notes', { - LIST: ['all'], - CREATE: ['note'], - UPDATE: ['note'], - DELETE: ['note'], - SEARCH: ['notes'], -}); - -export const NOTES_EVENTS = events('notes', { - NOTE: ['changed'], -}); -``` - -`src/shared/ipc/misc/screen.channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const SCREEN = domain('screen', { - LIST: ['sources'], - CAPTURE: ['screen'], - CHECK: ['permission'], -}); -``` - -`src/shared/ipc/misc/time.channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const TIME = domain('time', { - PARSE: ['expression'], -}); -``` - -`src/shared/ipc/misc/voice.channels.ts`: -```typescript -import { domain, events } from '../channel-builder'; - -export const VOICE = domain('voice', { - GET: ['config'], - UPDATE: ['config'], - CHECK: ['permission'], -}); - -export const VOICE_EVENTS = events('voice', { - SPEECH: ['transcript'], -}); -``` - -`src/shared/ipc/misc/webhook.channels.ts`: -```typescript -import { events } from '../channel-builder'; - -export const WEBHOOK_EVENTS = events('webhook', { - COMMAND: ['received'], -}); -``` - -`src/shared/ipc/misc/workspaces.channels.ts`: -```typescript -import { domain } from '../channel-builder'; - -export const WORKSPACES = domain('workspaces', { - LIST: ['all'], - CREATE: ['workspace'], - UPDATE: ['workspace'], - DELETE: ['workspace'], -}); -``` - -- [ ] **Step 2: Verify all channel files compile** - -Run: `npx tsc --noEmit src/shared/ipc/*/channels.ts src/shared/ipc/misc/*.channels.ts` -Expected: No errors - -- [ ] **Step 3: Commit** - -```bash -git add src/shared/ipc/ -git commit -m "feat(ipc): add channel constant files for all 29+ domains" -``` - ---- - -### Task 3: Update All Contracts to Use Channel Constants - -**Files:** -- Modify: Every `contract.ts` file under `src/shared/ipc/` and `src/shared/ipc/misc/` -- Delete: `src/shared/ipc/agents/contract.ts` (orchestrator channels) — replaced by agent-dashboard only - -For each contract file: -1. Import the channel constants from the sibling `channels.ts` -2. Replace every hardcoded string key with the constant reference -3. The contract object keys become computed property names: `[PROGRESS.CREATE.TASK]: { ... }` - -- [ ] **Step 1: Update each contract file** - -Pattern — before: -```typescript -export const progressInvoke = { - 'progress.listTasks': { input: z.object({}), output: z.array(progressTaskSchema) }, - 'progress.createTask': { input: createInput, output: progressTaskSchema }, -}; -``` - -After: -```typescript -import { PROGRESS } from './channels'; - -export const progressInvoke = { - [PROGRESS.LIST.TASKS]: { input: z.object({}), output: z.array(progressTaskSchema) }, - [PROGRESS.CREATE.TASK]: { input: createInput, output: progressTaskSchema }, -}; -``` - -Apply this pattern to every contract file. The Zod schemas stay exactly the same — only the keys change. - -For event contracts, same pattern: -```typescript -import { PROGRESS_EVENTS } from './channels'; - -export const progressEvents = { - [PROGRESS_EVENTS.TASK.UPDATED]: { payload: taskUpdatedPayloadSchema }, - [PROGRESS_EVENTS.TASK.CREATED]: { payload: taskCreatedPayloadSchema }, -}; -``` - -**Special: Delete the agents/contract.ts orchestrator sections.** Remove all `agent.*` and `event:agent.orchestrator.*` entries. The `agents/` IPC folder can be deleted entirely — agent-dashboard has its own folder. - -- [ ] **Step 2: Update all barrel index.ts files in each domain folder to re-export channels** - -Each domain's `index.ts` barrel should also export the channel constants: -```typescript -export { PROGRESS, PROGRESS_EVENTS } from './channels'; -``` - -- [ ] **Step 3: Run typecheck** - -Run: `npm run typecheck` -Expected: Many errors — every handler and renderer file that used the old string keys will break. This is expected and desired. Collect the error list for Task 4. - -- [ ] **Step 4: Commit** - -```bash -git add src/shared/ipc/ -git commit -m "feat(ipc): migrate all contracts to channel constants" -``` - ---- - -### Task 4: Fix All Broken References (Handlers + Renderer) - -**Files:** -- Modify: All ~43 handler files in `src/main/ipc/handlers/` -- Modify: All renderer `ipc()` calls across `src/renderer/features/` -- Modify: All `useIpcEvent()` hooks across `src/renderer/features/` -- Modify: All `router.emit()` calls in services and bootstrap -- Modify: `src/shared/ipc/index.ts` (root barrel) -- Modify: `src/shared/ipc/types.ts` (type utilities) - -This is the largest mechanical task. The approach: -1. Run `npm run typecheck` — TypeScript will list every broken reference -2. For each error: import the channel constant, replace the string literal -3. Repeat until typecheck passes - -- [ ] **Step 1: Update the root barrel `src/shared/ipc/index.ts`** - -The root barrel spreads all domain contracts into unified objects. Update it to: -- Remove the agents/orchestrator imports -- Add channel constant re-exports from each domain -- Keep the existing structure but reference the new contract shapes - -- [ ] **Step 2: Update all handler files** - -Pattern — before: -```typescript -router.handle('progress.createTask', async (input) => { ... }); -``` - -After: -```typescript -import { PROGRESS } from '@shared/ipc/progress/channels'; - -router.handle(PROGRESS.CREATE.TASK, async (input) => { ... }); -``` - -Apply to all ~43 handler files. Delete `agent-orchestrator-handlers.ts` entirely. - -- [ ] **Step 3: Update all renderer ipc() calls** - -Pattern — before: -```typescript -const tasks = await ipc('progress.listTasks', {}); -``` - -After: -```typescript -import { PROGRESS } from '@shared/ipc/progress/channels'; - -const tasks = await ipc(PROGRESS.LIST.TASKS, {}); -``` - -Search all files in `src/renderer/` for `ipc(` calls and update each one. - -- [ ] **Step 4: Update all useIpcEvent() hooks** - -Pattern — before: -```typescript -useIpcEvent('event:progress.taskUpdated', (payload) => { ... }); -``` - -After: -```typescript -import { PROGRESS_EVENTS } from '@shared/ipc/progress/channels'; - -useIpcEvent(PROGRESS_EVENTS.TASK.UPDATED, (payload) => { ... }); -``` - -- [ ] **Step 5: Update all router.emit() calls in services** - -Search `src/main/` for `router.emit(` and update each to use event constants. - -- [ ] **Step 6: Update EventBridge mappings** - -If EventBridge uses string-based event-to-query-key mappings, update those to use event constants. - -- [ ] **Step 7: Verify** - -Run: `npm run typecheck && npm run lint` -Expected: Zero errors - -- [ ] **Step 8: Commit** - -```bash -git add -A -git commit -m "refactor(ipc): replace all hardcoded channel strings with constants" -``` - ---- - -### Task 5: Install SQLite Dependencies - -**Files:** -- Modify: `package.json` -- Create: `src/main/db/` directory - -- [ ] **Step 1: Install packages** - -```bash -npm install better-sqlite3 drizzle-orm ulid -npm install -D @types/better-sqlite3 drizzle-kit -``` - -- [ ] **Step 2: Verify electron-rebuild config** - -Check that `@electron/rebuild` is already in devDependencies (confirmed: `^4.0.2`). Add a postinstall script if not present: - -```json -"postinstall": "electron-rebuild -f -w better-sqlite3" -``` - -- [ ] **Step 3: Verify better-sqlite3 works with electron-vite** - -The `externalizeDepsPlugin()` in `electron.vite.config.ts` should already handle native modules. Verify by running: - -```bash -npm run build -``` - -- [ ] **Step 4: Commit** - -```bash -git add package.json package-lock.json -git commit -m "deps: add better-sqlite3, drizzle-orm, ulid for command bus SQLite backing" -``` - ---- - -### Task 6: SQLite Database Schema & Connection - -**Files:** -- Create: `src/main/db/connection.ts` -- Create: `src/main/db/schema.ts` -- Create: `src/main/db/index.ts` -- Create: `drizzle.config.ts` (project root) -- Test: `tests/unit/services/db-connection.test.ts` - -- [ ] **Step 1: Create the Drizzle schema** - -```typescript -// src/main/db/schema.ts -import { integer, sqliteTable, text, index } from 'drizzle-orm/sqlite-core'; - -export const commands = sqliteTable('commands', { - id: text('id').primaryKey(), - channel: text('channel').notNull(), - domain: text('domain').notNull(), - verb: text('verb').notNull(), - noun: text('noun'), - isMutation: integer('is_mutation', { mode: 'boolean' }).notNull(), - sourceType: text('source_type').notNull(), - sourceId: text('source_id'), - sourceName: text('source_name'), - input: text('input', { mode: 'json' }), - output: text('output', { mode: 'json' }), - status: text('status').notNull(), - error: text('error'), - durationMs: integer('duration_ms'), - projectId: text('project_id'), - createdAt: text('created_at').notNull(), -}, (table) => [ - index('idx_commands_domain').on(table.domain), - index('idx_commands_verb').on(table.verb), - index('idx_commands_source_type').on(table.sourceType), - index('idx_commands_project_id').on(table.projectId), - index('idx_commands_created_at').on(table.createdAt), -]); - -export const sessions = sqliteTable('sessions', { - id: text('id').primaryKey(), - name: text('name').notNull(), - type: text('type').notNull(), - phase: text('phase'), - status: text('status').notNull(), - projectId: text('project_id'), - taskSlug: text('task_slug'), - model: text('model'), - pid: integer('pid'), - worktreePath: text('worktree_path'), - spawnConfig: text('spawn_config', { mode: 'json' }), - tokenUsage: text('token_usage', { mode: 'json' }), - toolUsage: text('tool_usage', { mode: 'json' }), - parentId: text('parent_id'), - teamName: text('team_name'), - wave: integer('wave'), - taskIndex: integer('task_index'), - startedAt: text('started_at').notNull(), - endedAt: text('ended_at'), - exitCode: integer('exit_code'), - error: text('error'), -}, (table) => [ - index('idx_sessions_status').on(table.status), - index('idx_sessions_type').on(table.type), - index('idx_sessions_project_id').on(table.projectId), - index('idx_sessions_task_slug').on(table.taskSlug), - index('idx_sessions_parent_id').on(table.parentId), -]); - -export const busEvents = sqliteTable('bus_events', { - id: text('id').primaryKey(), - channel: text('channel').notNull(), - payload: text('payload', { mode: 'json' }), - sourceCommandId: text('source_command_id'), - sessionId: text('session_id'), - projectId: text('project_id'), - createdAt: text('created_at').notNull(), -}, (table) => [ - index('idx_events_channel').on(table.channel), - index('idx_events_session_id').on(table.sessionId), - index('idx_events_source_command_id').on(table.sourceCommandId), - index('idx_events_created_at').on(table.createdAt), -]); -``` - -- [ ] **Step 2: Create the database connection module** - -```typescript -// src/main/db/connection.ts -import { join } from 'node:path'; - -import Database from 'better-sqlite3'; -import { drizzle } from 'drizzle-orm/better-sqlite3'; -import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; - -import * as schema from './schema'; - -let db: ReturnType<typeof drizzle> | null = null; -let sqlite: Database.Database | null = null; - -export function initDatabase(userDataPath: string): ReturnType<typeof drizzle> { - const dbPath = join(userDataPath, 'adc.db'); - sqlite = new Database(dbPath); - - sqlite.pragma('journal_mode = WAL'); - sqlite.pragma('foreign_keys = OFF'); - sqlite.pragma('busy_timeout = 5000'); - - db = drizzle(sqlite, { schema }); - - // Run migrations on startup - const migrationsPath = join(__dirname, '../../drizzle'); - migrate(db, { migrationsFolder: migrationsPath }); - - return db; -} - -export function getDatabase(): ReturnType<typeof drizzle> { - if (!db) { - throw new Error('Database not initialized — call initDatabase() first'); - } - return db; -} - -export function closeDatabase(): void { - if (sqlite) { - sqlite.close(); - sqlite = null; - db = null; - } -} - -export type AdcDatabase = ReturnType<typeof drizzle>; -``` - -- [ ] **Step 3: Create barrel** - -```typescript -// src/main/db/index.ts -export { closeDatabase, getDatabase, initDatabase } from './connection'; -export type { AdcDatabase } from './connection'; -export * from './schema'; -``` - -- [ ] **Step 4: Create drizzle.config.ts** - -```typescript -// drizzle.config.ts -import { defineConfig } from 'drizzle-kit'; - -export default defineConfig({ - dialect: 'sqlite', - schema: './src/main/db/schema.ts', - out: './drizzle', -}); -``` - -- [ ] **Step 5: Generate initial migration** - -```bash -npx drizzle-kit generate -``` - -This creates SQL files in `./drizzle/` for the initial schema. - -- [ ] **Step 6: Write test** - -```typescript -// tests/unit/services/db-connection.test.ts -import { existsSync, mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { closeDatabase, initDatabase } from '@main/db'; - -describe('database connection', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'adc-test-')); - }); - - afterEach(() => { - closeDatabase(); - rmSync(tempDir, { recursive: true, force: true }); - }); - - it('creates adc.db in the specified directory', () => { - initDatabase(tempDir); - expect(existsSync(join(tempDir, 'adc.db'))).toBe(true); - }); - - it('runs migrations without error', () => { - const db = initDatabase(tempDir); - // Verify tables exist by attempting a query - const result = db.select().from(commands).all(); - expect(result).toEqual([]); - }); -}); -``` - -- [ ] **Step 7: Run test** - -Run: `npx vitest run tests/unit/services/db-connection.test.ts` -Expected: PASS - -- [ ] **Step 8: Commit** - -```bash -git add src/main/db/ drizzle.config.ts drizzle/ tests/unit/services/db-connection.test.ts -git commit -m "feat(db): add SQLite database with Drizzle ORM schema and migrations" -``` - ---- - -### Task 7: Add Database to electron-vite Config - -**Files:** -- Modify: `electron.vite.config.ts` — ensure better-sqlite3 is externalized -- Modify: `electron-builder` config — ensure drizzle migrations are in extraResources - -- [ ] **Step 1: Verify externalization** - -Read `electron.vite.config.ts`. If `externalizeDepsPlugin()` is already used for the main process config, `better-sqlite3` is automatically externalized. No changes needed. - -If not, add `better-sqlite3` to the external list. - -- [ ] **Step 2: Add drizzle migrations to extraResources** - -In the electron-builder configuration (either `electron-builder.yml` or `package.json` build section), add: - -```json -"extraResources": ["drizzle/**"] -``` - -- [ ] **Step 3: Update connection.ts migrations path for production** - -The migrations path needs to resolve correctly in both development and production: - -```typescript -// In connection.ts, update migrationsPath: -const isDev = !app.isPackaged; -const migrationsPath = isDev - ? join(__dirname, '../../drizzle') - : join(process.resourcesPath, 'drizzle'); -``` - -- [ ] **Step 4: Verify build** - -Run: `npm run build` -Expected: Builds without error - -- [ ] **Step 5: Commit** - -```bash -git add electron.vite.config.ts package.json src/main/db/connection.ts -git commit -m "build: configure electron-vite and builder for SQLite native module" -``` - ---- - -### Task 8: Command Bus Core Service - -**Files:** -- Create: `src/main/bus/command-bus.ts` -- Create: `src/main/bus/types.ts` -- Create: `src/main/bus/index.ts` -- Test: `tests/unit/services/command-bus.test.ts` - -- [ ] **Step 1: Define bus types** - -```typescript -// src/main/bus/types.ts -import type { AdcDatabase } from '../db'; - -export interface CommandSource { - type: 'ui' | 'agent' | 'system'; - id?: string; - name?: string; -} - -export interface BusResult<T = unknown> { - commandId: string; - status: 'success' | 'error'; - output: T; - durationMs: number; - error?: string; -} - -export interface CommandFilter { - domain?: string; - verb?: string; - sourceType?: string; - projectId?: string; - since?: string; - limit?: number; -} - -export interface EventFilter { - channel?: string; - sessionId?: string; - since?: string; - limit?: number; -} - -export interface CommandRecord { - id: string; - channel: string; - domain: string; - verb: string; - noun: string | null; - isMutation: boolean; - sourceType: string; - sourceId: string | null; - sourceName: string | null; - input: unknown; - output: unknown; - status: string; - error: string | null; - durationMs: number | null; - projectId: string | null; - createdAt: string; -} - -export interface EventRecord { - id: string; - channel: string; - payload: unknown; - sourceCommandId: string | null; - sessionId: string | null; - projectId: string | null; - createdAt: string; -} - -export type CommandHandler = (input: unknown) => Promise<unknown>; - -export interface RegisteredCommand { - channel: string; - domain: string; - verb: string; - noun: string | null; - isMutation: boolean; - handler: CommandHandler; -} - -// Verbs that represent mutations (not reads) -const MUTATION_VERBS = new Set([ - 'create', 'update', 'delete', 'archive', 'add', 'remove', 'set', - 'save', 'send', 'start', 'stop', 'cancel', 'spawn', 'kill', - 'login', 'logout', 'register', 'refresh', 'restore', 'clear', - 'execute', 'launch', 'connect', 'disconnect', 'sync', 'import', - 'export', 'install', 'download', 'resolve', 'commit', 'push', - 'invoke', 'generate', 'report', 'dismiss', 'vote', 'toggle', - 'duplicate', 'write', 'apply', 'provision', 'teardown', 'handoff', - 'mark', 'retry', 'abort', 'setup', 'initialize', 'modify', - 'watch', 'log', 'reset', 'revoke', 'authorize', 'play', 'pause', - 'skip', 'resize', 'capture', -]); - -export function isMutationVerb(verb: string): boolean { - return MUTATION_VERBS.has(verb); -} - -export function parseChannel(channel: string): { domain: string; verb: string; noun: string | null } { - const parts = channel.split('.'); - if (parts.length === 3) { - return { domain: parts[0], verb: parts[1], noun: parts[2] }; - } - if (parts.length === 2) { - return { domain: parts[0], verb: parts[1], noun: null }; - } - // Handle hyphenated domains like "agent-dashboard.spawn.owner" - // or "workflow-engine.start.run" - // Try splitting from the right - const lastDot = channel.lastIndexOf('.'); - const secondLastDot = channel.lastIndexOf('.', lastDot - 1); - if (secondLastDot > 0) { - return { - domain: channel.slice(0, secondLastDot), - verb: channel.slice(secondLastDot + 1, lastDot), - noun: channel.slice(lastDot + 1), - }; - } - return { domain: channel.slice(0, lastDot), verb: channel.slice(lastDot + 1), noun: null }; -} -``` - -- [ ] **Step 2: Implement command bus** - -```typescript -// src/main/bus/command-bus.ts -import { eq, and, gte, desc } from 'drizzle-orm'; -import { ulid } from 'ulid'; - -import type { AdcDatabase } from '../db'; -import { commands, busEvents } from '../db/schema'; -import { createScopedLogger } from '../lib/logger'; - -import type { - BusResult, - CommandFilter, - CommandHandler, - CommandRecord, - CommandSource, - EventFilter, - EventRecord, - RegisteredCommand, -} from './types'; -import { isMutationVerb, parseChannel } from './types'; - -const logger = createScopedLogger('command-bus'); - -type EventHandler = (channel: string, payload: unknown) => void; - -export interface CommandBus { - dispatch(channel: string, input: unknown, source: CommandSource): Promise<BusResult>; - emit(channel: string, payload: unknown, context?: { commandId?: string; sessionId?: string; projectId?: string }): void; - on(channel: string, handler: EventHandler): () => void; - onAny(handler: EventHandler): () => void; - registerHandler(channel: string, handler: CommandHandler): void; - registerDynamic(channel: string, handler: CommandHandler): void; - getRegistry(): RegisteredCommand[]; - queryCommands(filter: CommandFilter): CommandRecord[]; - queryEvents(filter: EventFilter): EventRecord[]; - dispose(): void; -} - -export function createCommandBus(db: AdcDatabase): CommandBus { - const handlers = new Map<string, CommandHandler>(); - const eventListeners = new Map<string, Set<EventHandler>>(); - const anyListeners = new Set<EventHandler>(); - - // Track the "current" command ID for correlating events - let activeCommandId: string | null = null; - - function dispatch(channel: string, input: unknown, source: CommandSource): Promise<BusResult> { - const id = ulid(); - const { domain, verb, noun } = parseChannel(channel); - const mutation = isMutationVerb(verb); - const startTime = Date.now(); - - // Write pending command - db.insert(commands).values({ - id, - channel, - domain, - verb, - noun, - isMutation: mutation, - sourceType: source.type, - sourceId: source.id ?? null, - sourceName: source.name ?? null, - input: input as Record<string, unknown>, - output: null, - status: 'pending', - error: null, - durationMs: null, - projectId: null, - createdAt: new Date().toISOString(), - }).run(); - - const handler = handlers.get(channel); - if (!handler) { - const error = `No handler registered for channel: ${channel}`; - logger.warn(error); - db.update(commands) - .set({ status: 'error', error, durationMs: Date.now() - startTime }) - .where(eq(commands.id, id)) - .run(); - return Promise.resolve({ commandId: id, status: 'error', output: null, durationMs: Date.now() - startTime, error }); - } - - activeCommandId = id; - - return handler(input) - .then((output) => { - const durationMs = Date.now() - startTime; - db.update(commands) - .set({ status: 'success', output: output as Record<string, unknown>, durationMs }) - .where(eq(commands.id, id)) - .run(); - activeCommandId = null; - return { commandId: id, status: 'success' as const, output, durationMs }; - }) - .catch((err: Error) => { - const durationMs = Date.now() - startTime; - const error = err.message; - db.update(commands) - .set({ status: 'error', error, durationMs }) - .where(eq(commands.id, id)) - .run(); - activeCommandId = null; - return { commandId: id, status: 'error' as const, output: null, durationMs, error }; - }); - } - - function emit( - channel: string, - payload: unknown, - context?: { commandId?: string; sessionId?: string; projectId?: string }, - ): void { - const id = ulid(); - db.insert(busEvents).values({ - id, - channel, - payload: payload as Record<string, unknown>, - sourceCommandId: context?.commandId ?? activeCommandId, - sessionId: context?.sessionId ?? null, - projectId: context?.projectId ?? null, - createdAt: new Date().toISOString(), - }).run(); - - // Notify listeners - const channelListeners = eventListeners.get(channel); - if (channelListeners) { - for (const handler of channelListeners) { - try { - handler(channel, payload); - } catch (err) { - logger.error(`Event handler error for ${channel}:`, err); - } - } - } - for (const handler of anyListeners) { - try { - handler(channel, payload); - } catch (err) { - logger.error('Any-event handler error:', err); - } - } - } - - function on(channel: string, handler: EventHandler): () => void { - let listeners = eventListeners.get(channel); - if (!listeners) { - listeners = new Set(); - eventListeners.set(channel, listeners); - } - listeners.add(handler); - return () => { listeners!.delete(handler); }; - } - - function onAny(handler: EventHandler): () => void { - anyListeners.add(handler); - return () => { anyListeners.delete(handler); }; - } - - function registerHandler(channel: string, handler: CommandHandler): void { - handlers.set(channel, handler); - } - - function registerDynamic(channel: string, handler: CommandHandler): void { - handlers.set(channel, handler); - logger.info(`Dynamic command registered: ${channel}`); - } - - function getRegistry(): RegisteredCommand[] { - return [...handlers.entries()].map(([channel, handler]) => { - const { domain, verb, noun } = parseChannel(channel); - return { channel, domain, verb, noun, isMutation: isMutationVerb(verb), handler }; - }); - } - - function queryCommands(filter: CommandFilter): CommandRecord[] { - const conditions = []; - if (filter.domain) conditions.push(eq(commands.domain, filter.domain)); - if (filter.verb) conditions.push(eq(commands.verb, filter.verb)); - if (filter.sourceType) conditions.push(eq(commands.sourceType, filter.sourceType)); - if (filter.projectId) conditions.push(eq(commands.projectId, filter.projectId)); - if (filter.since) conditions.push(gte(commands.createdAt, filter.since)); - - let query = db.select().from(commands); - if (conditions.length > 0) { - query = query.where(and(...conditions)) as typeof query; - } - return query.orderBy(desc(commands.createdAt)).limit(filter.limit ?? 100).all() as CommandRecord[]; - } - - function queryEvents(filter: EventFilter): EventRecord[] { - const conditions = []; - if (filter.channel) conditions.push(eq(busEvents.channel, filter.channel)); - if (filter.sessionId) conditions.push(eq(busEvents.sessionId, filter.sessionId)); - if (filter.since) conditions.push(gte(busEvents.createdAt, filter.since)); - - let query = db.select().from(busEvents); - if (conditions.length > 0) { - query = query.where(and(...conditions)) as typeof query; - } - return query.orderBy(desc(busEvents.createdAt)).limit(filter.limit ?? 100).all() as EventRecord[]; - } - - function dispose(): void { - handlers.clear(); - eventListeners.clear(); - anyListeners.clear(); - } - - return { - dispatch, - emit, - on, - onAny, - registerHandler, - registerDynamic, - getRegistry, - queryCommands, - queryEvents, - dispose, - }; -} -``` - -- [ ] **Step 3: Create barrel** - -```typescript -// src/main/bus/index.ts -export { createCommandBus } from './command-bus'; -export type { CommandBus } from './command-bus'; -export type { - BusResult, - CommandFilter, - CommandHandler, - CommandRecord, - CommandSource, - EventFilter, - EventRecord, - RegisteredCommand, -} from './types'; -export { isMutationVerb, parseChannel } from './types'; -``` - -- [ ] **Step 4: Write tests** - -```typescript -// tests/unit/services/command-bus.test.ts -import { mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { createCommandBus } from '@main/bus'; -import { closeDatabase, initDatabase } from '@main/db'; - -describe('CommandBus', () => { - let tempDir: string; - let bus: ReturnType<typeof createCommandBus>; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'adc-bus-test-')); - const db = initDatabase(tempDir); - bus = createCommandBus(db); - }); - - afterEach(() => { - bus.dispose(); - closeDatabase(); - rmSync(tempDir, { recursive: true, force: true }); - }); - - it('dispatches a command and logs to SQLite', async () => { - const handler = vi.fn().mockResolvedValue({ id: '1', title: 'Test' }); - bus.registerHandler('test.create.item', handler); - - const result = await bus.dispatch( - 'test.create.item', - { title: 'Test' }, - { type: 'ui', name: 'TestComponent' }, - ); - - expect(result.status).toBe('success'); - expect(result.output).toEqual({ id: '1', title: 'Test' }); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - expect(handler).toHaveBeenCalledWith({ title: 'Test' }); - - // Verify logged to SQLite - const logged = bus.queryCommands({ domain: 'test' }); - expect(logged).toHaveLength(1); - expect(logged[0].channel).toBe('test.create.item'); - expect(logged[0].status).toBe('success'); - }); - - it('logs errors for failed commands', async () => { - bus.registerHandler('test.fail.item', () => Promise.reject(new Error('boom'))); - - const result = await bus.dispatch( - 'test.fail.item', - {}, - { type: 'system', name: 'test' }, - ); - - expect(result.status).toBe('error'); - expect(result.error).toBe('boom'); - }); - - it('returns error for unregistered channels', async () => { - const result = await bus.dispatch('nonexistent.channel', {}, { type: 'ui' }); - expect(result.status).toBe('error'); - expect(result.error).toContain('No handler registered'); - }); - - it('emits events and logs to SQLite', () => { - const handler = vi.fn(); - bus.on('event:test.item.created', handler); - - bus.emit('event:test.item.created', { id: '1' }); - - expect(handler).toHaveBeenCalledWith('event:test.item.created', { id: '1' }); - - const logged = bus.queryEvents({ channel: 'event:test.item.created' }); - expect(logged).toHaveLength(1); - }); - - it('parses channels into domain/verb/noun', () => { - const handler = vi.fn().mockResolvedValue(null); - bus.registerHandler('progress.create.task', handler); - - const registry = bus.getRegistry(); - const entry = registry.find((r) => r.channel === 'progress.create.task'); - expect(entry?.domain).toBe('progress'); - expect(entry?.verb).toBe('create'); - expect(entry?.noun).toBe('task'); - expect(entry?.isMutation).toBe(true); - }); -}); -``` - -- [ ] **Step 5: Run tests** - -Run: `npx vitest run tests/unit/services/command-bus.test.ts` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/main/bus/ tests/unit/services/command-bus.test.ts -git commit -m "feat(bus): implement command bus core with SQLite logging" -``` - ---- - -### Task 9: Session Management in Bus - -**Files:** -- Create: `src/main/bus/session-manager.ts` -- Modify: `src/main/bus/types.ts` — add session types -- Modify: `src/main/bus/index.ts` -- Test: `tests/unit/services/bus-session-manager.test.ts` - -- [ ] **Step 1: Add session types to types.ts** - -```typescript -// Add to src/main/bus/types.ts - -export interface SessionSpawnRequest { - name: string; - type: 'project-owner' | 'team-lead' | 'assistant' | 'qa' | 'research' | 'planner'; - phase?: 'research' | 'planning' | 'executing' | 'qa'; - projectId?: string; - projectPath?: string; - taskSlug?: string; - prompt: string; - model?: string; - parentId?: string; - teamName?: string; - wave?: number; - taskIndex?: number; - worktreePath?: string; - agentFlags?: { - agentId?: string; - agentName?: string; - agentType?: string; - dangerouslySkipPermissions?: boolean; - }; -} - -export interface SessionRecord { - id: string; - name: string; - type: string; - phase: string | null; - status: string; - projectId: string | null; - taskSlug: string | null; - model: string | null; - pid: number | null; - worktreePath: string | null; - spawnConfig: unknown; - tokenUsage: unknown; - toolUsage: unknown; - parentId: string | null; - teamName: string | null; - wave: number | null; - taskIndex: number | null; - startedAt: string; - endedAt: string | null; - exitCode: number | null; - error: string | null; -} - -export interface SessionFilter { - status?: string; - type?: string; - projectId?: string; - taskSlug?: string; - parentId?: string; -} - -export type SessionEventType = 'spawned' | 'active' | 'completed' | 'error' | 'killed'; -export type SessionEventHandler = (event: { type: SessionEventType; session: SessionRecord }) => void; -``` - -- [ ] **Step 2: Implement session manager** - -```typescript -// src/main/bus/session-manager.ts -import { eq, and } from 'drizzle-orm'; - -import type { AdcDatabase } from '../db'; -import { sessions } from '../db/schema'; -import type { AgentManagerService } from '../services/agent-manager'; -import { createScopedLogger } from '../lib/logger'; - -import type { - SessionEventHandler, - SessionFilter, - SessionRecord, - SessionSpawnRequest, -} from './types'; - -const logger = createScopedLogger('bus-sessions'); - -export interface BusSessionManager { - spawn(config: SessionSpawnRequest): Promise<SessionRecord>; - kill(sessionId: string): Promise<void>; - get(sessionId: string): SessionRecord | undefined; - list(filter?: SessionFilter): SessionRecord[]; - onEvent(handler: SessionEventHandler): () => void; - recoverInterrupted(): void; - dispose(): void; -} - -export function createBusSessionManager( - db: AdcDatabase, - agentManager: AgentManagerService, -): BusSessionManager { - const eventHandlers = new Set<SessionEventHandler>(); - const cleanups: Array<() => void> = []; - - function emitEvent(type: SessionEventHandler extends (e: infer E) => void ? E['type'] : never, session: SessionRecord): void { - for (const handler of eventHandlers) { - try { - handler({ type: type as any, session }); - } catch (err) { - logger.error('Session event handler error:', err); - } - } - } - - // Subscribe to agent-manager events to keep SQLite in sync - const unsubAgentManager = agentManager.onEvent((event) => { - if (event.type === 'session.ended') { - const sessionId = event.sessionId; - const existing = get(sessionId); - if (existing) { - const endedAt = new Date().toISOString(); - const status = event.data?.exitCode === 0 ? 'completed' : 'error'; - db.update(sessions) - .set({ - status, - endedAt, - exitCode: event.data?.exitCode ?? null, - tokenUsage: event.data?.tokenUsage ?? existing.tokenUsage, - }) - .where(eq(sessions.id, sessionId)) - .run(); - const updated = get(sessionId); - if (updated) { - emitEvent(status === 'completed' ? 'completed' : 'error', updated); - } - } - } else if (event.type === 'status.changed') { - const sessionId = event.sessionId; - const existing = get(sessionId); - if (existing && event.data?.status) { - db.update(sessions) - .set({ status: event.data.status }) - .where(eq(sessions.id, sessionId)) - .run(); - } - } - }); - cleanups.push(unsubAgentManager); - - function spawn(config: SessionSpawnRequest): Promise<SessionRecord> { - // Determine spawn method based on type - const isTeamLead = config.type === 'team-lead'; - - const spawnResult = isTeamLead - ? agentManager.spawnTeamLead({ - projectPath: config.projectPath ?? process.cwd(), - prompt: config.prompt, - model: config.model, - teamName: config.teamName, - agentFlags: config.agentFlags, - }) - : agentManager.spawnProjectOwner({ - projectPath: config.projectPath ?? process.cwd(), - prompt: config.prompt, - name: config.name, - model: config.model, - agentFlags: config.agentFlags, - }); - - const session = isTeamLead - ? (spawnResult as { session: { id: string; pid?: number } }).session - : spawnResult as { id: string; pid?: number }; - - const now = new Date().toISOString(); - const record: typeof sessions.$inferInsert = { - id: session.id, - name: config.name, - type: config.type, - phase: config.phase ?? null, - status: 'active', - projectId: config.projectId ?? null, - taskSlug: config.taskSlug ?? null, - model: config.model ?? null, - pid: session.pid ?? null, - worktreePath: config.worktreePath ?? null, - spawnConfig: config as unknown as Record<string, unknown>, - tokenUsage: null, - toolUsage: null, - parentId: config.parentId ?? null, - teamName: config.teamName ?? null, - wave: config.wave ?? null, - taskIndex: config.taskIndex ?? null, - startedAt: now, - endedAt: null, - exitCode: null, - error: null, - }; - - db.insert(sessions).values(record).run(); - const result = get(session.id)!; - emitEvent('spawned', result); - return Promise.resolve(result); - } - - function kill(sessionId: string): Promise<void> { - agentManager.stopSession(sessionId); - db.update(sessions) - .set({ status: 'killed', endedAt: new Date().toISOString() }) - .where(eq(sessions.id, sessionId)) - .run(); - const updated = get(sessionId); - if (updated) { - emitEvent('killed', updated); - } - return Promise.resolve(); - } - - function get(sessionId: string): SessionRecord | undefined { - const rows = db.select().from(sessions).where(eq(sessions.id, sessionId)).all(); - return rows[0] as SessionRecord | undefined; - } - - function list(filter?: SessionFilter): SessionRecord[] { - const conditions = []; - if (filter?.status) conditions.push(eq(sessions.status, filter.status)); - if (filter?.type) conditions.push(eq(sessions.type, filter.type)); - if (filter?.projectId) conditions.push(eq(sessions.projectId, filter.projectId)); - if (filter?.taskSlug) conditions.push(eq(sessions.taskSlug, filter.taskSlug)); - if (filter?.parentId) conditions.push(eq(sessions.parentId, filter.parentId)); - - let query = db.select().from(sessions); - if (conditions.length > 0) { - query = query.where(and(...conditions)) as typeof query; - } - return query.all() as SessionRecord[]; - } - - function onEvent(handler: SessionEventHandler): () => void { - eventHandlers.add(handler); - return () => { eventHandlers.delete(handler); }; - } - - function recoverInterrupted(): void { - const active = db.select().from(sessions) - .where(eq(sessions.status, 'active')) - .all(); - - for (const session of active) { - const pid = session.pid; - let alive = false; - if (pid) { - try { - process.kill(pid, 0); // Signal 0 = check if alive - alive = true; - } catch { - alive = false; - } - } - - if (!alive) { - logger.info(`Recovering interrupted session: ${session.id} (${session.name})`); - db.update(sessions) - .set({ - status: 'error', - error: 'Interrupted by app restart', - endedAt: new Date().toISOString(), - }) - .where(eq(sessions.id, session.id)) - .run(); - } - } - } - - function dispose(): void { - for (const cleanup of cleanups) cleanup(); - cleanups.length = 0; - eventHandlers.clear(); - } - - return { spawn, kill, get, list, onEvent, recoverInterrupted, dispose }; -} -``` - -- [ ] **Step 3: Update bus barrel** - -Add session-manager exports to `src/main/bus/index.ts`. - -- [ ] **Step 4: Write tests and verify** - -Run: `npx vitest run tests/unit/services/bus-session-manager.test.ts` - -- [ ] **Step 5: Commit** - -```bash -git add src/main/bus/ tests/unit/services/ -git commit -m "feat(bus): add session lifecycle management with SQLite persistence" -``` - ---- - -### Task 10: Wire Bus Into Bootstrap - -**Files:** -- Modify: `src/main/bootstrap/service-registry.ts` -- Modify: `src/main/bootstrap/ipc-wiring.ts` -- Modify: `src/main/bootstrap/event-wiring.ts` -- Modify: `src/main/bootstrap/lifecycle.ts` - -- [ ] **Step 1: Add database + bus initialization to service-registry.ts** - -At the top of `createServiceRegistry()` (before other services): - -```typescript -import { initDatabase, closeDatabase } from '../db'; -import { createCommandBus } from '../bus'; -import { createBusSessionManager } from '../bus/session-manager'; - -// Early in the function, after dataDir is resolved: -const db = initDatabase(dataDir); -const commandBus = createCommandBus(db); -``` - -After agentManagerService is created: - -```typescript -const busSessionManager = createBusSessionManager(db, agentManagerService); -busSessionManager.recoverInterrupted(); // Mark interrupted sessions on boot -``` - -Add `commandBus` and `busSessionManager` to the services bag and the return object. - -- [ ] **Step 2: Remove deprecated service creation from service-registry.ts** - -Delete these lines (approximate line numbers from research): -- Line 448–452: `createCrashRecovery(...)` — replaced by `busSessionManager.recoverInterrupted()` -- Line 455: `createTaskLauncher()` — zombie -- Line 457: `createAgentOrchestrator(...)` — v1 replaced by bus -- Line 572–577: `createJsonlProgressWatcher(...)` and `createProgressWatcherV2()` — duplicates - -Remove these from the services bag and return object. Update the `Services` interface. - -- [ ] **Step 3: Wire IPC handlers through bus** - -In `src/main/bootstrap/ipc-wiring.ts`, modify `wireIpcHandlers`: - -```typescript -export function wireIpcHandlers(router: IpcRouter, services: Services, commandBus: CommandBus): void { - // Register all handlers with the bus - registerAllHandlers(router, services); - - // Wrap router.handle to dispatch through bus - // The router's existing Zod validation stays — the bus adds tracking on top -} -``` - -The key integration point: intercept `router.handle()` calls so each invocation goes through `bus.dispatch()`. This can be done by patching the router's handle method or by modifying `registerAllHandlers` to register via the bus. - -- [ ] **Step 4: Clean up event-wiring.ts** - -Remove the orchestrator blocks (lines 115–179 and 182–239): -- Delete `agentOrchestrator.onSessionEvent(...)` block -- Delete `jsonlProgressWatcher.onProgress(...)` block -- Delete `jsonlProgressWatcher.start()` call - -Add bus session event forwarding: - -```typescript -busSessionManager.onEvent((event) => { - router.emit(`event:bus.session.${event.type}`, { - sessionId: event.session.id, - session: event.session, - }); -}); -``` - -- [ ] **Step 5: Add database cleanup to lifecycle.ts** - -In the shutdown handler, add `closeDatabase()` call after all services are disposed. - -- [ ] **Step 6: Verify** - -Run: `npm run typecheck && npm run build` -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add src/main/bootstrap/ -git commit -m "feat(bootstrap): wire command bus and session manager into app lifecycle" -``` - ---- - -### Task 11: Rewire Orchestrator Consumers - -**Files:** -- Modify: `src/main/services/workflow-engine/states/spawn.ts` -- Modify: `src/main/services/workflow-engine/states/qa-gate.ts` -- Modify: `src/main/services/workflow-engine/states/guardian.ts` -- Modify: `src/main/services/workflow-engine/types.ts` -- Modify: `src/main/services/qa/qa-runner.ts` -- Modify: `src/main/services/qa/qa-trigger.ts` -- Modify: `src/main/services/agent-orchestrator/agent-watchdog.ts` → move to `src/main/services/agent-watchdog/` -- Modify: `src/main/services/briefing/briefing-generator.ts` -- Modify: `src/main/services/insights/insights-service.ts` - -For each consumer, the pattern is the same: -1. Replace `import type { AgentOrchestrator }` with `import type { BusSessionManager }` and/or `import type { CommandBus }` -2. Replace `agentOrchestrator.spawn(opts)` with `busSessionManager.spawn(config)` -3. Replace `agentOrchestrator.listActiveSessions()` with `busSessionManager.list({ status: 'active' })` -4. Replace `agentOrchestrator.getSession(id)` with `busSessionManager.get(id)` -5. Replace `agentOrchestrator.getSessionByTaskId(taskId)` with `busSessionManager.list({ taskSlug: taskId })[0]` -6. Replace `agentOrchestrator.kill(id)` with `busSessionManager.kill(id)` -7. Replace `agentOrchestrator.onSessionEvent(handler)` with `busSessionManager.onEvent(handler)` - -- [ ] **Step 1: Update WorkflowEngine deps type** - -In `src/main/services/workflow-engine/types.ts`, change `agentOrchestrator: AgentOrchestrator` to `busSessionManager: BusSessionManager`. - -- [ ] **Step 2: Update spawn.ts** - -Replace the `agentOrchestrator.spawn()` call with `busSessionManager.spawn()`. Map the old `SpawnOptions` fields to `SessionSpawnRequest` fields. - -- [ ] **Step 3: Update qa-gate.ts** - -Replace `agentOrchestrator.getSessionByTaskId()` polling with `busSessionManager.list({ taskSlug })` queries. - -- [ ] **Step 4: Update guardian.ts** - -Replace `agentOrchestrator.spawn()` and `agentOrchestrator.getSession()` with bus equivalents. - -- [ ] **Step 5: Update qa-runner.ts** - -Replace all orchestrator calls with bus session manager calls. - -- [ ] **Step 6: Update qa-trigger.ts** - -Replace `orchestrator.onSessionEvent` with `busSessionManager.onEvent` and `orchestrator.getSessionByTaskId` with bus queries. - -- [ ] **Step 7: Move and update agent-watchdog.ts** - -Move from `src/main/services/agent-orchestrator/agent-watchdog.ts` to `src/main/services/agent-watchdog/agent-watchdog.ts`. Update to use `busSessionManager.list({ status: 'active' })` instead of `orchestrator.listActiveSessions()`. - -- [ ] **Step 8: Update briefing-generator.ts** - -Replace `agentOrchestrator.listActiveSessions()` with `busSessionManager.list()`. - -- [ ] **Step 9: Update insights-service.ts** - -Replace `agentOrchestrator.listActiveSessions()` with `busSessionManager.list()`. Consider also querying the `commands` table for richer metrics. - -- [ ] **Step 10: Update service-registry.ts** - -Update all the wiring — pass `busSessionManager` instead of `agentOrchestrator` to all rewired consumers. - -- [ ] **Step 11: Verify** - -Run: `npm run typecheck && npm run lint` -Expected: PASS - -- [ ] **Step 12: Commit** - -```bash -git add src/main/services/ src/main/bootstrap/ -git commit -m "refactor: rewire all orchestrator consumers to command bus session manager" -``` - ---- - -### Task 12: Delete Deprecated Systems - -**Files:** -- Delete: `src/main/services/agent-orchestrator/` (entire directory) -- Delete: `src/main/services/workflow/task-launcher.ts` -- Delete: `src/main/services/data-management/crash-recovery.ts` -- Delete: `src/main/services/progress-watcher-v2/` (entire directory) -- Delete: `src/main/ipc/handlers/agent-orchestrator-handlers.ts` -- Modify: `src/main/ipc/index.ts` — remove orchestrator handler registration -- Modify: `src/shared/ipc/agents/` — delete or gut this folder (orchestrator-only) -- Modify: `src/shared/ipc/index.ts` — remove orchestrator imports -- Modify: `src/shared/types/session-config.ts` — update to match bus types - -- [ ] **Step 1: Delete service directories and files** - -```bash -rm -rf src/main/services/agent-orchestrator/ -rm -rf src/main/services/progress-watcher-v2/ -rm src/main/services/workflow/task-launcher.ts -rm src/main/services/data-management/crash-recovery.ts -rm src/main/ipc/handlers/agent-orchestrator-handlers.ts -``` - -- [ ] **Step 2: Remove from IPC barrel and handler registration** - -In `src/main/ipc/index.ts`, remove the import and registration of `agent-orchestrator-handlers`. In `src/shared/ipc/index.ts`, remove `orchestratorInvoke` and `orchestratorEvents` imports. - -- [ ] **Step 3: Delete the agents/ IPC domain folder** - -Since all `agent.*` channels were orchestrator channels and are now deleted, remove `src/shared/ipc/agents/` entirely. - -- [ ] **Step 4: Clean up any remaining type imports** - -Search for any remaining imports from deleted modules and remove them. - -- [ ] **Step 5: Verify** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: PASS — zero references to deleted code - -- [ ] **Step 6: Run existing tests** - -Run: `npm run test` -Expected: All tests pass (some may need updating if they referenced deleted modules) - -- [ ] **Step 7: Commit** - -```bash -git add -A -git commit -m "chore: delete deprecated agent-orchestrator, task-launcher, crash-recovery, progress-watcher-v2" -``` - ---- - -### Task 13: Add Bus IPC Domain - -**Files:** -- Create: `src/shared/ipc/bus/channels.ts` -- Create: `src/shared/ipc/bus/contract.ts` -- Create: `src/shared/ipc/bus/schemas.ts` -- Create: `src/shared/ipc/bus/index.ts` -- Create: `src/main/ipc/handlers/bus-handlers.ts` - -The bus needs its own IPC domain so the renderer can query commands, events, and sessions. - -- [ ] **Step 1: Create bus channel constants** - -```typescript -// src/shared/ipc/bus/channels.ts -import { domain, events } from '../channel-builder'; - -export const BUS = domain('bus', { - QUERY: ['commands', 'events'], - LIST: ['sessions'], - GET: ['session', 'registry'], - SPAWN: ['session'], - KILL: ['session'], -}); - -export const BUS_EVENTS = events('bus', { - SESSION: ['spawned', 'active', 'completed', 'error', 'killed'], - COMMAND: ['executed'], -}); -``` - -- [ ] **Step 2: Create schemas and contract** - -Define Zod schemas for session queries and command queries. Create contract using the constants. - -- [ ] **Step 3: Create bus handlers** - -```typescript -// src/main/ipc/handlers/bus-handlers.ts -import { BUS } from '@shared/ipc/bus/channels'; - -export function registerBusHandlers(router: IpcRouter, commandBus: CommandBus, sessionManager: BusSessionManager): void { - router.handle(BUS.QUERY.COMMANDS, async (input) => commandBus.queryCommands(input)); - router.handle(BUS.QUERY.EVENTS, async (input) => commandBus.queryEvents(input)); - router.handle(BUS.LIST.SESSIONS, async (input) => sessionManager.list(input)); - router.handle(BUS.GET.SESSION, async (input) => sessionManager.get(input.sessionId)); - router.handle(BUS.GET.REGISTRY, async () => commandBus.getRegistry().map(r => ({ - channel: r.channel, domain: r.domain, verb: r.verb, noun: r.noun, isMutation: r.isMutation, - }))); - router.handle(BUS.SPAWN.SESSION, async (input) => sessionManager.spawn(input)); - router.handle(BUS.KILL.SESSION, async (input) => { await sessionManager.kill(input.sessionId); return { success: true }; }); -} -``` - -- [ ] **Step 4: Register in ipc-wiring** - -- [ ] **Step 5: Add to IPC root barrel** - -- [ ] **Step 6: Commit** - -```bash -git add src/shared/ipc/bus/ src/main/ipc/handlers/bus-handlers.ts -git commit -m "feat(ipc): add bus IPC domain for command/session/event queries" -``` - ---- - -### Task 14: Full Verification - -- [ ] **Step 1: Run all checks** - -```bash -npm run lint -npm run typecheck -npm run build -npm run test -``` - -All must pass with zero errors. - -- [ ] **Step 2: Manual smoke test** - -```bash -npm run dev -``` - -Verify: app starts, SQLite database created at userData path, IPC channels work, no console errors. - -- [ ] **Step 3: Commit any fixes** - ---- - -### Task 15: Documentation Updates - -**Files to update (every file listed is mandatory):** - -- Modify: `CLAUDE.md` — add Command Bus section, channel constants instructions, remove all orchestrator references -- Modify: `docs/architecture/ARCHITECTURE.md` — new system diagram with bus + SQLite layer, remove orchestrator from services list -- Modify: `docs/routing/FEATURES-INDEX.md` — update service inventory (remove orchestrator, task-launcher, progress-watcher-v2; add command-bus, bus-session-manager), update handler count, update IPC domain count -- Modify: `docs/routing/AI-AGENT-ROUTING-INDEX.md` — remove agents/orchestrator vertical slice, add bus/sessions domain vertical slice, update all consumer references -- Modify: `docs/patterns/PATTERNS.md` — add channel constants pattern section, add bus dispatch pattern section -- Modify: `.claude/agents/team-leader.md` — update to reference bus commands and channel constants -- Modify: `.claude/skills/electron-ipc/` — update the skill content to show channel constants + bus pattern instead of old string-literal pattern -- Modify: `.claude/skills/codebase-nav/` — update service inventory - -- [ ] **Step 1: Update CLAUDE.md** - -Add new section after "Caching Layer Rules": - -```markdown -## Command Bus - -All IPC channels are tracked by a central command bus backed by SQLite (`adc.db`). - -1. **Channel constants** — use `DOMAIN.VERB.NOUN` constants from `src/shared/ipc/<domain>/channels.ts`. Never use string literals for channel names. -2. **Bus dispatch** — the IPC router dispatches through the bus. Every command is logged with source attribution. -3. **Sessions** — all Claude sessions are tracked in the `sessions` SQLite table via `BusSessionManager`. No in-memory-only session tracking. -4. **Queries** — use `bus.queryCommands()` and `bus.queryEvents()` for analytics. Use `busSessionManager.list()` for session queries. - -### Channel Constant Pattern -```typescript -// Import constants from the domain's channels.ts -import { PROGRESS } from '@shared/ipc/progress/channels'; - -// Use in contracts, handlers, and renderer calls -router.handle(PROGRESS.CREATE.TASK, async (input) => { ... }); -const result = await ipc(PROGRESS.CREATE.TASK, { slug, title }); -``` -``` - -Remove all references to `agent-orchestrator`, `task-launcher`, `crash-recovery`, `progress-watcher-v2`. - -- [ ] **Step 2: Update ARCHITECTURE.md** - -Replace the system diagram. Add SQLite + bus layer between services and IPC router. Update service count and list. - -- [ ] **Step 3: Update FEATURES-INDEX.md and AI-AGENT-ROUTING-INDEX.md** - -Remove deleted services, add new ones, update counts. - -- [ ] **Step 4: Update agent definitions and skills** - -Update `.claude/agents/team-leader.md` and relevant skills to reference the new system. - -- [ ] **Step 5: Commit** - -```bash -git add CLAUDE.md docs/ .claude/ -git commit -m "docs: update all documentation for command bus architecture" -``` - ---- - -### Task 16: Write Phase 2 Task Files - -**Files:** -- Create: `progress/migrate-settings/task.md` -- Create: `progress/migrate-captures/task.md` -- Create: `progress/migrate-notes/task.md` -- Create: `progress/migrate-alerts/task.md` -- Create: `progress/migrate-ideas/task.md` -- Create: `progress/migrate-milestones/task.md` -- Create: `progress/migrate-changelog/task.md` -- Create: `progress/migrate-planner/task.md` -- Create: `progress/migrate-fitness/task.md` -- Create: `progress/migrate-briefings/task.md` -- Create: `progress/migrate-notifications/task.md` -- Create: `progress/migrate-progress-tasks/task.md` -- Create: `progress/migrate-progress-sessions/task.md` -- Create: `progress/migrate-task-specs/task.md` -- Create: `progress/migrate-workflow-engine/task.md` -- Create: `progress/migrate-oauth/task.md` -- Create: `progress/migrate-email/task.md` -- Create: `progress/migrate-hub-config/task.md` -- Create: `progress/remove-json-stores/task.md` -- Create: `progress/remove-progress-fs/task.md` - -Each task file follows YAML frontmatter format: - -```yaml ---- -title: "Migrate [Domain] to SQLite" -status: backlog -priority: medium -tags: [phase-2, wave-N, data-migration] ---- -``` - -- [ ] **Step 1: Write Wave 1 task files (7 simple JSON stores)** - -Each task file must include: -- Current JSON file path and service that reads/writes it -- Drizzle table schema to add (the exact `sqliteTable()` call) -- One-time migration: read JSON → insert into SQLite -- Service changes: replace `readJSON`/`writeJSON` with Drizzle queries -- Handler changes: update any affected channel handlers -- Test: verify data round-trips through the new path -- Docs: which docs reference this store - -- [ ] **Step 2: Write Wave 2 task files (4 directory-based stores)** - -- [ ] **Step 3: Write Wave 3 task files (4 complex domains)** - -- [ ] **Step 4: Write Wave 4 task files (3 encrypted stores)** - -- [ ] **Step 5: Write Wave 5 task files (2 cleanup tasks)** - -- [ ] **Step 6: Commit** - -```bash -git add progress/migrate-*/task.md progress/remove-*/task.md -git commit -m "docs: add Phase 2 data migration task files for agent teams" -``` - ---- - -## Final Verification - -After all 16 tasks are complete: - -```bash -npm run lint # Zero violations -npm run typecheck # Zero errors -npm run build # Builds successfully -npm run test # All tests pass -``` - -The app should start, create `adc.db`, track all IPC commands in SQLite, manage sessions through the bus, and have zero references to deleted code. diff --git a/docs/prompts/implementing-features/README.md b/docs/prompts/implementing-features/README.md index dae1b6bb..2e5044e7 100644 --- a/docs/prompts/implementing-features/README.md +++ b/docs/prompts/implementing-features/README.md @@ -179,7 +179,7 @@ Claude-UI/ │ ├── tracker.json # Single source of truth for plan/progress lifecycle │ ├── plans/ # Design documents (one per feature) │ └── specs/ # Specification files (one per feature) -├── .claude/agents/ # Agent prompt definitions (27 specialists) +├── .claude/agents/ # Agent prompt definitions (30 specialists) ├── hub/ # Hub backend (Fastify + SQLite) │ └── src/ │ ├── db/schema.sql diff --git a/docs/qa/2026-04-16-test-suite-runners/findings.md b/docs/qa/2026-04-16-test-suite-runners/findings.md new file mode 100644 index 00000000..24575860 --- /dev/null +++ b/docs/qa/2026-04-16-test-suite-runners/findings.md @@ -0,0 +1,107 @@ +# Test Suite + Runners UX/UI QA — 2026-04-16 + +Branch: `feature/project-runners` +Tested against: dev build (Electron, localhost:5173) +Scope: All 7 test-suite tabs + new RunnerPanel integration + +## Coverage Matrix + +| Tab | Empty State | Functional | Screenshot | +|---|---|---|---| +| Recording (+ Runners) | ok | partial | 01, 02, 09 | +| Library | ok | not exercised (no tests) | 03 | +| Results | ok | not exercised | 04 | +| Screenshots | **missing** | not exercised | 05 | +| Analytics | ok | not exercised | 06 | +| Shared Steps | ok | not exercised | 07 | +| CI Export | ok (but bugs) | **broken** | 08 | + +## Critical Findings (block ship) + +### F1 — Runner ProfileEditDialog saves silently fail, dialog stays open +- **Where**: `src/renderer/features/runners/components/ProfileEditDialog.tsx:34-41` +- **Repro**: Click "New" runner profile → fill form → Save. Dialog does NOT close, profile does NOT appear in dropdown, no error shown. +- **Root cause**: `save.mutate(...)` has no `onError` handler. When the main-process channel is missing (e.g., `"Unknown IPC channel: runners.profile.save"`), the promise resolves with `{ success: false, error }` but `onSuccess` still fires in react-query's default mutation. Actually the issue is worse — the IPC bridge returns the error object as a resolved value, so `onSuccess` runs on `{success:false}` payloads. Need to either throw in `ipc()` on `success:false` or inspect the response. +- **Verified**: direct console call `window.api.invoke('runners.profile.save', ...)` returns `{success: false, error: "Unknown IPC channel: runners.profile.save"}` — channel not registered at runtime (requires main-process restart since Vite HMR only reloads renderer). +- **Fix**: + 1. Make `@renderer/shared/lib/ipc` throw when the response is `{success:false}` so react-query's `onError` fires. + 2. Add toast/inline error in ProfileEditDialog `onError`. + 3. Confirm main-process restart picks up runners handlers (it should — bootstrap wires them). + +### F2 — CI Export Preview button silently fails +- **Where**: `src/renderer/features/test-suite/components/ExportPanel.tsx:41-53` +- **Repro**: Click "Preview YAML". Console shows `Uncaught (in promise)` — no dialog opens. +- **Root cause**: `try { ... } finally { setLoading(false) }` with NO `catch`. Any rejection from `ipc(TEST_SUITE.EXPORT['CI-PREVIEW'])` is silently swallowed into an unhandled promise. +- **Fix**: add `catch (err) { toast.error(err.message) }`. + +### F3 — Same silent-failure anti-pattern in CI Commit +- **Where**: `src/renderer/features/test-suite/components/ExportPanel.tsx:55-67` +- Same bug as F2. + +## Important Findings + +### F4 — [FALSE POSITIVE, closed] +- Screenshots tab does render a toolbar + EmptyState ("No run selected — Select a test run from the dropdown..."). Verified via DOM inspection (`textContent` includes the empty-state copy). The a11y snapshot tool didn't surface it because the empty state's wrapper divs carry no ARIA roles. +- No code change needed. Follow-up: consider adding `role="region"` + aria-label to EmptyState wrapper for better a11y surfacing. + +### F5 — CI Export path double-slash bug +- **Where**: `ExportPanel.tsx:145` and `:159` +- **Display**: `test-suite//**, src/**` and `test-suite//screenshots/` +- **Root cause**: `testDir` from `config.testDirectory` already contains a trailing `/` (stored as `"test-suite/"` in DB), then template literal appends another `/`. +- **Fix**: either strip trailing slash in presenter — `const normalizedTestDir = testDir.replace(/\/$/, '')` — or strip on write in the config service. + +### F6 — DialogContent missing aria-describedby (a11y warnings) +- **Where**: every @ui Dialog currently missing description (2+ warnings in console). +- **Fix**: add `<DialogDescription>` or `aria-describedby={undefined}` to silence. Non-blocking but affects screen readers. + +## Minor Findings + +### F7 — Runner "Dev Server" heading styling differs from sibling panels +- RunnerPanel uses h3 with different spacing from the adjacent Recording h3 below. Visual rhythm would improve if both used the same heading pattern (consistent margin + tracking). + +### F8 — "No runner selected." is terse +- Current: `No runner selected.` +- Better: `Create a runner profile (e.g. "npm run dev") to start a dev server from here.` + +### F9 — Start button disabled with no explanation +- When there are 0 profiles, Start is disabled. User can't tell why without hovering (no tooltip wired). +- Fix: add Tooltip wrapping the Button + `disabled` reason. + +### F10 — No scope indicator in RunnerPanel +- Panel heading says "Dev Server" but doesn't tell user which scope (project vs worktree) it's attached to. If a user switches worktree context, it's not obvious instances are per-scope. + +## Not-Yet-Exercised (backend restart required) + +These flows could not be tested live because main-process IPC handlers for `runners.*` channels returned "Unknown IPC channel" at runtime (dev HMR limitation — main process needs restart to pick up newly-registered handlers from this branch): + +- Profile save → persist → list refetch +- Instance start → spawn → health-check → ready status +- Instance stop → supervisor.kill → stopped status +- Instance restart → kill + start cycle +- auto-restart on abnormal exit +- OUTPUT/STATUS/HEALTH event stream into console +- Stop while starting (health controller abort path) +- Rapid start of two profiles (setQueryData dedupe path) + +**Recommend**: restart main process (or issue `npm run dev` full relaunch), then execute these paths. After F1/F2/F3 fixes land, repeat full tour. + +## Artifacts + +Screenshots in `docs/qa/2026-04-16-test-suite-runners/`: +- 01-test-suite-recording-initial.png +- 02-runner-new-profile-dialog.png +- 03-library-empty.png +- 04-results-empty.png +- 05-screenshots-empty.png +- 06-analytics-empty.png +- 07-shared-steps-empty.png +- 08-ci-export.png +- 09-recording-runner-panel.png + +## Recommended Fix Batch + +1. Wrap `@renderer/shared/lib/ipc` to throw on `{success:false}` responses. This is a single codebase-wide fix that silently resolves F1 + F2 + F3 and prevents recurrence. Cross-reference with silent-failure-hunter agent. +2. Add Screenshots tab empty state (F4). +3. Normalize `testDirectory` trailing slash (F5). +4. Add DialogDescription to all @ui Dialog usages (F6). +5. Polish RunnerPanel empty copy + tooltips (F7–F10). diff --git a/docs/research/2026-04-16-test-suite-competitor-scan.md b/docs/research/2026-04-16-test-suite-competitor-scan.md new file mode 100644 index 00000000..8d71e787 --- /dev/null +++ b/docs/research/2026-04-16-test-suite-competitor-scan.md @@ -0,0 +1,247 @@ +# ADC Test-Suite: Competitor Scan + Gap Analysis + +**Date:** 2026-04-16 +**Author:** Research pass (automated) +**Audience:** Engineers planning the next 2 sprints for `src/main/features/test-suite/` and `src/renderer/features/test-suite/` +**Scope:** Playwright-based E2E recorder/runner UIs and AI-augmented test-automation SaaS + +--- + +## 1. Competitor Landscape + +### 1.1 Playwright Codegen (Microsoft, built-in) +The canonical free recorder that ships with `@playwright/test`. Launched via `npx playwright codegen <url>`, it opens a Chromium window plus an Inspector panel that emits test code in JS/TS, Python, Java, or .NET. Selector strategy is priority-ordered: `getByRole` > `getByLabel` > `getByText` > CSS/XPath fallback [^1][^2]. No library, no run history, no analytics — it is a code emitter, not a test manager. **Pricing:** free (Apache-2.0). **Wins at:** selector quality, zero-cost entry, language coverage. + +### 1.2 Playwright UI Mode / Trace Viewer (Microsoft) +`npx playwright test --ui` launches a desktop-class Test Explorer with watch mode, per-action DOM snapshots, network/console tabs, a timeline, and locator picker [^3][^4]. Playwright 1.59 added action filtering and "Copy prompt" on errors for LLM-assisted debugging [^5]. **Pricing:** free. **Wins at:** time-travel debugging, integrated trace inspection, live editing — this is the direct reference model for what an engineer-facing local test UI should feel like. + +### 1.3 Cypress Studio (Cypress.io, now Cypress Cloud) +In-runner recorder layered on Cypress 15.11+. Generates tests by observing DOM diffs between actions; the 2025 "Studio AI" beta suggests assertions automatically [^6][^7]. Selector priority is uniqueness/readability/performance weighted. Hard limits: E2E only (no component tests), no cross-origin, Shadow DOM must be manual, no API-mock-while-recording. **Pricing:** Cypress is open-source; Cloud reporting is usage-based (Team $75/mo starting, Business $300/mo; Studio AI free during beta). **Wins at:** DOM-diff-driven assertion suggestions, tight integration with the existing Cypress runner UI. + +### 1.4 Checkly (Checkly) +Monitoring-first platform that runs real Playwright scripts on a cron from 20+ global regions. Products: Browser Checks (single script, 30-sec budget per run), Playwright Check Suites (full `@playwright/test` projects), and API monitors [^8][^9]. Hobby $0 (1k browser runs/mo, 10k API runs/mo); Team $40/mo for 6k browser runs; overages $6.25/1k [^10]. **Wins at:** scheduling/alerting, multi-region execution, turning existing Playwright repos into production monitors with minimal glue. + +### 1.5 BrowserStack Low-Code Automation (BrowserStack) +Recorder + real-device cloud. Captures actions, offers natural-language step authoring, self-healing locators, CSV data-driven runs, reusable modules, API steps, WCAG/accessibility scanning [^11]. **Pricing:** Automate base $129/mo for one parallel; Low-Code Automation is bundled/enterprise-quoted — public SKU list is sparse [^12]. **Wins at:** cross-browser/device matrix, real-device execution, self-healing at scale. + +### 1.6 Testim (Tricentis) +AI-stabilized web UI automation with "Smart Locators" that combine DOM metadata, ML, and visual context. Records, edits in a visual canvas, exports to code. Pricing starts ~$299/mo; enterprise is custom [^13][^14]. **Wins at:** locator self-healing on volatile apps, enterprise Tricentis ecosystem integration (Tosca, qTest). + +### 1.7 Mabl (Mabl) +Low-code, AI-first platform spanning web, mobile, API, accessibility, performance. Auto-heals locators via ML, auto-triages failures ("Auto TFA"), conversational test-planning agent, visual diff baked in [^15]. Pricing is quote-only; no self-serve tier; industry reporting puts entry around $499/mo [^16]. **Wins at:** autonomous triage, unified test type coverage, exec-friendly reporting. + +### 1.8 Ranorex Studio (Idera) +Desktop-heritage tool covering web, desktop (Win32/WPF), and mobile. Record-and-playback, object repository, CSV/XML data-driven, Jenkins/Azure DevOps hooks. Licenses: perpetual $890–$4,990 + 12-month maintenance; subscription tiers added in 2025 [^17][^18]. **Wins at:** desktop-app coverage (rare in this list), regulated-industry customers, offline-first workflow. + +### 1.9 Reflect.run (Reflect) +Cloud recorder-in-a-cloud-browser. No-code, AI-adaptive locators, visual regression built-in, CI integrations (GitHub Actions, Jenkins, CircleCI), Jira/Slack hooks. Team $200/mo, Pro $500/mo [^19][^20]. **Wins at:** fastest non-technical onboarding, cloud-hosted browser means zero local setup. + +### 1.10 Visual Regression Specialists (Applitools, Percy) +Not full runners — diff engines bolted onto any framework. **Applitools Eyes**: Visual AI that ignores anti-aliasing/rendering noise, Ultrafast Grid for parallel cross-browser rendering; pricing via "Test Units," quote-only [^21]. **Percy (BrowserStack)**: free 5k screenshots/mo, paid from $199/mo; Visual Review Agent auto-filters ~40% of false positives [^22][^23]. **Wins at:** perceptual diffs that don't drown reviewers in false positives — the bar for any visual-diff feature. + +### 1.11 TestCafe Studio (DevExpress) +Visual recorder + IDE wrapping the open-source TestCafe runner. Perpetual-ish license from $249.99/year [^24]. Note: open-source TestCafe has seen reduced upstream activity vs. Playwright, and most 2025 comparisons frame it as a legacy choice [^25]. Included for completeness, not as a leader. + +--- + +## 2. Feature Matrix + +Columns chosen to match ADC's seven tabs plus industry-standard capabilities. "—" = not applicable / not a product focus. "?" = not publicly documented. Citations in footnotes. + +| Capability | ADC | PW Codegen | PW UI Mode | Cypress Studio | Checkly | BStack LCA | Testim | Mabl | Ranorex | Reflect | Applitools | Percy | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Action recording | Yes (embedded webview) | Yes | — | Yes (in-runner) | via Codegen | Yes | Yes | Yes | Yes | Yes (cloud) | — | — | +| Selector strategy | custom `selector-builder` | `getByRole`-priority [^1] | `getByRole`-priority [^3] | uniqueness-weighted [^6] | PW-native [^8] | Self-healing AI [^11] | Smart Locators ML [^13] | ML auto-heal [^15] | Object repo | AI-adaptive [^19] | — | — | +| Self-healing selectors | No | No | No | No (suggests) | No | Yes [^11] | Yes [^13] | Yes [^15] | No (manual) | Yes [^19] | — | — | +| Inline step editing | Yes | — | Live edit [^3] | Yes | — | Yes | Yes | Yes | Yes | Yes | — | — | +| Drag-to-reorder steps | Yes | — | — | No | — | Yes (modules) | Yes | Yes | Yes | Yes | — | — | +| Library / script mgmt | Yes (DB) | file-only | file-only | file-only | cloud | cloud | cloud | cloud | file/repo | cloud | — | — | +| Run history | Yes | — | single-run | via Cloud [^7] | Yes, 12mo [^10] | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Visual regression | Yes (DiffViewer) | — | — | plugin | Yes [^8] | Yes | Yes | Yes | limited | Yes [^19] | Yes (AI) [^21] | Yes (AI) [^23] | +| Perceptual/AI diff | Pixel only | — | — | — | Pixel | ? | Visual locator | Yes [^15] | Pixel | AI [^19] | Yes [^21] | Yes [^23] | +| Analytics / trends | Yes (sparklines) | — | — | Cloud dashboard | Yes [^8] | Yes | Yes | Yes [^15] | Reports | Yes | Yes | Yes | +| Shared/reusable steps | Yes (by domain) | — | fixtures only [^26] | custom cmds | fixtures | Modules [^11] | Groups | Flows | User-code actions | Components | — | — | +| Data-driven testing | No | — | projects [^27] | fixtures | fixtures | CSV [^11] | Yes | Yes | CSV/XML [^17] | Yes | — | — | +| Scheduling | Yes (scheduler svc) | — | — | — | Yes (cron, 20+ regions) [^9] | Yes | Yes | Yes | via CI | Yes | — | — | +| CI export (GH Actions) | Yes (workflow-exporter) | — | boilerplate | boilerplate | Terraform/MCP | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| AI step generation | No | — | "Copy prompt" only [^5] | Assertion suggest [^6] | via PW MCP [^28] | NL authoring [^11] | Yes | Yes (Test Creation Agent) [^15] | No | No direct | — | — | +| MCP / LLM agent hooks | No | — | partial [^5] | No | Yes [^28] | No | No | No | No | No | — | — | +| Multi-browser | Chromium only (webview) | Chromium/FF/WebKit | All 3 | Chromium/FF/Edge | All 3 [^8] | 3500+ real | All | All | Cross-browser [^17] | Cross-browser [^19] | via Grid [^21] | Cross-browser | +| Mobile support | No | emulated | emulated | emulated | emulated | real devices | emulated | real+emu | real+emu | emulated | emulated | emulated | +| Flake detection | No | No | No (trace only) | Test Replay [^7] | Yes (retry stats) | Yes | Yes | Auto TFA [^15] | Reports | Yes | partial | partial | +| Parallel execution | Single runner | `--workers` | `--workers` | CI only | Multi-region par. | Yes | Yes | Yes | Yes | Yes | Ultrafast Grid | Yes | +| Cloud execution | No (local only) | local | local | Cloud add-on | Cloud-only [^9] | Cloud | Cloud | Cloud | Hybrid | Cloud-only | Cloud | Cloud | +| Local execution | Yes (Electron) | Yes | Yes | Yes | via CLI | via agent | limited | limited | Yes | No | N/A | N/A | +| Desktop/Electron app | Yes (the host) | — | — | — | — | — | Electron app | — | Yes (desktop-native) | — | — | — | +| In-app dev-server mgmt | Yes (runners feature) | — | — | — | — | — | — | — | — | — | — | — | +| Pricing floor (public) | free (OSS candidate) | free | free | OSS + $75 Cloud | $0 / $40 Team [^10] | $129 Automate [^12] | ~$299/mo [^13] | ~$499/mo quote [^16] | $890+ perpetual [^17] | $200/mo [^20] | quote | free 5k / $199+ [^22] | + +--- + +## 3. Gaps in ADC + +Ranked by user impact for the target persona (engineer-PM hybrid using ADC to orchestrate agents + shipped software). + +### H1 — No AI step generation or assertion suggestions +Cypress Studio suggests assertions from DOM diffs [^6]; Mabl's Test Creation Agent drafts whole plans [^15]. ADC's agent-host architecture (Claude CLI, stream-json) is a natural fit to expose a "describe a flow, get steps" panel. **To close:** new `ai-step-generator` service in `src/main/features/test-suite/`, prompt-wraps the existing agent runtime, returns structured `RecordedStep[]` that plug into the Recording tab's reducer. + +### H2 — No self-healing selectors +Every commercial competitor has this; our `selector-builder.ts` emits one locator and dies on DOM drift. **To close:** capture N backup locators per element at record time (role, label, text, data-testid, CSS path, parent-relative), persist on the step, and on run failure walk the fallback list before marking the step failed. No ML needed for v1 — ranked fallback list gets 80% of the value. + +### H3 — No data-driven testing +Backend has `data-runner.ts` plus `DataRunDialog` in the renderer (per recent commit `23087166`), but there is no CSV/JSON fixture binding to recorded scripts. Playwright supports this natively via `forEach` parameterization [^27]. **To close:** add `dataSourceId` FK on scripts, CSV upload in Library, render `{{varName}}` tokens in step inputs, iterate at runtime. + +### H4 — No perceptual / AI-aware visual diff +`diff-engine` is pixel-based. Percy's Visual Review Agent filters ~40% of false positives [^23]; Applitools Visual AI ignores anti-aliasing [^21]. Our current pipeline floods reviewers on any font-rendering change. **To close:** add `diff-engine-v2` using pixelmatch with anti-aliasing flag enabled, plus region-ignore rectangles persisted on the baseline. AI/perceptual is a v3 concern. + +### H5 — No flake detection / retry analytics +Mabl's Auto TFA and TestDino's grouping [^28] auto-cluster failures. ADC's `analytics` service has sparklines but no "this test failed 3 of last 10 runs — likely flake" signal. **To close:** add `flakeScore` column computed nightly (failures / runs over trailing 14d), surface as pill in Library tab. + +### H6 — Single-browser coverage +We record via Chromium webview only. Playwright supports Firefox and WebKit [^3]. **To close:** add browser toggle in Run panel, pass `--project` flag to `runner.ts`, store per-browser screenshot baselines. + +### M7 — No cross-origin / shadow-DOM recording guardrails +Cypress Studio warns on these limits [^6]; we silently produce broken locators. **To close:** detect `document` boundary and shadowRoot traversal in the webview preload script, show an inline warning step-by-step. + +### M8 — No real-device or mobile emulation UI +BrowserStack/Reflect both target this. Low priority for ADC's initial persona but capped ceiling without it. **To close:** Playwright device descriptors in a dropdown; no backend work beyond the flag. + +### M9 — No parallel execution for local runs +`runner.ts` executes serially. Playwright has `--workers` [^3]. **To close:** expose workers count in Run panel, pipe through. + +### L10 — No trace viewer integration +Playwright's trace viewer is the gold standard for post-hoc debugging [^4]. Our Results tab shows logs only. **To close:** store `trace.zip` from `playwright.config` and open with `npx playwright show-trace` via `shell.openPath` — no custom UI needed. + +### L11 — No cloud execution +Checkly's core differentiator [^9]. Not aligned with ADC's local-first ethos; explicitly out of scope unless a cloud-sync SKU emerges. + +### L12 — No accessibility (WCAG) scanning +BrowserStack bundles this [^11]; `axe-playwright` is a single dependency away. Low effort, low immediate demand. + +--- + +## 4. Differentiators / Strengths + +1. **Local-first, offline-capable Electron host.** Every competitor above (except Ranorex, TestCafe Studio, and Playwright's own CLI) requires either a cloud account or live internet. ADC works on a plane. +2. **Integrated with agent orchestration.** Nothing in this market ships a Claude CLI session next to the test recorder. The runway for "agent writes the test, runs it, observes it, fixes the app, re-runs" is uniquely open to ADC. +3. **Runners feature adjacency.** In-app dev-server lifecycle management (`src/renderer/features/runners/`) scoped per project/worktree with supervisor auto-restart is a workflow no competitor ships. Test runs can deterministically depend on a known-green dev server state. +4. **Single SQLite source of truth.** Library + Results + Screenshots + Analytics all query one DB. Competitors split data across cloud service, local fs, and CI artifacts — diff-ing a 6-month trend in ADC is one SQL query. +5. **GitHub Actions export as a first-class tab.** Most competitors treat CI as an afterthought; ADC generates the workflow YAML deliberately. Combined with the runners feature, this closes the dev-to-CI loop inside one app. +6. **Tight domain-tagged shared-step library.** The `shared-steps-store` with per-domain (auth / checkout / nav) insertion matches how real teams reuse flows — competitors either ship fixtures (devs-only) or opaque "modules" (no-code-only). + +--- + +## 5. Recommended Upgrades + +Ordered by (impact × feasibility) / cost. Quick wins first. + +### QW-1. Ranked selector fallback list (closes H2) +**Value:** Kills the #1 reason recorded tests break. Competitive parity table-stake. +**Scope:** 2–3 days. Extend `selector-builder.ts` to emit `{ primary: Locator, fallbacks: Locator[] }` per step. Extend `runner.ts` to retry next fallback on `TimeoutError`. DB migration: JSON column on step. +**Prereqs:** None. +**Quick win:** Yes. + +### QW-2. Playwright trace viewer handoff (closes L10) +**Value:** 10× better debugging for one afternoon of work. +**Scope:** 1 day. Set `trace: 'on-first-retry'` in `playwright-config-writer`, copy `trace.zip` to result dir, add "Open trace" button in Results tab that shells out to `npx playwright show-trace`. +**Prereqs:** None. +**Quick win:** Yes. + +### QW-3. Multi-browser toggle (closes H6) +**Value:** Real cross-browser coverage with zero UI invention. +**Scope:** 2 days. Browser chip-group in Run panel; extend `playwright-config-writer` with project per selected browser; per-browser baseline key in `baseline-store`. +**Prereqs:** None (WebKit and Firefox ship with `@playwright/test`). +**Quick win:** Yes. + +### QW-4. Anti-aliasing-aware pixelmatch (closes H4 v1) +**Value:** Cuts visual-diff false-positive rate without an ML budget. Foundation for MS-5a. +**Scope:** 2 days. Flip `includeAA: true` in `diff-engine`, add ignore-region UI to `DiffViewer.tsx` (draw rectangles, persist to baseline metadata). +**Prereqs:** None. +**Quick win:** Yes. + +### MS-4a. Viewport profiles (new — H-tier gap not listed in §3) +**Value:** Every shipped UI now ships mobile + desktop. Without this, the feature only tests one layout breakpoint per project. Also the blocker for grid-normalized diff (MS-5a) and proper cross-device CI. +**Scope:** 3–4 days. +- Schema: new `viewport_profiles` table (id, name, width, height, deviceScaleFactor, userAgent, touch, isMobile) with seeded rows for `desktop-1440`, `tablet-768`, `mobile-375` mapped to Playwright `devices[...]` where possible. +- Per-script field: `viewports: string[]` (IDs into the table). Default = `['desktop-1440']`. Migrate existing scripts with this default. +- Runner fan-out: `runner.ts` iterates selected profiles and emits one run per (script × profile), grouped under a parent run ID. +- `baseline-store`: key baselines by `(scriptId, viewportId, stepIndex)` instead of `(scriptId, stepIndex)`. Migration must rekey existing rows to `desktop-1440`. +- UI: viewport multiselect chip-group in `ScriptSelector` + `ConfigEditDialog`; per-viewport columns in Results tab; side-by-side viewport comparison in `DiffViewer.tsx`. +- `playwright-config-writer`: emit one Playwright `project` per profile using `devices[name]` or manual `viewport` config. +**Prereqs:** None. +**Quick win:** No — infrastructure. Required for MS-5a. + +### MS-5a. Grid-normalized visual diff + spatial selector fallback (replaces H4 v2) +**Value:** Unique differentiator. Kills viewport-size flake that plain pixelmatch cannot solve, and adds a spatial fallback selector that survives DOM rewrites. No competitor ships grid-based diff — Applitools and Percy solve the same problem with proprietary ML; we solve it with configurable math. +**Scope:** 4–5 days on top of MS-4a. +- **Grid diff engine:** divide viewport into N×M cells (default 24×16, configurable per test). For each cell, compute a structural fingerprint (perceptual hash + color histogram). First-pass compare = fingerprint distance per cell. For cells flagged above threshold, run pixelmatch locally inside the cell with AA-aware compare. Cell-boundary halo smoothing to avoid sub-pixel text crossing cells triggering both neighbors. +- **Granularity:** per-test `gridDensity: 'coarse' | 'standard' | 'fine'` (12×8 / 24×16 / 48×32). UI slider in `ConfigEditDialog`. +- **Spatial selector fallback:** at record time, capture `{ cellCol, cellRow, offsetWithinCell }` for each interacted element. Add to the ranked selector fallback list from QW-1 as the last-resort locator ("element nearest cell [14, 3]"). Runner falls back to spatial match only after DOM selectors all fail. +- **UI:** overlay toggle in `DiffViewer.tsx` that shows the grid + highlights flagged cells with severity shading; click cell → inspect pixel-diff within. +- **Baseline storage:** add `grid_fingerprint.json` sibling file next to each baseline PNG (per viewport). +**Prereqs:** MS-4a (viewport profiles) — grid coordinates only make sense relative to a known viewport. QW-1 (ranked selector fallback) — grid coord is an entry in that list. +**Quick win:** No — but highest-ceiling differentiator on this list. + +### MS-5. Data-driven runner wiring (closes H3) +**Value:** Unlocks real test scale (one script × N rows beats N scripts). +**Scope:** 1 sprint. CSV/JSON upload in Library, `{{token}}` substitution in `script-writer`, iteration loop in `data-runner` (stub exists), per-row result rollup in `analytics`. +**Prereqs:** Schema migration for `data_sources` table. +**Quick win:** No — multi-sprint-ish but mostly plumbing. + +### MS-6. AI step generation via existing agent host (closes H1) +**Value:** Unique differentiator nobody else can ship this cheaply. +**Scope:** 1 sprint. New IPC domain `test-suite-ai`, prompt-template service, `AiStepDraftPanel.tsx` in Recording tab. Claude emits `RecordedStep[]` JSON matching existing schema. +**Prereqs:** Lock the `RecordedStep` schema so the LLM output contract is stable; agent-host already streams JSON. +**Quick win:** No, but compound leverage is highest on this list. + +### MS-7. Flake score + retry analytics (closes H5) +**Value:** Turns run history from log archive into signal. +**Scope:** 1 sprint. Nightly job in `analytics` service computes `flakeScore`, pill component in Library tab, dedicated "Flakes" sub-view under Analytics. Optionally cluster failures by stack-trace hash. +**Prereqs:** Ensure run results persist failure stack text (verify in `runner.ts`). +**Quick win:** No. + +### MS-8. Cross-origin + shadow-DOM recorder guardrails (closes M7) +**Value:** Stops users recording tests that can't possibly run. +**Scope:** 3–5 days. Preload script in recording webview detects boundary crossings, emits inline warning step. +**Prereqs:** None. +**Quick win:** Borderline — closer to QW if scope stays small. + +**Recommended 2-sprint cut (revised 2026-04-16 after design review):** +- **Sprint 1:** MS-4a (viewport profiles) + QW-1 (ranked selector fallback) + QW-2 (trace viewer handoff) + QW-4 (AA-aware pixelmatch + ignore regions). MS-4a is load-bearing for the rest, so it goes first. +- **Sprint 2:** MS-5a (grid-normalized diff + spatial selector fallback) + QW-3 (multi-browser toggle). MS-6 (AI step gen) and MS-7 (flake score) queued as stretch. +- **Sprint 3:** MS-5 (data-driven runner) + MS-6 (AI step gen) + MS-8 (cross-origin guardrails). + +**Rationale for reordering:** the 2026-04-16 review elevated viewport-size flake and spatial-fallback selection as differentiating features, which together require viewport profiles as a foundation. Doing viewport profiles first (MS-4a) keeps every downstream upgrade compatible; doing it later would require re-keying baselines and re-running scripts. + +--- + +## 6. Sources + +[^1]: Playwright Codegen docs, "Test generator." https://playwright.dev/docs/codegen — accessed 2026-04-16. +[^2]: Software Testing Tutorials, "How to Use Playwright Recorder to Automatically Generate Test." https://software-testing-tutorials-automation.com/2025/04/playwright-recorder-codegen.html — accessed 2026-04-16. +[^3]: Playwright UI Mode docs. https://playwright.dev/docs/test-ui-mode — accessed 2026-04-16. +[^4]: Playwright Trace Viewer docs. https://playwright.dev/docs/trace-viewer — accessed 2026-04-16. +[^5]: Playwright release notes (1.59+). https://playwright.dev/docs/release-notes — accessed 2026-04-16. +[^6]: Cypress Studio docs. https://docs.cypress.io/app/guides/cypress-studio — accessed 2026-04-16. +[^7]: DEV Community, "Cypress Test Replay in 2025." https://dev.to/cypress/cypress-test-replay-in-2025-the-ultimate-guide-to-time-travel-debugging-5485 — accessed 2026-04-16. +[^8]: Checkly docs, "Playwright Support." https://www.checklyhq.com/docs/detect/synthetic-monitoring/browser-checks/playwright-support/ — accessed 2026-04-16. +[^9]: Checkly product page, "Synthetic Monitoring." https://www.checklyhq.com/product/synthetic-monitoring/ — accessed 2026-04-16. +[^10]: Checkly pricing page. https://www.checklyhq.com/pricing/ — accessed 2026-04-16. +[^11]: BrowserStack Low-Code Automation features. https://www.browserstack.com/low-code-automation/features — accessed 2026-04-16. +[^12]: BrowserStack pricing. https://www.browserstack.com/pricing — accessed 2026-04-16. +[^13]: Tricentis Testim product page. https://www.tricentis.com/products/test-automation-web-apps-testim — accessed 2026-04-16. +[^14]: Testim pricing summary (SaaSWorthy). https://www.saasworthy.com/product/testim-io/pricing — accessed 2026-04-16. +[^15]: Mabl product page. https://www.mabl.com/ — accessed 2026-04-16. +[^16]: TestGuild Mabl review (pricing estimate). https://testguild.com/tools/mabl — accessed 2026-04-16. +[^17]: Ranorex Studio profile (Software Advice). https://www.softwareadvice.com/automation-testing/ranorex-studio-profile/ — accessed 2026-04-16. +[^18]: Ranorex subscription licensing announcement. https://www.ranorex.com/blog/ranorex-introduces-subscription-licensing/ — accessed 2026-04-16. +[^19]: Reflect.run product page. https://reflect.run/ — accessed 2026-04-16. +[^20]: Reflect.run pricing. https://reflect.run/pricing/ — accessed 2026-04-16. +[^21]: Applitools Eyes platform page. https://applitools.com/platform/eyes/ — accessed 2026-04-16. +[^22]: Percy pricing. https://percy.io/pricing — accessed 2026-04-16. +[^23]: Percy by BrowserStack product page. https://www.browserstack.com/percy — accessed 2026-04-16. +[^24]: DevExpress TestCafe Studio pricing. https://www.componentsource.com/product/testcafe-studio/prices — accessed 2026-04-16. +[^25]: Testsigma, "TestCafe vs Playwright." https://testsigma.com/blog/testcafe-vs-playwright/ — accessed 2026-04-16. +[^26]: Playwright Fixtures docs. https://playwright.dev/docs/test-fixtures — accessed 2026-04-16. +[^27]: Playwright Parameterize tests docs. https://playwright.dev/docs/test-parameterize — accessed 2026-04-16. +[^28]: TestDino, "Playwright AI Ecosystem 2026: MCP, Agents & Self-Healing Tests." https://testdino.com/blog/playwright-ai-ecosystem/ — accessed 2026-04-16. diff --git a/docs/routing/AI-AGENT-ROUTING-INDEX.md b/docs/routing/AI-AGENT-ROUTING-INDEX.md index 5b13015f..05d8accb 100644 --- a/docs/routing/AI-AGENT-ROUTING-INDEX.md +++ b/docs/routing/AI-AGENT-ROUTING-INDEX.md @@ -34,7 +34,7 @@ Each row traces a domain from shared types through to the rendered route. | github | `types/github.ts` | `ipc/github/` | `services/github/` | `handlers/github-handlers.ts` | `features/github/` | `project.routes.ts` | | hotkeys | -- | `ipc/misc/hotkeys.contract.ts` | -- (tray service) | `handlers/hotkey-handlers.ts` | -- | -- | | hub | `types/hub-protocol.ts`, `types/hub/` | `ipc/hub/` | `services/hub/` | `handlers/hub-handlers.ts` | `features/settings/` (HubSettings) | `settings.routes.ts` | -| ideation | `types/idea.ts` | `ipc/misc/ideas.contract.ts` | `services/ideas/` | `handlers/ideas-handlers.ts` | `features/ideation/` | `project.routes.ts` | +| ideation | `types/idea.ts` | `ipc/misc/ideas.contract.ts` | `services/ideas/` | `handlers/ideas-handlers.ts` | `features/ideas/` | `project.routes.ts` | | insights | `types/insights.ts` | `ipc/misc/insights.contract.ts` | `services/insights/` | `handlers/insights-handlers.ts` | `features/insights/` | `project.routes.ts` | | mcp | -- | `ipc/misc/mcp.contract.ts` | `main/mcp/` | `handlers/mcp-handlers.ts` | `features/communications/` | -- | | merge | -- | `ipc/misc/merge.contract.ts` | `services/merge/` | `handlers/merge-handlers.ts` | `features/merge/` | -- | @@ -53,7 +53,7 @@ Each row traces a domain from shared types through to the rendered route. | tasks | `types/progress.ts` | `ipc/progress/` | `features/progress/progress-service.ts` (SQLite — sole source of truth, old TaskService/TaskRepository deleted) | `features/progress/progress-handlers.ts` | `features/tasks/` | `project.routes.ts` | | terminals | `types/terminal.ts` | `ipc/terminals/` | `services/terminal/` | `handlers/terminal-handlers.ts` | `features/terminals/` | `project.routes.ts` | | voice | `types/voice.ts` | `ipc/misc/voice.contract.ts` | `services/voice/` | `handlers/voice-handlers.ts` | `features/voice/` | -- | -| workspaces | `types/workspace.ts` | `ipc/misc/workspaces.contract.ts` | -- | `handlers/workspace-handlers.ts` | `features/workspaces/` | -- | +| workspaces | `types/workspace.ts` | `ipc/misc/workspaces.contract.ts` | -- | `handlers/workspace-handlers.ts` | `features/workspace/` | -- | | progress | `types/progress.ts` | `ipc/progress/` | `services/progress/` | `handlers/progress-handlers.ts` | `features/tasks/` (ProgressTaskGrid) | `project.routes.ts` | | workflow-templates | -- | `ipc/workflow-templates/` | `services/workflow-templates/` | `handlers/workflow-template-handlers.ts` | `features/tools/` | `misc.routes.ts` | | workflow-engine | -- | `ipc/workflow-engine/` | `services/workflow-engine/` | `handlers/workflow-engine-handlers.ts` | -- | -- | @@ -452,11 +452,11 @@ Idea capture & tracking. | IPC Contract | `shared/ipc/misc/ideas.contract.ts` | | Service | `main/services/ideas/` | | Handler | `main/ipc/handlers/ideas-handlers.ts` | -| Feature Module | `renderer/features/ideation/` | -| API Hooks | `renderer/features/ideation/api/useIdeas.ts` | -| Query Keys | `renderer/features/ideation/api/queryKeys.ts` | -| Event Hook | `renderer/features/ideation/hooks/useIdeaEvents.ts` | -| Store | `renderer/features/ideation/store.ts` | +| Feature Module | `renderer/features/ideas/` | +| API Hooks | `renderer/features/ideas/api/useIdeas.ts` | +| Query Keys | `renderer/features/ideas/api/queryKeys.ts` | +| Event Hook | `renderer/features/ideas/hooks/useIdeaEvents.ts` | +| Store | `renderer/features/ideas/store.ts` | | Components | `IdeationPage`, `IdeaForm` | | Route | `renderer/app/routes/project.routes.ts` → `/projects/$projectId/ideation` | @@ -810,11 +810,11 @@ Workspace management via Hub. | IPC Contract | `shared/ipc/misc/workspaces.contract.ts` | | Service | -- (handled via Hub API client directly) | | Handler | `main/ipc/handlers/workspace-handlers.ts` | -| Feature Module | `renderer/features/workspaces/` | -| API Hooks | `renderer/features/workspaces/api/useWorkspaces.ts` | -| Query Keys | `renderer/features/workspaces/api/queryKeys.ts` | -| Event Hook | `renderer/features/workspaces/hooks/useWorkspaceEvents.ts` | -| Store | `renderer/features/workspaces/store.ts` | +| Feature Module | `renderer/features/workspace/` | +| API Hooks | `renderer/features/workspace/api/useWorkspaces.ts` | +| Query Keys | `renderer/features/workspace/api/queryKeys.ts` | +| Event Hook | `renderer/features/workspace/hooks/useWorkspaceEvents.ts` | +| Store | `renderer/features/workspace/store.ts` | | Components | `WorkspaceCard`, `WorkspacesTab`, `WorkspaceEditor` | --- @@ -1138,7 +1138,7 @@ The `src/shared/ipc/misc/` folder bundles 17 smaller domain contracts. Here's wh | `notes.contract.ts` | `notes.*` | `notes-handlers.ts` | `services/notes/` | | `rate-limit.contract.ts` | `rateLimit.*` | -- (internal) | -- | | `screen.contract.ts` | `screen.*` | `screen-handlers.ts` | `services/screen/` | -| `time.contract.ts` | `time.*` | `time-handlers.ts` | `services/time-parser/` | +| ~~`time.contract.ts`~~ | ~~`time.*`~~ | ~~`time-handlers.ts`~~ | REMOVED — time-parser feature deleted (Sprint 6-7) | | `voice.contract.ts` | `voice.*` | `voice-handlers.ts` | `services/voice/` | | `webhook.contract.ts` | `webhooks.*` | `webhook-settings-handlers.ts` | `services/settings/` | | `workspaces.contract.ts` | `workspaces.*` | `workspace-handlers.ts` | -- (Hub API) | diff --git a/docs/routing/FEATURES-INDEX.md b/docs/routing/FEATURES-INDEX.md index 4bc38b35..c0cc2e86 100644 --- a/docs/routing/FEATURES-INDEX.md +++ b/docs/routing/FEATURES-INDEX.md @@ -110,7 +110,7 @@ Location: `src/main/services/` | **email** | Email sending (SMTP, SQLite-backed). Sub-modules: `email-encryption.ts`, `email-queue.ts` (SQLite via `emailQueue` table), `email-store.ts` (SQLite via `emailConfig` table), `smtp-transport.ts` (barrel: `index.ts`). Deps: `db`, `dataDir`. One-time JSON migration from `email-config.json`. | sendEmail, getConfig, testConnection | - | | **notifications** | Background Slack/GitHub watchers, SQLite-backed. Sub-modules: `slack-watcher.ts`, `github-watcher.ts`, `notification-filter.ts`, `notification-manager.ts`, `notification-store.ts` (SQLite via `notifications` + `notificationConfig` tables; barrel: `index.ts`) | startWatching, stopWatching, getNotifications | `event:notification.*` | | **progress** | SQLite-backed progress tracking — sole source of truth for all task data (old `.adc/specs/` filesystem system and `TaskService`/`TaskRepository` fully removed). Sub-modules: `progress-service.ts`, `task-file-io.ts`, `schema.ts` (Drizzle ORM, `progress_tasks` table with UUID `id` column). All consumers (My Work, Workflow Pipeline, Assistant, Briefing, QA, Insights) read from this service. | listTasks, getTask, createTask, updateTask, deleteTask | `event:progress.*` | -| **time-parser** | Natural language time parsing | parseTimeExpression | - | +| ~~**time-parser**~~ | REMOVED Sprint 6-7 — NLP time parsing feature deleted | - | - | | **voice** | Voice interface (Web Speech API) | startListening, stopListening, speak | `event:voice.*` | | **device** | Device registration & heartbeat via Hub API | registerDevice, updateDevice, sendHeartbeat | `event:hub.devices.*` | | **command-bus** | Unified command bus for all agent lifecycle operations. SQLite-backed (Drizzle ORM). Dispatches commands, tracks sessions. Location: `src/main/bus/`. Sub-modules: `command-bus.ts`, `session-manager.ts`, `dispatcher.ts` | dispatch, query, subscribe | `event:bus.*` | @@ -310,7 +310,7 @@ The IPC contract has been split from a single monolithic file into **27 domain f | `data-management` | Data store registry, retention settings, cleanup, usage, export/import (8 invoke + 1 event) | | `security` | Security settings, audit export (3 channels) | | `window` | Window controls (minimize, maximize, close, isMaximized) | -| `tracker` | Plan tracker (list, get, update docs/tracker.json via IPC) | +| ~~`tracker`~~ | REMOVED Sprint 9 — tracker IPC domain deleted (no renderer callers) | | `workflow` | Workflow execution | | `progress` | SQLite-backed task pipeline CRUD + Research→Plan→Team actions + FS-watch events (13 invoke + 7 event channels) | | `workflow-templates` | Workflow template CRUD, artifact scanning/writing (7 invoke + 3 event channels) | diff --git a/docs/superpowers/plans/2026-04-08-hub-relay-gaps.md b/docs/superpowers/plans/2026-04-08-hub-relay-gaps.md deleted file mode 100644 index ef44b81d..00000000 --- a/docs/superpowers/plans/2026-04-08-hub-relay-gaps.md +++ /dev/null @@ -1,922 +0,0 @@ -# Hub Relay Gaps — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix all known bugs, complete missing tasks, and add test/doc coverage for the hub-relay feature so it can ship. - -**Architecture:** The relay system enables cross-device Claude sessions. A "claimer" device claims a project hosted on a remote device, then spawns/streams/kills sessions through the Hub WebSocket relay. The implementation lives across shared IPC contracts, main-process services, Hub server routes, and renderer React Query hooks. This plan fixes 3 IPC gaps, wires the heartbeat service properly, builds the missing relay session UI hook, and adds tests + docs. - -**Tech Stack:** TypeScript, Zod, Electron IPC, TanStack Query, Vitest, Hub REST API - -**Branch:** `feature/hub-relay` (based on `master` at `2b60eb5`) - ---- - -## File Structure - -| File | Action | Responsibility | -|------|--------|----------------| -| `src/shared/ipc/relay/contract.ts` | Modify | Add `relay.forceReclaim` and `relay.releaseProject` invoke channels | -| `src/shared/ipc/relay/schemas.ts` | Modify | Add `ForceReclaimInputSchema` | -| `src/shared/ipc/projects/contract.ts` | Modify | Add `projects.listAll` invoke channel | -| `src/shared/ipc/projects/schemas.ts` | Modify | Add `ProjectSchema` fields for `remote`, `hostDeviceId`, `hostDeviceName`, `claimedByDeviceId` (if not already present) | -| `src/main/ipc/handlers/relay-handlers.ts` | Modify | Add `relay.forceReclaim` and `relay.releaseProject` handlers | -| `src/main/ipc/handlers/project-handlers.ts` | Modify | Add `projects.listAll` handler | -| `src/main/bootstrap/service-registry.ts` | Modify | Replace inline heartbeat with `createHeartbeatService` | -| `src/renderer/features/workspace/hooks/useRelaySession.ts` | Create | Hook for relay session lifecycle events | -| `src/renderer/features/workspace/api/useWorkspace.ts` | Modify | Add `useSpawnRemoteSession` mutation | -| `src/renderer/features/projects/api/useProjects.ts` | Modify | Add `useAllProjects` query hook | -| `tests/unit/services/relay-service.test.ts` | Create | Unit tests for relay service | -| `tests/integration/ipc-handlers/relay-handlers.test.ts` | Create | Integration tests for relay IPC handlers | -| `docs/routing/FEATURES-INDEX.md` | Modify | Add relay domain entry | -| `docs/routing/AI-AGENT-ROUTING-INDEX.md` | Modify | Add relay domain trace | - ---- - -### Task 1: Add Missing IPC Contract Channels - -**Files:** -- Modify: `src/shared/ipc/relay/contract.ts` -- Modify: `src/shared/ipc/relay/schemas.ts` -- Modify: `src/shared/ipc/projects/contract.ts` - -- [ ] **Step 1: Add `ForceReclaimInputSchema` to relay schemas** - -In `src/shared/ipc/relay/schemas.ts`, add at the bottom before the closing comment: - -```typescript -// ─── Force Reclaim Schema ───────────────────────────────────── - -export const ForceReclaimInputSchema = z.object({ - projectId: z.string(), -}); -``` - -- [ ] **Step 2: Add `relay.forceReclaim` and `relay.releaseProject` to relay contract** - -In `src/shared/ipc/relay/contract.ts`, add these two channels inside `relayInvoke` after `relay.unclaimProject`: - -```typescript - 'relay.forceReclaim': { - input: z.object({ - projectId: z.string(), - }), - output: SuccessWithErrorSchema, - }, - 'relay.releaseProject': { - input: z.object({ - projectId: z.string(), - }), - output: SuccessResponseSchema, - }, -``` - -Also add the `ForceReclaimInputSchema` import if using it (not needed here since we use inline `z.object`). - -- [ ] **Step 3: Add `projects.listAll` to projects contract** - -In `src/shared/ipc/projects/contract.ts`, add inside `projectsInvoke` after `projects.list`: - -```typescript - 'projects.listAll': { - input: z.object({ currentDeviceId: z.string() }), - output: z.array(ProjectSchema), - }, -``` - -- [ ] **Step 4: Run typecheck to verify contract changes compile** - -Run: `npx tsc --noEmit --project tsconfig.json 2>&1 | head -30` -Expected: No new errors from the contract files. There may be errors about missing handlers — that's expected and fixed in Task 2. - -- [ ] **Step 5: Commit** - -```bash -git add src/shared/ipc/relay/contract.ts src/shared/ipc/relay/schemas.ts src/shared/ipc/projects/contract.ts -git commit -m "feat(ipc): add relay.forceReclaim, relay.releaseProject, and projects.listAll contracts" -``` - ---- - -### Task 2: Implement Missing IPC Handlers - -**Files:** -- Modify: `src/main/ipc/handlers/relay-handlers.ts` -- Modify: `src/main/ipc/handlers/project-handlers.ts` - -- [ ] **Step 1: Write failing test for `relay.forceReclaim` handler** - -Create a quick smoke test. In `tests/integration/ipc-handlers/relay-handlers.test.ts`: - -```typescript -/** - * Integration tests for relay IPC handlers - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { HubApiClient } from '@main/services/hub/hub-api-client'; -import type { IpcRouter } from '@main/ipc/router'; - -function createMockHubApiClient(): HubApiClient { - return { - hubGet: vi.fn(), - hubPost: vi.fn(), - hubPatch: vi.fn(), - hubPut: vi.fn(), - hubDelete: vi.fn(), - listTasks: vi.fn(), - getTask: vi.fn(), - createTask: vi.fn(), - updateTask: vi.fn(), - deleteTask: vi.fn(), - pushProgress: vi.fn(), - updateTaskStatus: vi.fn(), - executeTask: vi.fn(), - cancelTask: vi.fn(), - registerDevice: vi.fn(), - heartbeat: vi.fn(), - }; -} - -function createMockRouter(): IpcRouter & { - _handlers: Map<string, (input: unknown) => Promise<unknown>>; - _emitted: Array<{ channel: string; payload: unknown }>; -} { - const handlers = new Map<string, (input: unknown) => Promise<unknown>>(); - const emitted: Array<{ channel: string; payload: unknown }> = []; - - return { - _handlers: handlers, - _emitted: emitted, - handle(channel: string, handler: (input: unknown) => Promise<unknown>) { - handlers.set(channel, handler); - }, - emit(channel: string, payload: unknown) { - emitted.push({ channel, payload }); - }, - } as unknown as IpcRouter & { - _handlers: Map<string, (input: unknown) => Promise<unknown>>; - _emitted: Array<{ channel: string; payload: unknown }>; - }; -} - -describe('registerRelayHandlers', () => { - let router: ReturnType<typeof createMockRouter>; - let hubApiClient: HubApiClient; - const getDeviceId = vi.fn(() => 'device-1'); - - beforeEach(() => { - router = createMockRouter(); - hubApiClient = createMockHubApiClient(); - vi.clearAllMocks(); - }); - - async function loadAndRegister() { - const { registerRelayHandlers } = await import( - '@main/ipc/handlers/relay-handlers' - ); - registerRelayHandlers(router, hubApiClient, getDeviceId); - } - - describe('relay.forceReclaim', () => { - it('calls Hub force-reclaim endpoint and emits projectClaimed event', async () => { - vi.mocked(hubApiClient.hubPost).mockResolvedValueOnce({ - ok: true, - data: { - data: { - projectId: 'proj-1', - claimedByDeviceId: 'device-1', - hostDeviceId: 'device-2', - expiresAt: '2026-04-08T12:00:00Z', - forceReclaim: true, - }, - }, - statusCode: 200, - }); - - await loadAndRegister(); - const handler = router._handlers.get('relay.forceReclaim'); - expect(handler).toBeDefined(); - - const result = await handler!({ projectId: 'proj-1' }); - expect(result).toEqual({ success: true }); - - expect(hubApiClient.hubPost).toHaveBeenCalledWith( - '/api/projects/proj-1/force-reclaim', - { deviceId: 'device-1' }, - ); - - expect(router._emitted).toContainEqual( - expect.objectContaining({ channel: 'event:relay.projectClaimed' }), - ); - }); - - it('returns error when device not registered', async () => { - getDeviceId.mockReturnValueOnce(null); - - await loadAndRegister(); - const handler = router._handlers.get('relay.forceReclaim'); - const result = await handler!({ projectId: 'proj-1' }); - - expect(result).toEqual({ - success: false, - error: 'Device not registered — cannot force-reclaim project', - }); - }); - }); - - describe('relay.releaseProject', () => { - it('calls Hub release endpoint and emits projectUnclaimed event', async () => { - vi.mocked(hubApiClient.hubPost).mockResolvedValueOnce({ - ok: true, - data: { success: true }, - statusCode: 200, - }); - - await loadAndRegister(); - const handler = router._handlers.get('relay.releaseProject'); - expect(handler).toBeDefined(); - - const result = await handler!({ projectId: 'proj-1' }); - expect(result).toEqual({ success: true }); - - expect(hubApiClient.hubPost).toHaveBeenCalledWith( - '/api/projects/proj-1/release', - { deviceId: 'device-1' }, - ); - - expect(router._emitted).toContainEqual( - expect.objectContaining({ channel: 'event:relay.projectUnclaimed' }), - ); - }); - }); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `npx vitest run tests/integration/ipc-handlers/relay-handlers.test.ts 2>&1 | tail -20` -Expected: FAIL — `relay.forceReclaim` handler not registered. - -- [ ] **Step 3: Add `relay.forceReclaim` and `relay.releaseProject` handlers** - -In `src/main/ipc/handlers/relay-handlers.ts`, add after the `relay.unclaimProject` handler: - -```typescript - // ─── relay.forceReclaim ────────────────────────────────────── - router.handle('relay.forceReclaim', async ({ projectId }) => { - const deviceId = getDeviceId(); - if (!deviceId) { - return { success: false, error: 'Device not registered — cannot force-reclaim project' }; - } - - const result = await hubApiClient.hubPost<HubClaimResponse>( - `/api/projects/${encodeURIComponent(projectId)}/force-reclaim`, - { deviceId }, - ); - - if (!result.ok) { - return { success: false, error: result.error ?? 'Failed to force-reclaim project' }; - } - - router.emit('event:relay.projectClaimed', { - projectId, - claimedByDeviceId: deviceId, - claimedAt: new Date().toISOString(), - }); - - return { success: true }; - }); - - // ─── relay.releaseProject ──────────────────────────────────── - router.handle('relay.releaseProject', async ({ projectId }) => { - const deviceId = getDeviceId(); - if (!deviceId) { - throw new Error('Device not registered — cannot release project'); - } - - const result = await hubApiClient.hubPost<{ success: boolean }>( - `/api/projects/${encodeURIComponent(projectId)}/release`, - { deviceId }, - ); - - if (!result.ok) { - throw new Error(result.error ?? 'Failed to release project'); - } - - router.emit('event:relay.projectUnclaimed', { - projectId, - unclaimedAt: new Date().toISOString(), - }); - - return { success: true }; - }); -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `npx vitest run tests/integration/ipc-handlers/relay-handlers.test.ts 2>&1 | tail -20` -Expected: PASS — all 3 tests pass. - -- [ ] **Step 5: Add `projects.listAll` handler** - -In `src/main/ipc/handlers/project-handlers.ts`, inside `registerProjectHandlers`, add after the `projects.list` handler: - -```typescript - // ─── projects.listAll ───────────────────────────────────────── - router.handle('projects.listAll', async ({ currentDeviceId }) => { - return service.listAllProjects(currentDeviceId); - }); -``` - -- [ ] **Step 6: Run typecheck** - -Run: `npx tsc --noEmit --project tsconfig.json 2>&1 | head -30` -Expected: No errors. - -- [ ] **Step 7: Commit** - -```bash -git add src/main/ipc/handlers/relay-handlers.ts src/main/ipc/handlers/project-handlers.ts tests/integration/ipc-handlers/relay-handlers.test.ts -git commit -m "feat(relay): add forceReclaim, releaseProject, and listAll IPC handlers with tests" -``` - ---- - -### Task 3: Wire HeartbeatService in Service Registry - -**Files:** -- Modify: `src/main/bootstrap/service-registry.ts` - -- [ ] **Step 1: Write failing test for heartbeat wiring** - -In `tests/unit/services/relay-service.test.ts`, add a focused test: - -```typescript -/** - * Unit Tests — Relay Service - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { RelayEnvelope, SessionSpawnPayload } from '@shared/types/relay'; - -describe('RelayService', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - async function createFreshService() { - vi.resetModules(); - const { createRelayService } = await import( - '@main/services/relay/relay-service' - ); - return createRelayService(); - } - - describe('spawnRemoteSession()', () => { - it('sends a spawn envelope through the send function', () => { - const sendFn = vi.fn(); - const service = createFreshService(); - - return service.then((s) => { - s.setSendFn(sendFn); - const sessionId = s.spawnRemoteSession('host-1', 'proj-1', { - agentRole: 'coder', - prompt: 'Fix the bug', - workDir: '/path/to/project', - taskId: 'task-1', - }); - - expect(sessionId).toMatch(/^relay-/); - expect(sendFn).toHaveBeenCalledOnce(); - - const envelope = sendFn.mock.calls[0][0] as RelayEnvelope; - expect(envelope.type).toBe('spawn'); - expect(envelope.sessionId).toBe(sessionId); - }); - }); - }); - - describe('isRemoteProject()', () => { - it('returns false for unregistered projects', async () => { - const service = await createFreshService(); - expect(service.isRemoteProject('proj-1')).toBe(false); - }); - - it('returns true after registerRemoteProject', async () => { - const service = await createFreshService(); - service.registerRemoteProject('proj-1', 'host-1'); - expect(service.isRemoteProject('proj-1')).toBe(true); - }); - - it('returns false after unregisterRemoteProject', async () => { - const service = await createFreshService(); - service.registerRemoteProject('proj-1', 'host-1'); - service.unregisterRemoteProject('proj-1'); - expect(service.isRemoteProject('proj-1')).toBe(false); - }); - }); - - describe('getHostDeviceId()', () => { - it('returns undefined for unregistered projects', async () => { - const service = await createFreshService(); - expect(service.getHostDeviceId('proj-1')).toBeUndefined(); - }); - - it('returns host device ID for registered projects', async () => { - const service = await createFreshService(); - service.registerRemoteProject('proj-1', 'host-1'); - expect(service.getHostDeviceId('proj-1')).toBe('host-1'); - }); - }); - - describe('killSession()', () => { - it('sends a kill envelope and removes session state', async () => { - const sendFn = vi.fn(); - const service = await createFreshService(); - service.setSendFn(sendFn); - - const sessionId = service.spawnRemoteSession('host-1', 'proj-1', { - agentRole: 'coder', - prompt: 'Fix the bug', - workDir: '/path/to/project', - taskId: 'task-1', - }); - - sendFn.mockClear(); - service.killSession(sessionId, 'user cancelled'); - - expect(sendFn).toHaveBeenCalledOnce(); - const envelope = sendFn.mock.calls[0][0] as RelayEnvelope; - expect(envelope.type).toBe('kill'); - expect(envelope.sessionId).toBe(sessionId); - }); - - it('logs warning for unknown session', async () => { - const service = await createFreshService(); - // Should not throw - service.killSession('nonexistent-session'); - }); - }); - - describe('handleHubMessage()', () => { - it('ignores non-relay messages', async () => { - const service = await createFreshService(); - // Should not throw - service.handleHubMessage({ type: 'notification', data: {} }); - service.handleHubMessage('not an object'); - service.handleHubMessage(null); - }); - }); -}); -``` - -- [ ] **Step 2: Run the relay service tests** - -Run: `npx vitest run tests/unit/services/relay-service.test.ts 2>&1 | tail -20` -Expected: PASS — all tests pass (these test the existing service, confirming it works). - -- [ ] **Step 3: Replace inline heartbeat with HeartbeatService** - -In `src/main/bootstrap/service-registry.ts`, find the heartbeat section (around lines 315-355). Replace the inline `heartbeatIntervalId` / `setInterval` block. - -**Remove** the following variables and inline heartbeat code: -```typescript - let heartbeatIntervalId: ReturnType<typeof setInterval> | null = null; - // ... the entire setInterval block inside registerDeviceAndStartHeartbeat -``` - -**Replace** with: - -```typescript - const heartbeatService = createHeartbeatService({ - deviceService, - projectSource: projectService, - intervalMs: HEARTBEAT_INTERVAL_MS, - }); -``` - -Add the import at the top of the file: -```typescript -import { createHeartbeatService } from '../services/device/heartbeat'; -``` - -Inside `registerDeviceAndStartHeartbeat`, after the device registration succeeds, replace the `setInterval` block with: -```typescript - heartbeatService.start(result.data.id); - appLogger.info('[Hub] Heartbeat service started (30s)'); -``` - -Remove `heartbeatIntervalId` from the returned registry object. Add `heartbeatService` if the registry exposes it (check existing return shape). - -- [ ] **Step 4: Run typecheck** - -Run: `npx tsc --noEmit --project tsconfig.json 2>&1 | head -30` -Expected: No errors. - -- [ ] **Step 5: Run lint** - -Run: `npx eslint src/main/bootstrap/service-registry.ts --max-warnings 0 2>&1 | tail -10` -Expected: No warnings. - -- [ ] **Step 6: Commit** - -```bash -git add src/main/bootstrap/service-registry.ts tests/unit/services/relay-service.test.ts -git commit -m "fix(relay): wire HeartbeatService with project source in service registry" -``` - ---- - -### Task 4: Add `useAllProjects` Query Hook - -**Files:** -- Modify: `src/renderer/features/projects/api/useProjects.ts` -- Modify: `src/renderer/features/projects/api/queryKeys.ts` - -- [ ] **Step 1: Add `useAllProjects` hook** - -In `src/renderer/features/projects/api/useProjects.ts`, add after the `useProjects` hook: - -```typescript -/** Fetch all projects across all connected devices (includes remote projects) */ -export function useAllProjects() { - const { currentDeviceId } = useDeviceStore(); - return useQuery({ - queryKey: projectKeys.allProjects(), - queryFn: () => ipc('projects.listAll', { currentDeviceId: currentDeviceId ?? '' }), - enabled: currentDeviceId !== null && currentDeviceId.length > 0, - staleTime: 60_000, - }); -} -``` - -Add the import at the top: -```typescript -import { useDeviceStore } from '@features/devices'; -``` - -- [ ] **Step 2: Run typecheck** - -Run: `npx tsc --noEmit --project tsconfig.json 2>&1 | head -30` -Expected: No errors (the `projects.listAll` channel was added in Task 1). - -- [ ] **Step 3: Commit** - -```bash -git add src/renderer/features/projects/api/useProjects.ts -git commit -m "feat(projects): add useAllProjects hook for cross-device project list" -``` - ---- - -### Task 5: Create `useRelaySession` Hook - -**Files:** -- Create: `src/renderer/features/workspace/hooks/useRelaySession.ts` -- Modify: `src/renderer/features/workspace/api/useWorkspace.ts` - -- [ ] **Step 1: Create the relay session hook** - -Create `src/renderer/features/workspace/hooks/useRelaySession.ts`: - -```typescript -/** - * useRelaySession — listens for relay session events and normalizes - * them into a shape consumable by the workspace session list. - * - * Subscribes to: - * event:relay.sessionSpawned — adds a new remote session entry - * event:relay.sessionOutput — appends output to the session's buffer - * event:relay.sessionEnded — marks the session as completed - */ - -import { useCallback, useRef, useState } from 'react'; - -import { useIpcEvent } from '@renderer/shared/hooks'; - -export interface RelaySessionEntry { - sessionId: string; - projectId: string; - status: 'running' | 'completed' | 'error'; - exitCode?: number; - outputLines: string[]; - startedAt: string; - endedAt?: string; -} - -/** - * Tracks relay sessions for a specific project. - * Returns the current list of relay sessions and a clear function. - */ -export function useRelaySession(projectId: string | null) { - const sessionsRef = useRef<Map<string, RelaySessionEntry>>(new Map()); - const [sessions, setSessions] = useState<RelaySessionEntry[]>([]); - - const sync = useCallback(() => { - setSessions([...sessionsRef.current.values()]); - }, []); - - useIpcEvent( - 'event:relay.sessionSpawned', - useCallback( - (payload: { sessionId: string; projectId: string }) => { - if (projectId !== null && payload.projectId !== projectId) return; - - sessionsRef.current.set(payload.sessionId, { - sessionId: payload.sessionId, - projectId: payload.projectId, - status: 'running', - outputLines: [], - startedAt: new Date().toISOString(), - }); - sync(); - }, - [projectId, sync], - ), - ); - - useIpcEvent( - 'event:relay.sessionOutput', - useCallback( - (payload: { sessionId: string; output: { data: string } }) => { - const entry = sessionsRef.current.get(payload.sessionId); - if (!entry) return; - - entry.outputLines.push(payload.output.data); - sync(); - }, - [sync], - ), - ); - - useIpcEvent( - 'event:relay.sessionEnded', - useCallback( - (payload: { - sessionId: string; - ended: { exitCode: number; endedAt: string }; - }) => { - const entry = sessionsRef.current.get(payload.sessionId); - if (!entry) return; - - entry.status = payload.ended.exitCode === 0 ? 'completed' : 'error'; - entry.exitCode = payload.ended.exitCode; - entry.endedAt = payload.ended.endedAt; - sync(); - }, - [sync], - ), - ); - - const clearSessions = useCallback(() => { - sessionsRef.current.clear(); - setSessions([]); - }, []); - - return { relaySessions: sessions, clearRelaySessions: clearSessions }; -} -``` - -- [ ] **Step 2: Add `useSpawnRemoteSession` mutation to useWorkspace** - -In `src/renderer/features/workspace/api/useWorkspace.ts`, add at the bottom of the file: - -```typescript -/** Spawn a Claude session on a remote host device via relay */ -export function useSpawnRemoteSession() { - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: (data: { - hostDeviceId: string; - projectId: string; - payload: { - agentRole: string; - prompt: string; - workDir: string; - taskId: string; - }; - }) => ipc('relay.spawnSession', data), - onError: onError('spawn remote session'), - }); -} -``` - -Add the missing import if not already present: -```typescript -import { useMutationErrorToast } from '@renderer/shared/hooks'; -``` - -- [ ] **Step 3: Export from workspace barrel** - -Verify `src/renderer/features/workspace/index.ts` exports the new hook. If a barrel exists, add: -```typescript -export { useRelaySession } from './hooks/useRelaySession'; -``` - -If there's no barrel, skip this — consumers import directly. - -- [ ] **Step 4: Run typecheck** - -Run: `npx tsc --noEmit --project tsconfig.json 2>&1 | head -30` -Expected: No errors. - -- [ ] **Step 5: Run lint** - -Run: `npx eslint src/renderer/features/workspace/hooks/useRelaySession.ts src/renderer/features/workspace/api/useWorkspace.ts --max-warnings 0 2>&1 | tail -10` -Expected: No warnings. - -- [ ] **Step 6: Commit** - -```bash -git add src/renderer/features/workspace/hooks/useRelaySession.ts src/renderer/features/workspace/api/useWorkspace.ts -git commit -m "feat(relay): add useRelaySession hook and useSpawnRemoteSession mutation" -``` - ---- - -### Task 6: Fix ProjectListPage Force-Reclaim Wiring - -**Files:** -- Modify: `src/renderer/features/projects/api/useProjects.ts` -- Modify: `src/renderer/features/projects/components/ProjectListPage.tsx` - -- [ ] **Step 1: Add `useForceReclaimProject` mutation** - -In `src/renderer/features/projects/api/useProjects.ts`, add: - -```typescript -/** Force-reclaim a project that another device has claimed */ -export function useForceReclaimProject() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: (projectId: string) => - ipc('relay.forceReclaim', { projectId }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); - }, - onError: onError('force-reclaim project'), - }); -} -``` - -- [ ] **Step 2: Update ProjectListPage to use the new mutation** - -In `src/renderer/features/projects/components/ProjectListPage.tsx`, find the import of `useReleaseProject` and add `useForceReclaimProject`: - -```typescript -import { - useClaimProject, - useForceReclaimProject, - useProjects, - useReleaseProject, - useRemoveProject, - useSubProjects, -} from '../api/useProjects'; -``` - -In the `ProjectListPage` component body, add: -```typescript - const forceReclaimProject = useForceReclaimProject(); -``` - -Update `handleForceReclaimProject` to use the dedicated mutation instead of the release→reopen hack: - -```typescript - function handleForceReclaimProject( - e: React.MouseEvent | React.KeyboardEvent, - project: Project, - ) { - e.stopPropagation(); - forceReclaimProject.mutate(project.id, { - onSuccess: (result) => { - if (result.success) { - addToast('Project reclaimed', 'success'); - addProjectTab(project.id); - void navigate({ to: projectViewPath(project.id, PROJECT_VIEWS.TASKS) }); - } else { - addToast(result.error ?? 'Failed to reclaim project', 'error'); - } - }, - }); - } -``` - -- [ ] **Step 3: Run typecheck** - -Run: `npx tsc --noEmit --project tsconfig.json 2>&1 | head -30` -Expected: No errors. - -- [ ] **Step 4: Run lint** - -Run: `npx eslint src/renderer/features/projects/components/ProjectListPage.tsx src/renderer/features/projects/api/useProjects.ts --max-warnings 0 2>&1 | tail -10` -Expected: No warnings. - -- [ ] **Step 5: Commit** - -```bash -git add src/renderer/features/projects/api/useProjects.ts src/renderer/features/projects/components/ProjectListPage.tsx -git commit -m "fix(relay): wire force-reclaim to dedicated IPC channel instead of release hack" -``` - ---- - -### Task 7: Update Documentation - -**Files:** -- Modify: `docs/routing/FEATURES-INDEX.md` -- Modify: `docs/routing/AI-AGENT-ROUTING-INDEX.md` - -- [ ] **Step 1: Add relay domain to FEATURES-INDEX** - -In `docs/routing/FEATURES-INDEX.md`, add a row to the domain table (alphabetically near "relay"): - -```markdown -| **relay** | Cross-device session relay. Manages project claims, session lifecycle (spawn/input/output/kill/resume), and WebSocket message routing between claimer and host devices. | claimProject, unclaimProject, forceReclaim, releaseProject, spawnSession, sendInput, killSession, resumeSession, sendEnvelope | `event:relay.projectClaimed`, `event:relay.projectUnclaimed`, `event:relay.sessionSpawned`, `event:relay.sessionOutput`, `event:relay.sessionEnded`, `event:relay.messageReceived` | -``` - -- [ ] **Step 2: Add relay domain trace to AI-AGENT-ROUTING-INDEX** - -In `docs/routing/AI-AGENT-ROUTING-INDEX.md`, add a relay section. Match the existing format in the file. Include: - -```markdown -## Relay Domain - -End-to-end trace for cross-device session relay. - -| Layer | File | Purpose | -|-------|------|---------| -| Types | `src/shared/types/relay.ts` | `RelayEnvelope`, `SessionSpawnPayload`, `SessionOutputPayload`, etc. | -| Schemas | `src/shared/ipc/relay/schemas.ts` | Zod schemas mirroring relay types | -| Contract | `src/shared/ipc/relay/contract.ts` | 9 invoke channels + 6 event channels | -| Handlers | `src/main/ipc/handlers/relay-handlers.ts` | `relay.claimProject`, `relay.unclaimProject`, `relay.forceReclaim`, `relay.releaseProject` | -| Service | `src/main/services/relay/relay-service.ts` | `createRelayService()` — incoming/outgoing session routing | -| Service Types | `src/main/services/relay/relay-types.ts` | Hub API response shapes (`HubClaimResponse`, `RemoteProjectRaw`) | -| Heartbeat | `src/main/services/device/heartbeat.ts` | `createHeartbeatService()` — sends project list with each heartbeat | -| Hub WS | `hub/src/ws/relay-router.ts` | Hub-side WebSocket relay (point-to-point session routing) | -| Hub Routes | `hub/src/routes/relay.ts` | Hub REST routes for claim/release/force-reclaim | -| Hub Migration | `hub/src/db/migrations/006_relay_tables.sql` | `session_relay`, `project_claim` tables | -| Renderer Hook | `src/renderer/features/workspace/hooks/useRelaySession.ts` | `useRelaySession()` — subscribes to relay session events | -| Renderer Mutations | `src/renderer/features/projects/api/useProjects.ts` | `useClaimProject()`, `useReleaseProject()`, `useForceReclaimProject()`, `useAllProjects()` | -| Renderer Mutations | `src/renderer/features/workspace/api/useWorkspace.ts` | `useSpawnRemoteSession()` | -| EventBridge | `src/renderer/shared/components/EventBridge.tsx` | `event:relay.projectClaimed` → invalidates `['projects', 'list']` | -``` - -- [ ] **Step 3: Commit** - -```bash -git add docs/routing/FEATURES-INDEX.md docs/routing/AI-AGENT-ROUTING-INDEX.md -git commit -m "docs: add relay domain to FEATURES-INDEX and AI-AGENT-ROUTING-INDEX" -``` - ---- - -### Task 8: Full Verification Pass - -**Files:** None (verification only) - -- [ ] **Step 1: Run full typecheck** - -Run: `npm run typecheck 2>&1 | tail -20` -Expected: Exit code 0, no errors. - -- [ ] **Step 2: Run full lint** - -Run: `npm run lint 2>&1 | tail -20` -Expected: Exit code 0, no warnings. - -- [ ] **Step 3: Run full build** - -Run: `npm run build 2>&1 | tail -20` -Expected: Exit code 0, successful build. - -- [ ] **Step 4: Run full test suite** - -Run: `npm run test 2>&1 | tail -30` -Expected: All tests pass, including the 2 new test files. - -- [ ] **Step 5: Verify all relay IPC channels have handlers** - -Run: `grep -oP "'relay\.\w+'" src/shared/ipc/relay/contract.ts | sort` and compare with `grep -oP "'relay\.\w+'" src/main/ipc/handlers/relay-handlers.ts | sort`. - -Every invoke channel in the contract should have a corresponding handler registration. - -- [ ] **Step 6: Verify EventBridge event names match handler emissions** - -Run: `grep "event:relay" src/renderer/shared/components/EventBridge.tsx` and `grep "event:relay" src/main/ipc/handlers/relay-handlers.ts`. - -Event names in EventBridge should match exactly what handlers emit. - -- [ ] **Step 7: Final commit (if any verification fixes were needed)** - -```bash -git add -A -git commit -m "fix(relay): verification pass fixes" -``` diff --git a/docs/superpowers/plans/2026-04-09-command-bus-spec-completion.md b/docs/superpowers/plans/2026-04-09-command-bus-spec-completion.md deleted file mode 100644 index 935c5f12..00000000 --- a/docs/superpowers/plans/2026-04-09-command-bus-spec-completion.md +++ /dev/null @@ -1,811 +0,0 @@ -# Command Bus Spec Completion — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close all 8 gaps between the command bus design spec and the actual implementation — wire the bus as the IPC interceptor, expose commands as MCP tools, fix session spawning, add missing tables, and update all documentation. - -**Architecture:** The IPC router's `handle()` method gets a `setBus()` hook so every inbound IPC call flows through `bus.dispatch()` for tracking. MCP tool exposure generates tool definitions from the bus registry. Documentation and skills are updated to reflect the new architecture. - -**Tech Stack:** TypeScript strict, Drizzle ORM, better-sqlite3, MCP SDK - ---- - -## Task Grouping - -| Group | Tasks | Description | -|-------|-------|-------------| -| A: Bus Integration | 1–3 | Wire bus into router, fix workspace spawning | -| B: MCP Exposure | 4 | Expose bus commands as MCP tools | -| C: Missing Tables | 5 | Add Wave 3 missing tables | -| D: Documentation | 6–8 | PATTERNS.md, agent defs, skills | - ---- - -### Task 1: Wire Command Bus Into IPC Router - -**Files:** -- Modify: `src/main/ipc/router.ts` -- Modify: `src/main/bootstrap/ipc-wiring.ts` -- Modify: `src/main/bootstrap/service-registry.ts` -- Test: `tests/unit/services/ipc-router-bus.test.ts` - -The router's `handle()` method currently calls handlers directly. We add a `setBus()` method so when a bus is attached, all dispatches flow through it for tracking. The Zod validation stays in the router — the bus adds logging on top. - -- [ ] **Step 1: Write the failing test** - -```typescript -// tests/unit/services/ipc-router-bus.test.ts -import { describe, expect, it, vi } from 'vitest'; - -vi.mock('electron', () => ({ - app: { isPackaged: false }, - ipcMain: { handle: vi.fn() }, - BrowserWindow: { getAllWindows: () => [] }, -})); - -describe('IpcRouter bus integration', () => { - it('dispatches through bus when bus is attached', async () => { - const bus = { - dispatch: vi.fn().mockResolvedValue({ - commandId: '1', status: 'success', output: { id: 'x' }, durationMs: 1, - }), - }; - // Create router, attach bus, verify dispatch is called - // (will fail because setBus doesn't exist yet) - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run tests/unit/services/ipc-router-bus.test.ts` -Expected: FAIL — `setBus` not defined - -- [ ] **Step 3: Add `setBus()` to IpcRouter** - -In `src/main/ipc/router.ts`, add: - -```typescript -import type { CommandBus } from '../bus'; - -export class IpcRouter { - private getMainWindow: () => BrowserWindow | null; - private bus: CommandBus | null = null; - - setBus(bus: CommandBus): void { - this.bus = bus; - } - - handle<T extends InvokeChannel>(channel: T, handler: InvokeHandler<T>): void { - ipcMain.handle(channel, async (_event, rawInput: unknown) => { - try { - const contract = ipcInvokeContract[channel]; - const input = contract.input.parse(rawInput); - - // If bus is attached, dispatch through it for tracking - if (this.bus) { - const result = await this.bus.dispatch( - channel, - input, - { type: 'ui' as const }, - ); - if (result.status === 'error') { - return { success: false, error: result.error }; - } - return { success: true, data: result.output }; - } - - // Fallback: direct handler call (no bus) - const result = await handler(input as InvokeInput<T>); - return { success: true, data: result }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }); - - // Register the handler on the bus too (so bus.dispatch finds it) - if (this.bus) { - this.bus.registerHandler(channel, (input) => handler(input as InvokeInput<T>)); - } - } -} -``` - -**Important:** The bus needs handlers registered BEFORE `setBus()` is called, or we need to register handlers on the bus during `handle()`. Since `handle()` is called during bootstrap before `setBus()`, we need a two-phase approach: collect handlers first, then register them all on the bus when `setBus()` is called. - -Revised approach — store handlers in a Map, register on bus when `setBus()` is called: - -```typescript -export class IpcRouter { - private getMainWindow: () => BrowserWindow | null; - private bus: CommandBus | null = null; - private registeredHandlers = new Map<string, (input: unknown) => Promise<unknown>>(); - - setBus(bus: CommandBus): void { - this.bus = bus; - // Register all previously-registered handlers on the bus - for (const [channel, handler] of this.registeredHandlers) { - bus.registerHandler(channel, handler); - } - } - - handle<T extends InvokeChannel>(channel: T, handler: InvokeHandler<T>): void { - // Store handler for bus registration - const wrappedHandler = (input: unknown) => handler(input as InvokeInput<T>) as Promise<unknown>; - this.registeredHandlers.set(channel, wrappedHandler); - - // If bus already attached, register immediately - if (this.bus) { - this.bus.registerHandler(channel, wrappedHandler); - } - - ipcMain.handle(channel, async (_event, rawInput: unknown) => { - try { - const contract = ipcInvokeContract[channel]; - const input = contract.input.parse(rawInput); - - if (this.bus) { - const result = await this.bus.dispatch(channel, input, { type: 'ui' }); - if (result.status === 'error') { - return { success: false, error: result.error }; - } - return { success: true, data: result.output }; - } - - const result = await handler(input as InvokeInput<T>); - return { success: true, data: result }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }); - } - - emit<T extends EventChannel>(channel: T, payload: EventPayload<T>): void { - // Also emit through bus if attached - if (this.bus) { - this.bus.emit(channel, payload); - } - const win = this.getMainWindow(); - if (win && !win.isDestroyed()) { - win.webContents.send(channel, payload); - } - } -} -``` - -- [ ] **Step 4: Wire setBus in bootstrap** - -In `src/main/bootstrap/ipc-wiring.ts`: - -```typescript -import type { CommandBus } from '../bus'; - -export function wireIpcHandlers(router: IpcRouter, services: Services, commandBus?: CommandBus): void { - registerAllHandlers(router, services); - // Attach bus AFTER all handlers are registered - if (commandBus) { - router.setBus(commandBus); - } -} -``` - -Update the call in `src/main/index.ts`: - -```typescript -wireIpcHandlers(registry.router, registry.services, registry.commandBus); -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `npx vitest run tests/unit/services/ipc-router-bus.test.ts` -Expected: PASS - -- [ ] **Step 6: Run full verification** - -Run: `npm run typecheck && npm run lint && npm run build && npx vitest run` -Expected: All pass - -- [ ] **Step 7: Commit** - -```bash -git add src/main/ipc/router.ts src/main/bootstrap/ipc-wiring.ts src/main/index.ts tests/unit/services/ipc-router-bus.test.ts -git commit -m "feat(bus): wire command bus as IPC interceptor — all calls tracked in SQLite" -``` - ---- - -### Task 2: Wire Bus Events Into Router Emit - -**Files:** -- Already modified in Task 1 (router.ts emit method) -- Modify: `src/main/bootstrap/event-wiring.ts` — add bus session event forwarding - -- [ ] **Step 1: Add bus session event forwarding to event-wiring.ts** - -In `src/main/bootstrap/event-wiring.ts`, the `EventWiringDeps` interface and `wireEventForwarding()` need `busSessionManager`: - -```typescript -import type { BusSessionManager } from '../bus/session-manager'; -import { BUS_EVENTS } from '@shared/ipc/bus/channels'; - -interface EventWiringDeps { - router: IpcRouter; - busSessionManager: BusSessionManager; - // ... existing deps -} - -export function wireEventForwarding(deps: EventWiringDeps): void { - // Bus session events → renderer - deps.busSessionManager.onEvent((event) => { - const channel = BUS_EVENTS.SESSION[event.type.toUpperCase() as keyof typeof BUS_EVENTS.SESSION]; - if (channel) { - deps.router.emit(channel as any, { - sessionId: event.session.id, - session: event.session, - }); - } - }); - - // ... existing event wiring -} -``` - -- [ ] **Step 2: Update index.ts to pass busSessionManager** - -```typescript -wireEventForwarding({ - router: registry.router, - busSessionManager: registry.busSessionManager, - watchEvaluator: registry.watchEvaluator, - webhookRelay: registry.webhookRelay, - hubConnectionManager: registry.hubConnectionManager, -}); -``` - -- [ ] **Step 3: Run verification** - -Run: `npm run typecheck && npm run lint && npm run build` - -- [ ] **Step 4: Commit** - -```bash -git add src/main/bootstrap/event-wiring.ts src/main/index.ts -git commit -m "feat(bus): forward bus session events to renderer via IPC" -``` - ---- - -### Task 3: Fix Workspace Session Manager to Spawn Via Bus - -**Files:** -- Modify: `src/main/features/workspace/workspace-session-manager.ts` - -- [ ] **Step 1: Add BusSessionManager to deps** - -The factory `createWorkspaceSessionManager()` currently takes `(agentManager, worktreeProvisioner, getMainWindow)`. Add `busSessionManager` as a parameter. - -- [ ] **Step 2: Replace agentManager.spawnTeamLead with busSessionManager.spawn** - -At line ~192, replace: -```typescript -const result = agentManager.spawnTeamLead({ - projectPath: worktreePath, - teamName: `workspace-tl-${projectId}-${String(index)}`, - prompt, - model: TEAM_LEAD_MODEL, - name: `workspace-tl-${projectId}-${String(index)}`, -}); -``` - -With: -```typescript -const result = await busSessionManager.spawn({ - name: `workspace-tl-${projectId}-${String(index)}`, - type: 'team-lead', - projectPath: worktreePath, - prompt, - model: TEAM_LEAD_MODEL, - teamName: `workspace-tl-${projectId}-${String(index)}`, - projectId, -}); -``` - -- [ ] **Step 3: Replace agentManager.spawnProjectOwner with busSessionManager.spawn** - -At line ~242, replace the `spawnProjectOwner` call with: -```typescript -const result = await busSessionManager.spawn({ - name: `workspace-primary-${projectId}`, - type: 'project-owner', - projectPath, - prompt: "You are the primary Claude session...", - model: PRIMARY_MODEL, - projectId, -}); -``` - -- [ ] **Step 4: Update service-registry.ts** - -Pass `busSessionManager` to `createWorkspaceSessionManager()`: -```typescript -const workspaceSessionManager = createWorkspaceSessionManager( - agentManagerService, - worktreeProvisioner, - getMainWindow, - busSessionManager, -); -``` - -- [ ] **Step 5: Verify** - -Run: `npm run typecheck && npm run lint && npm run build && npx vitest run` - -- [ ] **Step 6: Commit** - -```bash -git add src/main/features/workspace/ src/main/bootstrap/service-registry.ts -git commit -m "refactor(workspace): spawn sessions through bus session manager" -``` - ---- - -### Task 4: Expose Bus Commands as MCP Tools - -**Files:** -- Create: `src/main/bus/mcp-bridge.ts` -- Modify: `src/main/bootstrap/service-registry.ts` - -The bus generates MCP tool definitions from its registry, so any AI session can invoke any command. - -- [ ] **Step 1: Create the MCP bridge** - -```typescript -// src/main/bus/mcp-bridge.ts -import type { CommandBus } from './command-bus'; -import { createScopedLogger } from '../lib/logger'; - -const logger = createScopedLogger('bus-mcp-bridge'); - -export interface BusMcpTool { - name: string; - description: string; - inputSchema: Record<string, unknown>; -} - -export interface BusMcpBridge { - getTools: () => BusMcpTool[]; - callTool: (name: string, args: unknown) => Promise<unknown>; -} - -export function createBusMcpBridge(bus: CommandBus): BusMcpBridge { - return { - getTools() { - return bus.getRegistry() - .filter((cmd) => cmd.isMutation) // Only expose mutations as tools - .map((cmd) => ({ - name: cmd.channel, - description: `${cmd.verb} ${cmd.noun ?? ''} in ${cmd.domain}`.trim(), - inputSchema: { type: 'object', properties: {}, additionalProperties: true }, - })); - }, - - async callTool(name: string, args: unknown) { - const result = await bus.dispatch(name, args, { type: 'agent', name: 'mcp-bridge' }); - if (result.status === 'error') { - throw new Error(result.error ?? 'Command failed'); - } - return result.output; - }, - }; -} -``` - -- [ ] **Step 2: Export from bus barrel** - -Add to `src/main/bus/index.ts`: -```typescript -export { createBusMcpBridge } from './mcp-bridge'; -export type { BusMcpBridge, BusMcpTool } from './mcp-bridge'; -``` - -- [ ] **Step 3: Wire in service-registry** - -After the command bus and all handlers are registered: -```typescript -import { createBusMcpBridge } from '../bus'; - -// After wireIpcHandlers (so all handlers are registered on the bus): -const busMcpBridge = createBusMcpBridge(commandBus); -``` - -Add `busMcpBridge` to the return object and `ServiceRegistryResult` interface. - -- [ ] **Step 4: Verify** - -Run: `npm run typecheck && npm run lint && npm run build` - -- [ ] **Step 5: Commit** - -```bash -git add src/main/bus/mcp-bridge.ts src/main/bus/index.ts src/main/bootstrap/service-registry.ts -git commit -m "feat(bus): expose bus commands as MCP tool definitions" -``` - ---- - -### Task 5: Add Wave 3 Missing Tables - -**Files:** -- Create: `src/main/features/progress/session-logs-schema.ts` -- Create: `src/main/features/project/task-specs-schema.ts` -- Create: `src/main/features/workflow-engine/workflow-agents-schema.ts` -- Modify: `src/main/db/schema.ts` — add re-exports - -These tables are scaffolds for future use. They follow the spec exactly. - -- [ ] **Step 1: Create session_logs table** - -```typescript -// src/main/features/progress/session-logs-schema.ts -import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; - -export const sessionLogs = sqliteTable('session_logs', { - id: text('id').primaryKey(), - sessionId: text('session_id').notNull(), - slug: text('slug').notNull(), - eventType: text('event_type').notNull(), - payload: text('payload', { mode: 'json' }).$type<unknown>(), - timestamp: text('timestamp').notNull(), -}, (table) => [ - index('idx_session_logs_session_id').on(table.sessionId), - index('idx_session_logs_slug').on(table.slug), -]); -``` - -- [ ] **Step 2: Create task_specs, task_requirements, task_plans tables** - -```typescript -// src/main/features/project/task-specs-schema.ts -import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'; - -export const taskSpecs = sqliteTable('task_specs', { - id: text('id').primaryKey(), - projectId: text('project_id').notNull(), - slug: text('slug').notNull(), - title: text('title').notNull(), - content: text('content'), - createdAt: text('created_at').notNull(), - updatedAt: text('updated_at').notNull(), -}, (table) => [ - index('idx_task_specs_project_id').on(table.projectId), -]); - -export const taskRequirements = sqliteTable('task_requirements', { - id: text('id').primaryKey(), - specId: text('spec_id').notNull(), - description: text('description').notNull(), - status: text('status').notNull().default('pending'), - createdAt: text('created_at').notNull(), -}, (table) => [ - index('idx_task_requirements_spec_id').on(table.specId), -]); - -export const taskPlans = sqliteTable('task_plans', { - id: text('id').primaryKey(), - specId: text('spec_id').notNull(), - content: text('content').notNull(), - version: text('version').notNull().default('1'), - createdAt: text('created_at').notNull(), -}, (table) => [ - index('idx_task_plans_spec_id').on(table.specId), -]); -``` - -- [ ] **Step 3: Create workflow_agents table** - -```typescript -// src/main/features/workflow-engine/workflow-agents-schema.ts -import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; - -export const workflowAgents = sqliteTable('workflow_agents', { - id: text('id').primaryKey(), - runId: text('run_id').notNull(), - role: text('role').notNull(), - sessionId: text('session_id'), - taskSlug: text('task_slug'), - wave: integer('wave'), - status: text('status').notNull().default('pending'), - startedAt: text('started_at'), - completedAt: text('completed_at'), - error: text('error'), -}, (table) => [ - index('idx_workflow_agents_run_id').on(table.runId), -]); -``` - -- [ ] **Step 4: Add re-exports to db/schema.ts barrel** - -```typescript -export * from '../features/progress/session-logs-schema'; -export * from '../features/project/task-specs-schema'; -export * from '../features/workflow-engine/workflow-agents-schema'; -``` - -- [ ] **Step 5: Generate migration** - -Run: `npx drizzle-kit generate` - -- [ ] **Step 6: Verify** - -Run: `npm run typecheck && npm run build` - -- [ ] **Step 7: Commit** - -```bash -git add src/main/features/progress/session-logs-schema.ts src/main/features/project/task-specs-schema.ts src/main/features/workflow-engine/workflow-agents-schema.ts src/main/db/schema.ts drizzle/ -git commit -m "feat(db): add Wave 3 scaffold tables (session_logs, task_specs, workflow_agents)" -``` - ---- - -### Task 6: Update PATTERNS.md - -**Files:** -- Modify: `docs/patterns/PATTERNS.md` - -- [ ] **Step 1: Add Channel Constants pattern** - -Add after existing IPC patterns section: - -```markdown -## Channel Constants - -All IPC channels use typed constants — never hardcoded strings. - -### Defining Channels - -```typescript -// src/shared/ipc/<domain>/channels.ts -import { domain, events } from '../channel-builder'; - -export const DASHBOARD = domain('dashboard', { - LIST: ['captures'], - CREATE: ['capture'], - DELETE: ['capture'], -}); -// DASHBOARD.LIST.CAPTURES = "dashboard.list.captures" (literal type) - -export const DASHBOARD_EVENTS = events('dashboard', { - CAPTURE: ['changed'], -}); -// DASHBOARD_EVENTS.CAPTURE.CHANGED = "event:dashboard.capture.changed" -``` - -### Using in Contracts - -```typescript -export const dashboardInvoke = { - [DASHBOARD.LIST.CAPTURES]: { input: z.object({}), output: z.array(captureSchema) }, -}; -``` - -### Using in Handlers - -```typescript -router.handle(DASHBOARD.LIST.CAPTURES, () => Promise.resolve(service.listCaptures())); -``` - -### Using in Renderer - -```typescript -const captures = await ipc(DASHBOARD.LIST.CAPTURES, {}); -useIpcEvent(DASHBOARD_EVENTS.CAPTURE.CHANGED, () => invalidate()); -``` - -## Command Bus Dispatch - -Every IPC call is tracked by the command bus in SQLite. - -### How It Works - -1. Renderer calls `ipc(CHANNEL, input)` via preload bridge -2. IPC router validates input with Zod -3. Router dispatches through `bus.dispatch(channel, input, source)` -4. Bus writes pending command to SQLite, executes handler, writes result -5. Every command is queryable: `bus.queryCommands({ domain: 'dashboard' })` - -### Source Attribution - -```typescript -// UI calls get source: { type: 'ui' } -// Agent calls get source: { type: 'agent', id: sessionId, name: agentName } -// System calls get source: { type: 'system', name: 'scheduler' } -``` - -### Session Management - -```typescript -// All Claude sessions tracked in SQLite -const sessions = busSessionManager.list({ status: 'active' }); -const session = await busSessionManager.spawn({ name, type, prompt, ... }); -await busSessionManager.kill(sessionId); -``` -``` - -- [ ] **Step 2: Commit** - -```bash -git add docs/patterns/PATTERNS.md -git commit -m "docs: add channel constants + bus dispatch patterns to PATTERNS.md" -``` - ---- - -### Task 7: Update Agent Definitions - -**Files:** -- Modify: `.claude/agents/team-leader.md` -- Modify: `.claude/agents/service-engineer.md` - -- [ ] **Step 1: Update team-leader.md** - -Add to the initialization protocol or rules section: - -```markdown -## Command Bus Integration - -- All sessions are tracked in SQLite via `BusSessionManager` -- Spawn agents through `busSessionManager.spawn()`, NOT `agentManager` directly -- Query active sessions: `busSessionManager.list({ status: 'active' })` -- Every IPC command is logged — use `bus.queryCommands()` for debugging -- Channel constants: `import { PROGRESS } from '@shared/ipc/progress/channels'` -``` - -- [ ] **Step 2: Update service-engineer.md** - -Add to the initialization protocol: - -```markdown -## Feature Slice Design - -Services live in `src/main/features/<domain>/` alongside their handler and schema: - -``` -src/main/features/<domain>/ -├── schema.ts ← Drizzle table definition -├── <domain>-service.ts ← Business logic factory -├── <domain>-handlers.ts ← IPC handler registration -└── [sub-modules] ← Domain-specific helpers -``` - -## Channel Constants - -Never use hardcoded IPC strings. Import from `@shared/ipc/<domain>/channels`: - -```typescript -import { DASHBOARD } from '@shared/ipc/dashboard/channels'; -router.handle(DASHBOARD.CREATE.CAPTURE, async (input) => { ... }); -``` - -## SQLite Persistence - -All domain data is stored in SQLite via Drizzle ORM: - -```typescript -import { captures } from './schema'; -import type { AdcDatabase } from '../../db'; - -export function createDashboardService(deps: { db: AdcDatabase, router: IpcRouter, dataDir: string }) { - // Drizzle queries instead of JSON file I/O - db.select().from(captures).orderBy(desc(captures.createdAt)).all(); -} -``` -``` - -- [ ] **Step 3: Commit** - -```bash -git add .claude/agents/team-leader.md .claude/agents/service-engineer.md -git commit -m "docs: update agent definitions for command bus + FSD architecture" -``` - ---- - -### Task 8: Update Skills - -**Files:** -- Modify: `.claude/skills/electron-ipc/SKILL.md` -- Modify: `.claude/skills/codebase-nav/SKILL.md` - -- [ ] **Step 1: Update electron-ipc skill** - -Add a new section after the existing IPC channel instructions: - -```markdown -## Channel Constants (Required) - -All IPC channels use typed constants from `src/shared/ipc/<domain>/channels.ts`. NEVER use string literals. - -```typescript -// Define: src/shared/ipc/<domain>/channels.ts -import { domain, events } from '../channel-builder'; - -export const DOMAIN = domain('domain-name', { - VERB: ['noun1', 'noun2'], -}); - -export const DOMAIN_EVENTS = events('domain-name', { - SUBJECT: ['past-tense'], -}); -``` - -Use in contracts: `[DOMAIN.VERB.NOUN]: { input: schema, output: schema }` -Use in handlers: `router.handle(DOMAIN.VERB.NOUN, async (input) => { ... })` -Use in renderer: `ipc(DOMAIN.VERB.NOUN, input)` - -## Command Bus - -Every IPC call flows through the command bus for SQLite tracking. The bus wraps the router — no extra code needed in handlers. Source attribution, duration, and success/error status are recorded automatically. - -Bus IPC channels for querying: `BUS.QUERY.COMMANDS`, `BUS.QUERY.EVENTS`, `BUS.LIST.SESSIONS` -``` - -- [ ] **Step 2: Update codebase-nav skill** - -Update the domain resolution table: - -```markdown -## Domain → File Resolution (FSD) - -For any domain `{d}`: - -Contract: src/shared/ipc/{d}/contract.ts (Zod schemas, channel defs) -Channels: src/shared/ipc/{d}/channels.ts (typed channel constants) -Schemas: src/shared/ipc/{d}/schemas.ts (shared Zod types) -Service: src/main/features/{d}/*-service.ts (business logic) -Handler: src/main/features/{d}/*-handlers.ts (IPC handler) -Schema: src/main/features/{d}/schema.ts (Drizzle SQLite table) -Feature: src/renderer/features/{d}/ (React UI) -Types: src/shared/types/{d}.ts (TypeScript interfaces) -``` - -Note the change from `src/main/services/` → `src/main/features/` and `src/main/ipc/handlers/` → co-located in the feature. -``` - -- [ ] **Step 3: Commit** - -```bash -git add .claude/skills/electron-ipc/SKILL.md .claude/skills/codebase-nav/SKILL.md -git commit -m "docs: update electron-ipc and codebase-nav skills for FSD + bus" -``` - ---- - -## Wave 5 Items (Deferred) - -The spec mentions two additional Wave 5 items that are **not implemented in this plan**: - -1. **`migrate-renderer-localstorage`** — Move renderer localStorage to SQLite via IPC. This requires a new IPC domain and renderer-side refactor. Deferred to a separate plan. - -2. **`update-user-data-migrator`** — Replace JSON migration with SQLite user-scoping. Now that all services use SQLite (global DB), the user-data-migrator's JSON file copying is legacy. It should be updated to handle user-scoped SQLite rows instead. Deferred to a separate plan since it touches the auth/session lifecycle. - -Both are logged in `progress/command-bus-phase2/tasks/` as backlog items. - ---- - -## Verification Checklist - -After all 8 tasks: - -- [ ] `npm run typecheck` — zero errors -- [ ] `npm run lint` — zero errors -- [ ] `npm run build` — succeeds -- [ ] `npx vitest run` — all tests pass -- [ ] Every IPC call logged to SQLite `commands` table (verify with bus query) -- [ ] Bus session events forwarded to renderer -- [ ] Workspace sessions spawned through bus session manager -- [ ] MCP bridge exposes mutation commands as tools -- [ ] Wave 3 tables exist and migrations generated -- [ ] PATTERNS.md has channel constants + bus dispatch sections -- [ ] Agent definitions reference bus + FSD -- [ ] Skills reference channel constants + FSD paths diff --git a/docs/superpowers/plans/2026-04-11-gap-closure-multi-sprint.md b/docs/superpowers/plans/2026-04-11-gap-closure-multi-sprint.md deleted file mode 100644 index 8b77d2bf..00000000 --- a/docs/superpowers/plans/2026-04-11-gap-closure-multi-sprint.md +++ /dev/null @@ -1,706 +0,0 @@ -# Gap Closure — Multi-Sprint Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close every gap identified in the full-system data flow audit. Every schema field displayed, every entity editable, every backend with a UI, every feature searchable/filterable. - -**Architecture:** Feature Slice Design — each task targets one feature domain's renderer layer (hooks + components). Backend is already complete for most features. - -**Tech Stack:** React, TanStack Query, TanStack Table, Zustand, @ui design system, Electron IPC - ---- - -## Sprint Overview - -| Sprint | Theme | Features | Tasks | -|--------|-------|----------|-------| -| 1 | Core CRUD Completeness | Progress Tasks, Milestones, Alerts, Captures | 8 | -| 2 | Edit & Metadata Display | Ideas, Fitness, Changelog, Briefing Config | 8 | -| 3 | Search, Filter, Bulk Ops | All personal features + Agent Dashboard | 7 | -| 4 | Settings & Git UI | Settings toggles, Git Dashboard, Agent Dashboard fields | 6 | -| 5 | Missing Integrations UI | Email, Notifications, Spotify | 5 | -| 6 | Schema Fixes & New Backends | Progress schema gaps, Alert/Capture/Fitness update IPC | 6 | -| 7 | Polish & Completion | Calendar, Discord stubs → real or remove, Tools page | 4 | - -**Total: 44 tasks across 7 sprints** - ---- - -## Sprint 1: Core CRUD Completeness - -> Close the biggest user-facing gaps: can't edit the things you use most. - -### Task 1.1: Progress Tasks — Inline Edit Dialog - -**Problem:** Zero edit capability after creation. Title, description, priority, status all read-only. - -**Files:** -- Create: `src/renderer/features/tasks/components/EditProgressTaskDialog.tsx` -- Modify: `src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx` (add edit button to row actions) -- Modify: `src/renderer/features/tasks/components/detail/ProgressTaskDetailRow.tsx` (add edit button) - -**Acceptance Criteria:** -- [ ] Edit dialog with fields: title, description, priority (select), status (select) -- [ ] Opens from row action menu (kebab) and detail row -- [ ] Uses `useUpdateProgressTask()` mutation (already exists) -- [ ] All fields use @ui primitives (FormInput, FormSelect, Dialog) -- [ ] Typecheck + lint pass - -### Task 1.2: Progress Tasks — Jira & PR Linking UI - -**Problem:** Jira/PR link buttons exist in detail row but are disabled/non-functional. - -**Files:** -- Modify: `src/renderer/features/tasks/components/detail/ProgressTaskDetailRow.tsx` -- Create: `src/renderer/features/tasks/components/LinkJiraDialog.tsx` -- Create: `src/renderer/features/tasks/components/LinkPrDialog.tsx` - -**Acceptance Criteria:** -- [ ] "Link Jira" button opens dialog with jiraTicket + jiraUrl inputs -- [ ] "Link PR" button opens dialog with prUrl input (auto-derives prNumber) -- [ ] Both call `useUpdateProgressTask()` with the link fields -- [ ] Linked items show as clickable badges in grid/detail -- [ ] Typecheck + lint pass - -### Task 1.3: Progress Tasks — Schema Fix for PR/Jira Fields - -**Problem:** `prNumber`, `prStatus`, `jiraUrl` exist in IPC but NOT in SQLite schema. Data is accepted but lost on restart. - -**Files:** -- Create: `drizzle/0012_add_pr_jira_fields.sql` -- Modify: `drizzle/meta/_journal.json` -- Modify: `src/main/features/progress/schema.ts` (add columns) -- Modify: `src/main/features/progress/progress-service.ts` (include in rowToTask) - -**Acceptance Criteria:** -- [ ] Migration adds: `pr_number INTEGER`, `pr_status TEXT`, `jira_url TEXT` to `progress_tasks` -- [ ] `rowToTask()` maps these columns to ProgressTask fields -- [ ] Fields round-trip: set via update → persist → return on list/get -- [ ] Typecheck + lint pass - -### Task 1.4: Progress Tasks — Remove Unused Schema Columns - -**Problem:** `tags` and `branch` columns exist in schema but are never read, written, or displayed anywhere. - -**Files:** -- Create: `drizzle/0013_drop_unused_progress_columns.sql` (or leave and document — SQLite can't DROP COLUMN easily) -- Modify: `src/main/features/progress/schema.ts` (remove from Drizzle definition if not dropping) - -**Acceptance Criteria:** -- [ ] Decision documented: drop columns or repurpose them -- [ ] If keeping tags: add tag input to create/edit dialogs, display as badges -- [ ] If dropping: migration removes columns, schema updated -- [ ] Typecheck + lint pass - -### Task 1.5: Milestones — Edit Dialog - -**Problem:** Only status is editable via dropdown. Title, description, targetDate are read-only after creation. Task items can't be edited or deleted. - -**Files:** -- Create: `src/renderer/features/roadmap/components/MilestoneEditDialog.tsx` -- Modify: `src/renderer/features/roadmap/components/RoadmapPage.tsx` (add edit button per milestone card) - -**Acceptance Criteria:** -- [ ] Edit dialog with: title, description, targetDate (date picker), status -- [ ] Task management: edit task text, delete tasks (not just toggle) -- [ ] Uses `useUpdateMilestone()` mutation (already exists) -- [ ] All @ui primitives -- [ ] Typecheck + lint pass - -### Task 1.6: Alerts — Update IPC + Edit UI - -**Problem:** No update channel exists. Cannot edit message, triggerAt, or recurring config after creation. - -**Files:** -- Modify: `src/shared/ipc/misc/alerts.channels.ts` (add UPDATE.ALERT) -- Modify: `src/shared/ipc/misc/alerts.contract.ts` (add update schema) -- Modify: `src/main/features/alerts/alert-service.ts` (add updateAlert method) -- Modify: `src/main/features/alerts/alert-handlers.ts` (register update handler) -- Create: `src/renderer/features/personal/alerts/api/useAlertMutations.ts` (useUpdateAlert) -- Create: `src/renderer/features/personal/alerts/components/AlertEditDialog.tsx` -- Modify: `src/renderer/features/personal/alerts/components/AlertsPage.tsx` (add edit button) - -**Acceptance Criteria:** -- [ ] UPDATE.ALERT channel with schema: `{ id, message?, triggerAt?, recurring?, linkedTo? }` -- [ ] Service updates alert in SQLite -- [ ] Edit dialog with all editable fields -- [ ] Display `linkedTo` field (currently hidden) -- [ ] Display `createdAt` timestamp -- [ ] Typecheck + lint pass - -### Task 1.7: Captures — Update IPC + Edit UI - -**Problem:** Cannot edit capture text. Limited to 5 recent items. - -**Files:** -- Modify: `src/shared/ipc/dashboard/channels.ts` (add UPDATE.CAPTURE) -- Modify: `src/shared/ipc/dashboard/contract.ts` (add update schema) -- Modify: `src/main/features/dashboard/dashboard-service.ts` (add updateCapture, remove MAX_RECENT limit or add pagination) -- Modify: `src/main/features/dashboard/dashboard-handlers.ts` (register update handler) -- Modify: `src/renderer/features/dashboard/api/useCaptures.ts` (add useUpdateCapture) -- Modify: `src/renderer/features/dashboard/components/QuickCapture.tsx` (inline edit, show all) - -**Acceptance Criteria:** -- [ ] Inline text editing on click -- [ ] "Show all" button to see beyond 5 recent -- [ ] UPDATE.CAPTURE IPC channel -- [ ] Typecheck + lint pass - -### Task 1.8: Captures — Search + Convert to Task/Note - -**Problem:** No search, no way to act on captures beyond delete. - -**Files:** -- Modify: `src/renderer/features/dashboard/components/QuickCapture.tsx` - -**Acceptance Criteria:** -- [ ] Search input filters captures by text -- [ ] "Convert to Task" action (calls useCreateProgressTask with capture text as title) -- [ ] "Convert to Note" action (calls useCreateNote with capture text as content) -- [ ] Delete confirmation -- [ ] Typecheck + lint pass - ---- - -## Sprint 2: Edit & Metadata Display - -> Make hidden data visible, add edit capability to remaining features. - -### Task 2.1: Ideas — Tags UI - -**Problem:** Tags field fully implemented in backend but zero UI. - -**Files:** -- Modify: `src/renderer/features/ideation/components/IdeaEditForm.tsx` (add tags input) -- Modify: `src/renderer/features/ideation/components/IdeationPage.tsx` (display tags as badges, add tag filter) - -**Acceptance Criteria:** -- [ ] Tags shown as badges on idea cards -- [ ] Tags editable in edit form (comma-separated or chip input) -- [ ] Filter by tag (alongside category filter) -- [ ] Typecheck + lint pass - -### Task 2.2: Ideas — Search - -**Problem:** No search capability. - -**Files:** -- Modify: `src/renderer/features/ideation/components/IdeationPage.tsx` (add SearchInput) -- Optionally: add search to service/IPC (or client-side filter) - -**Acceptance Criteria:** -- [ ] SearchInput at top of page -- [ ] Filters ideas by title + description (client-side is fine) -- [ ] Works alongside category filter -- [ ] Typecheck + lint pass - -### Task 2.3: Fitness — Workout Edit + Notes Display - -**Problem:** Workouts can't be edited. Notes field stored but never displayed. - -**Files:** -- Modify: `src/shared/ipc/fitness/channels.ts` (add UPDATE.WORKOUT) -- Modify: `src/main/features/fitness/fitness-service.ts` (add updateWorkout) -- Modify: `src/main/features/fitness/fitness-handlers.ts` (register handler) -- Create: `src/renderer/features/personal/fitness/components/WorkoutEditDialog.tsx` -- Modify: `src/renderer/features/personal/fitness/components/WorkoutLog.tsx` (show notes, add edit button) - -**Acceptance Criteria:** -- [ ] Notes field displayed in workout log entries -- [ ] Edit dialog for date, type, duration, exercises, notes -- [ ] UPDATE.WORKOUT IPC channel -- [ ] Typecheck + lint pass - -### Task 2.4: Fitness — Measurement Edit/Delete + Hidden Fields - -**Problem:** Measurements are immutable. boneMass field hidden. - -**Files:** -- Modify: `src/shared/ipc/fitness/channels.ts` (add UPDATE.MEASUREMENT, DELETE.MEASUREMENT) -- Modify: `src/main/features/fitness/fitness-service.ts` (add update/delete methods) -- Modify: `src/renderer/features/personal/fitness/components/MeasurementChart.tsx` (show boneMass, add edit/delete) - -**Acceptance Criteria:** -- [ ] boneMass displayed when present -- [ ] Edit dialog for all measurement fields -- [ ] Delete capability -- [ ] Typecheck + lint pass - -### Task 2.5: Fitness — Goal Edit - -**Problem:** Can only update progress (current), not the goal definition (target, deadline, unit). - -**Files:** -- Modify: `src/shared/ipc/fitness/channels.ts` (add UPDATE.GOAL for full updates) -- Modify: `src/main/features/fitness/fitness-service.ts` (add updateGoal for all fields) -- Create: `src/renderer/features/personal/fitness/components/GoalEditDialog.tsx` - -**Acceptance Criteria:** -- [ ] Edit dialog for type, target, unit, deadline -- [ ] UPDATE.GOAL IPC channel (distinct from UPDATE.GOAL-PROGRESS) -- [ ] Typecheck + lint pass - -### Task 2.6: Changelog — Edit/Delete Existing Entries - -**Problem:** Can only add entries. Can't edit or delete. - -**Files:** -- Modify: `src/shared/ipc/misc/changelog.channels.ts` (add UPDATE.ENTRY, DELETE.ENTRY) -- Modify: `src/main/features/changelog/changelog-service.ts` (add update/delete) -- Modify: `src/renderer/features/personal/changelog/` (add edit/delete buttons) - -**Acceptance Criteria:** -- [ ] Edit button on each changelog entry → opens editor -- [ ] Delete button with confirmation -- [ ] Display `createdAt` timestamp -- [ ] Typecheck + lint pass - -### Task 2.7: Briefing — Config UI Panel - -**Problem:** All 4 config fields stored but completely inaccessible to users. - -**Files:** -- Create: `src/renderer/features/personal/briefing/components/BriefingConfigPanel.tsx` -- Modify: `src/renderer/features/personal/briefing/components/BriefingPage.tsx` (add config gear icon → panel) -- Create: `src/renderer/features/personal/briefing/api/useBriefingConfig.ts` (hooks for config CRUD) - -**Acceptance Criteria:** -- [ ] Config panel (slide-out or modal) with: - - enabled toggle (Switch) - - scheduledTime input (time picker) - - includeGitHub checkbox - - includeAgentActivity checkbox -- [ ] Uses `BRIEFING.GET.CONFIG` and `BRIEFING.UPDATE.CONFIG` channels -- [ ] All @ui primitives -- [ ] Typecheck + lint pass - -### Task 2.8: Show createdAt Timestamps Globally - -**Problem:** Almost every feature hides createdAt. - -**Files:** -- Modify: All list/card/row components for: notes, ideas, milestones, alerts, captures, fitness, progress tasks - -**Acceptance Criteria:** -- [ ] `createdAt` shown as relative time ("3d ago") in list views -- [ ] Hover/tooltip shows absolute time -- [ ] Consistent placement across all features -- [ ] Typecheck + lint pass - ---- - -## Sprint 3: Search, Filter, Bulk Operations - -> Make every feature searchable and add multi-select actions. - -### Task 3.1: Global Search Pattern — Shared Component - -**Files:** -- Create: `src/renderer/shared/components/SearchableList.tsx` (or use existing SearchInput pattern) - -**Acceptance Criteria:** -- [ ] Reusable search + filter + sort pattern for list views -- [ ] Debounced search input -- [ ] Sort dropdown -- [ ] Can compose with existing page layouts - -### Task 3.2: Milestones — Search + Filter + Sort - -**Files:** -- Modify: `src/renderer/features/roadmap/components/RoadmapPage.tsx` - -**Acceptance Criteria:** -- [ ] Search by title/description -- [ ] Filter by status (planned/in-progress/completed) -- [ ] Sort by targetDate, progress, createdAt -- [ ] Typecheck + lint pass - -### Task 3.3: Alerts — Search + Sort - -**Files:** -- Modify: `src/renderer/features/personal/alerts/components/AlertsPage.tsx` - -**Acceptance Criteria:** -- [ ] Search alerts by message text -- [ ] Sort by triggerAt, createdAt -- [ ] Filter by alert type -- [ ] Typecheck + lint pass - -### Task 3.4: Fitness — Search Across All Entities - -**Files:** -- Modify fitness page components - -**Acceptance Criteria:** -- [ ] Search workouts by type/notes -- [ ] Search goals by type -- [ ] Date range filter on measurements -- [ ] Typecheck + lint pass - -### Task 3.5: Agent Dashboard — Session Filters + Search - -**Problem:** Filter params defined in IPC but no UI controls. - -**Files:** -- Modify: `src/renderer/features/agent-dashboard/components/` (add filter bar) - -**Acceptance Criteria:** -- [ ] Filter by session type (project-owner, team-lead, teammate) -- [ ] Filter by status (running, completed, failed, idle) -- [ ] Filter by team name -- [ ] Search by session name -- [ ] Typecheck + lint pass - -### Task 3.6: Progress Tasks — Bulk Operations - -**Problem:** Checkboxes exist in grid but no bulk action toolbar. - -**Files:** -- Create: `src/renderer/features/tasks/components/BulkActionBar.tsx` -- Modify: `src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx` - -**Acceptance Criteria:** -- [ ] Selection checkboxes functional -- [ ] Bulk action bar appears when items selected: Archive, Delete, Change Status, Change Priority -- [ ] Confirmation dialog for destructive actions -- [ ] Uses existing mutations in batch -- [ ] Typecheck + lint pass - -### Task 3.7: My Work — Enhanced View - -**Problem:** No search, no priority display, no jira/PR links, no sorting. - -**Files:** -- Modify: `src/renderer/features/my-work/components/MyWorkPage.tsx` - -**Acceptance Criteria:** -- [ ] Search input (title/description) -- [ ] Priority badges shown on each task -- [ ] Jira/PR link badges shown (clickable) -- [ ] Sort by: priority, updatedAt, status -- [ ] Click task → navigate to project tasks tab with detail expanded -- [ ] Typecheck + lint pass - ---- - -## Sprint 4: Settings & Git UI - -> Expose hidden settings toggles and build the missing Git dashboard. - -### Task 4.1: Settings — App Behavior Toggles - -**Problem:** 7 settings stored but no UI toggles. - -**Files:** -- Modify: `src/renderer/features/settings/components/` (add to Advanced or new Behavior section) - -**Acceptance Criteria:** -- [ ] openAtLogin — Switch toggle -- [ ] minimizeToTray — Switch toggle -- [ ] startMinimized — Switch toggle -- [ ] keepRunning — Switch toggle (keep app running when window closed) -- [ ] assistantAutoStart — Switch toggle -- [ ] logLevel — Select dropdown (debug, info, warn, error) -- [ ] maxConcurrentAgents — number input with min/max -- [ ] All use `useUpdateSettings()` mutation -- [ ] Typecheck + lint pass - -### Task 4.2: Git Dashboard — Status & Branches - -**Problem:** Full git backend, zero UI. GitPage currently just shows GitHub panel. - -**Files:** -- Create: `src/renderer/features/git-overview/components/GitStatusCard.tsx` -- Create: `src/renderer/features/git-overview/components/BranchList.tsx` -- Create: `src/renderer/features/git-overview/api/useGit.ts` (query hooks) -- Modify: `src/renderer/features/git-overview/components/GitPage.tsx` (add git tab alongside GitHub tab) - -**Acceptance Criteria:** -- [ ] Git Status card: current branch, clean/dirty, ahead/behind, staged/modified/untracked counts -- [ ] Branch list with current branch highlighted -- [ ] Create branch button + dialog -- [ ] Switch branch action -- [ ] Uses `GIT.GET.STATUS`, `GIT.GET.BRANCHES`, `GIT.CREATE.BRANCH` -- [ ] Typecheck + lint pass - -### Task 4.3: Git Dashboard — Worktree Manager - -**Files:** -- Create: `src/renderer/features/git-overview/components/WorktreeList.tsx` - -**Acceptance Criteria:** -- [ ] List all worktrees with path, branch, status -- [ ] Create worktree button + dialog -- [ ] Remove worktree action with confirmation -- [ ] Uses `GIT.LIST.WORKTREES`, `GIT.CREATE.WORKTREE`, `GIT.REMOVE.WORKTREE` -- [ ] Typecheck + lint pass - -### Task 4.4: Git Dashboard — Commit & Push - -**Files:** -- Create: `src/renderer/features/git-overview/components/CommitPanel.tsx` - -**Acceptance Criteria:** -- [ ] Staged files list -- [ ] Commit message input -- [ ] Commit button → `GIT.COMMIT.CHANGES` -- [ ] Push button → `GIT.PUSH.CHANGES` -- [ ] Create PR button → `GIT.CREATE.PR` -- [ ] Typecheck + lint pass - -### Task 4.5: Agent Dashboard — Hidden Fields Display - -**Problem:** toolUsage, phase, wave, taskIndex stored but never shown. - -**Files:** -- Modify: `src/main/features/agent-dashboard/agent-dashboard-handlers.ts` (include toolUsage, wave, taskIndex in response) -- Modify: `src/renderer/features/agent-dashboard/components/` (display in expanded panel) - -**Acceptance Criteria:** -- [ ] Tool usage breakdown (which tools, how many calls each) -- [ ] Phase indicator in status bar -- [ ] Wave/taskIndex shown for team-lead sessions -- [ ] Token usage with cost estimate ($input/output per model) -- [ ] Typecheck + lint pass - -### Task 4.6: Agent Dashboard — Bulk Stop + Session Actions - -**Files:** -- Modify: `src/renderer/features/agent-dashboard/components/` - -**Acceptance Criteria:** -- [ ] "Stop All" button for running sessions -- [ ] Individual session actions: stop, restart, view logs -- [ ] Session type filter dropdown -- [ ] Typecheck + lint pass - ---- - -## Sprint 5: Missing Integration UIs - -> Build UIs for backends that already exist. - -### Task 5.1: Email Integration Panel - -**Problem:** Complete backend (7 channels), zero UI. - -**Files:** -- Create: `src/renderer/features/integrations/components/EmailPanel.tsx` -- Create: `src/renderer/features/integrations/api/useEmail.ts` (hooks) -- Modify: `src/renderer/features/integrations/components/IntegrationsPage.tsx` (add Email tab) - -**Acceptance Criteria:** -- [ ] Email config form (SMTP settings) -- [ ] Test connection button -- [ ] Email queue list (pending, sent, failed) -- [ ] Retry failed button -- [ ] Send test email -- [ ] Typecheck + lint pass - -### Task 5.2: Notification Controls - -**Problem:** Can't control watchers or mark-as-read from UI. - -**Files:** -- Modify: `src/renderer/features/integrations/components/` (notification settings) -- Create: `src/renderer/features/integrations/api/useNotifications.ts` - -**Acceptance Criteria:** -- [ ] Watcher status display (running/stopped/error) -- [ ] Start/stop watching buttons -- [ ] Mark as read / mark all read -- [ ] Notification config panel (what to watch, refresh interval) -- [ ] Typecheck + lint pass - -### Task 5.3: Spotify Widget — Real Controls - -**Problem:** Widget is a stub. - -**Files:** -- Modify: `src/renderer/features/productivity/components/SpotifyWidget.tsx` -- Create: `src/renderer/features/productivity/api/useSpotify.ts` (hooks) - -**Acceptance Criteria:** -- [ ] Now playing display (track, artist, album art) -- [ ] Play/pause/skip controls -- [ ] Volume slider -- [ ] Search tracks -- [ ] Uses existing `SPOTIFY.*` IPC channels -- [ ] Graceful "not connected" state -- [ ] Typecheck + lint pass - -### Task 5.4: GitHub — PR Diff Viewer - -**Problem:** Can list PRs but can't view diffs. - -**Files:** -- Create: `src/renderer/features/integrations/components/PrDiffView.tsx` - -**Acceptance Criteria:** -- [ ] File-by-file diff view -- [ ] Syntax highlighting -- [ ] Addition/deletion line counts -- [ ] Typecheck + lint pass - -### Task 5.5: GitHub — Commit History - -**Files:** -- Create: `src/renderer/features/git-overview/components/CommitHistory.tsx` - -**Acceptance Criteria:** -- [ ] Commit list with hash, message, author, date -- [ ] Pagination / infinite scroll -- [ ] Click to view diff -- [ ] Typecheck + lint pass - ---- - -## Sprint 6: Schema Fixes & New Backends - -> Fix data model gaps and add missing IPC channels. - -### Task 6.1: Progress Tasks — Add workflow/workflowPhase to rowToTask - -**Problem:** Fields can be set via update but `rowToTask()` doesn't extract them. - -**Files:** -- Modify: `src/main/features/progress/progress-service.ts` (add to rowToTask mapping) - -**Acceptance Criteria:** -- [ ] workflow and workflowPhase included in ProgressTask returned by list/get -- [ ] Round-trip verified: set → persist → return -- [ ] Typecheck + lint pass - -### Task 6.2: Planner — Persist completedGoals + Display scheduledTasks - -**Problem:** completedGoals tracked in UI but not schema. scheduledTasks stored but never rendered. - -**Files:** -- Modify: `src/main/features/planner/planner-service.ts` (ensure completedGoals persists) -- Modify: `src/renderer/features/personal/planner/components/DayView.tsx` (render scheduledTasks) - -**Acceptance Criteria:** -- [ ] completedGoals count persists across sessions -- [ ] scheduledTasks rendered as a list/table in day view -- [ ] Typecheck + lint pass - -### Task 6.3: Alert — Add linkedTo Display - -**Problem:** linkedTo field stored but never shown. - -**Files:** -- Modify: `src/renderer/features/personal/alerts/components/AlertsPage.tsx` - -**Acceptance Criteria:** -- [ ] linkedTo shown as a clickable link/badge on alert cards -- [ ] Typecheck + lint pass - -### Task 6.4: Fitness — Workout Notes Display - -**Problem:** Notes stored but hidden in log view. - -Already covered in Task 2.3 — skip if done. - -### Task 6.5: Ideas — createdAt/updatedAt Display - -Already covered in Task 2.8 — skip if done. - -### Task 6.6: Multi-filter Support - -**Problem:** All features only support filtering by one criterion at a time. - -**Files:** -- Modify: Notes (multi-tag filter), Ideas (category + status combo), Milestones (status + date range) - -**Acceptance Criteria:** -- [ ] Notes: select multiple tags to filter by -- [ ] Ideas: filter by category AND status simultaneously -- [ ] Milestones: filter by status AND date range -- [ ] Typecheck + lint pass - ---- - -## Sprint 7: Polish & Completion - -> Handle remaining stubs and polish. - -### Task 7.1: Calendar Integration — Decision - -**Problem:** Zero implementation. No backend, no UI. - -**Decision Required:** -- [ ] If building: Define IPC channels, create service (Google Calendar API), build CalendarPanel -- [ ] If removing: Delete CalendarPanel stub, remove from integrations nav -- [ ] Document decision - -### Task 7.2: Discord Integration — Decision - -**Problem:** Zero implementation. UI-only stub. - -**Decision Required:** -- [ ] If building: Define IPC channels, create service (Discord API), build DiscordPanel -- [ ] If removing: Delete DiscordPanel stub, remove from integrations nav -- [ ] Document decision - -### Task 7.3: Tools Page — Claude Config Scanner - -**Problem:** Tools page shows "No items configured" but tools actually exist in `.claude/`. - -**Files:** -- Create: `src/main/features/claude/claude-config-scanner.ts` (scan .claude/ for skills, agents, commands, plugins) -- Create: `src/shared/ipc/claude/channels.ts` (LIST.SKILLS, LIST.AGENTS, LIST.COMMANDS, LIST.PLUGINS) -- Create: `src/renderer/features/tools/api/useClaudeConfig.ts` (hooks) -- Modify: `src/renderer/features/tools/components/ToolsPage.tsx` (display real data) - -**Acceptance Criteria:** -- [ ] Scans project `.claude/skills/`, `.claude/agents/`, reads plugin.json files -- [ ] Displays name, description, type for each tool -- [ ] Click to view content (read-only markdown display) -- [ ] "Refresh" button to re-scan -- [ ] Typecheck + lint pass - -### Task 7.4: Final Verification - -- [ ] Run full typecheck: `npm run typecheck` -- [ ] Run full lint: `npm run lint` -- [ ] Run full build: `npm run build` -- [ ] Run unit tests: `npm run test:unit` -- [ ] Grep for remaining TODO/FIXME/STUB -- [ ] Grep for remaining raw HTML elements -- [ ] Re-run data flow audit on all features — verify 100% field coverage - ---- - -## Sprint Dependencies - -``` -Sprint 1 ──→ Sprint 2 ──→ Sprint 3 - │ │ - ▼ ▼ - Sprint 6 Sprint 4 - │ │ - ▼ ▼ - Sprint 7 Sprint 5 -``` - -- Sprint 1 & 2 can partially overlap (different features) -- Sprint 3 depends on Sprint 1-2 (search/filter on features that have edit capability) -- Sprint 4-5 are independent of each other -- Sprint 6 can start after Sprint 2 -- Sprint 7 is the final polish sprint - ---- - -## Estimated Effort - -| Sprint | Tasks | Estimated Agent-Hours | Complexity | -|--------|-------|-----------------------|------------| -| 1 | 8 | 4-6h | Medium-High (new IPC channels + UI) | -| 2 | 8 | 3-5h | Medium (mostly UI, some IPC) | -| 3 | 7 | 2-4h | Medium (UI patterns, reusable) | -| 4 | 6 | 4-6h | High (Git UI from scratch) | -| 5 | 5 | 3-5h | Medium-High (integration UIs) | -| 6 | 6 | 2-3h | Low-Medium (fixes, small changes) | -| 7 | 4 | 2-4h | Variable (decisions needed) | - -**Total: 44 tasks, ~20-33 agent-hours** diff --git a/docs/superpowers/plans/2026-04-13-sprint-6-7-finish-line.md b/docs/superpowers/plans/2026-04-13-sprint-6-7-finish-line.md deleted file mode 100644 index b9643e5b..00000000 --- a/docs/superpowers/plans/2026-04-13-sprint-6-7-finish-line.md +++ /dev/null @@ -1,1624 +0,0 @@ -# Sprint 6-7 Finish Line — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Wire the command bus to the renderer, provision workspaces locally, deliver live visualization data, and clean up deprecated channels. - -**Architecture:** All 5 areas target existing seams — no new domains. Main changes are: renderer stubs replaced with real BUS IPC calls; new workspace SQLite service replacing inline throws; visualization hooks consolidated and live-updated via EventBridge. - -**Tech Stack:** Electron IPC (typed channels), TanStack Query, Drizzle SQLite, React, EventBridge (setQueryData pattern for events) - -**Spec:** `docs/superpowers/specs/2026-04-13-sprint-6-7-foundation-design.md` - ---- - -## Wave 1 — Backend - -> Tasks 1-4 are independent. Assign to parallel agents. - ---- - -### Task 1: Workspace DB Migration + Schema - -**Files:** -- Create: `drizzle/0014_add_workspaces_table.sql` -- Modify: `drizzle/meta/_journal.json` (add entry) -- Create: `src/main/features/workspaces/schema.ts` -- Modify: `src/main/db/schema.ts` (add export) - -- [ ] **Step 1: Create migration SQL** - -```sql --- drizzle/0014_add_workspaces_table.sql -CREATE TABLE IF NOT EXISTS workspaces ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - host_device_id TEXT, - settings TEXT NOT NULL DEFAULT '{"autoStart":false,"maxConcurrent":3,"defaultBranch":"main"}', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -``` - -- [ ] **Step 2: Update migration journal** - -In `drizzle/meta/_journal.json`, add after the last entry in the `entries` array (after the `0013_progress_workflow_columns` entry): - -```json - ,{ - "idx": 14, - "version": "6", - "when": 1775843500000, - "tag": "0014_add_workspaces_table", - "breakpoints": true - } -``` - -- [ ] **Step 3: Create Drizzle schema file** - -Create `src/main/features/workspaces/schema.ts`: - -```typescript -import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; - -export const workspaces = sqliteTable('workspaces', { - id: text('id').primaryKey(), - name: text('name').notNull(), - description: text('description'), - hostDeviceId: text('host_device_id'), - settings: text('settings').notNull().default('{"autoStart":false,"maxConcurrent":3,"defaultBranch":"main"}'), - createdAt: text('created_at').notNull(), - updatedAt: text('updated_at').notNull(), -}); -``` - -- [ ] **Step 4: Add to schema barrel** - -In `src/main/db/schema.ts`, add at the end: - -```typescript -export * from '../features/workspaces/schema'; -``` - -- [ ] **Step 5: Typecheck** - -Run: `npx tsc --noEmit` -Expected: no errors - -- [ ] **Step 6: Commit** - -```bash -git add drizzle/0014_add_workspaces_table.sql drizzle/meta/_journal.json src/main/features/workspaces/schema.ts src/main/db/schema.ts -git commit -m "feat(workspaces): add SQLite migration + Drizzle schema" -``` - ---- - -### Task 2: Workspace Service + Handlers - -**Files:** -- Create: `src/main/features/workspaces/workspaces-service.ts` -- Create: `src/main/features/workspaces/workspaces-handlers.ts` - -**Dependencies:** Task 1 (schema.ts must exist) - -- [ ] **Step 1: Create workspace service** - -Create `src/main/features/workspaces/workspaces-service.ts`: - -```typescript -import { hostname } from 'node:os'; - -import { eq } from 'drizzle-orm'; - -import { generateId } from '@shared/lib/id'; - -import { workspaces } from '../../db/schema'; -import { createScopedLogger } from '../../lib/logger'; - -import type { AdcDatabase } from '../../db'; - -const logger = createScopedLogger('workspaces-service'); - -export interface WorkspaceRow { - id: string; - name: string; - description: string | null; - hostDeviceId: string | null; - settings: string; - createdAt: string; - updatedAt: string; -} - -export interface WorkspaceRecord { - id: string; - name: string; - description?: string; - hostDeviceId?: string; - settings: { autoStart: boolean; maxConcurrent: number; defaultBranch: string }; - createdAt: string; - updatedAt: string; -} - -function rowToRecord(row: WorkspaceRow): WorkspaceRecord { - const settings = JSON.parse(row.settings) as WorkspaceRecord['settings']; - return { - id: row.id, - name: row.name, - ...(row.description !== null && { description: row.description }), - ...(row.hostDeviceId !== null && { hostDeviceId: row.hostDeviceId }), - settings, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -export interface WorkspacesService { - init: () => void; - list: () => WorkspaceRecord[]; - create: (input: { name: string; description?: string }) => WorkspaceRecord; - update: (id: string, input: { - name?: string; - description?: string; - hostDeviceId?: string; - settings?: Partial<WorkspaceRecord['settings']>; - }) => WorkspaceRecord; - delete: (id: string) => { success: boolean }; -} - -export function createWorkspacesService({ db }: { db: AdcDatabase }): WorkspacesService { - function init(): void { - const existing = db.select().from(workspaces).limit(1).all(); - if (existing.length > 0) return; - - const now = new Date().toISOString(); - const id = generateId(); - const name = hostname(); - db.insert(workspaces).values({ - id, - name, - description: null, - hostDeviceId: null, - settings: JSON.stringify({ autoStart: false, maxConcurrent: 3, defaultBranch: 'main' }), - createdAt: now, - updatedAt: now, - }).run(); - logger.info(`[Workspaces] Auto-provisioned workspace "${name}" (${id})`); - } - - function list(): WorkspaceRecord[] { - return db.select().from(workspaces).all().map(rowToRecord); - } - - function create(input: { name: string; description?: string }): WorkspaceRecord { - const now = new Date().toISOString(); - const id = generateId(); - db.insert(workspaces).values({ - id, - name: input.name, - description: input.description ?? null, - hostDeviceId: null, - settings: JSON.stringify({ autoStart: false, maxConcurrent: 3, defaultBranch: 'main' }), - createdAt: now, - updatedAt: now, - }).run(); - const row = db.select().from(workspaces).where(eq(workspaces.id, id)).get(); - if (!row) throw new Error(`Workspace ${id} not found after insert`); - return rowToRecord(row); - } - - function update(id: string, input: { - name?: string; - description?: string; - hostDeviceId?: string; - settings?: Partial<WorkspaceRecord['settings']>; - }): WorkspaceRecord { - const existing = db.select().from(workspaces).where(eq(workspaces.id, id)).get(); - if (!existing) throw new Error(`Workspace ${id} not found`); - - const now = new Date().toISOString(); - const existingSettings = JSON.parse(existing.settings) as WorkspaceRecord['settings']; - const mergedSettings = input.settings - ? { ...existingSettings, ...input.settings } - : existingSettings; - - db.update(workspaces).set({ - ...(input.name !== undefined && { name: input.name }), - ...(input.description !== undefined && { description: input.description }), - ...(input.hostDeviceId !== undefined && { hostDeviceId: input.hostDeviceId }), - settings: JSON.stringify(mergedSettings), - updatedAt: now, - }).where(eq(workspaces.id, id)).run(); - - const updated = db.select().from(workspaces).where(eq(workspaces.id, id)).get(); - if (!updated) throw new Error(`Workspace ${id} not found after update`); - return rowToRecord(updated); - } - - function deleteWorkspace(id: string): { success: boolean } { - db.delete(workspaces).where(eq(workspaces.id, id)).run(); - return { success: true }; - } - - return { init, list, create, update, delete: deleteWorkspace }; -} -``` - -- [ ] **Step 2: Create workspace handlers** - -Create `src/main/features/workspaces/workspaces-handlers.ts`: - -```typescript -import { WORKSPACES } from '@shared/ipc/misc/workspaces.channels'; - -import type { IpcRouter } from '../../ipc/router'; -import type { WorkspacesService } from './workspaces-service'; - -export function registerWorkspacesHandlers( - router: IpcRouter, - service: WorkspacesService, -): void { - router.handle(WORKSPACES.LIST.ALL, () => - Promise.resolve(service.list()), - ); - - router.handle(WORKSPACES.CREATE.WORKSPACE, ({ name, description }) => - Promise.resolve(service.create({ name, description })), - ); - - router.handle(WORKSPACES.UPDATE.WORKSPACE, ({ id, name, description, hostDeviceId, settings }) => - Promise.resolve(service.update(id, { name, description, hostDeviceId, settings })), - ); - - router.handle(WORKSPACES.DELETE.WORKSPACE, ({ id }) => - Promise.resolve(service.delete(id)), - ); -} -``` - -- [ ] **Step 3: Typecheck** - -Run: `npx tsc --noEmit` -Expected: no errors - -- [ ] **Step 4: Commit** - -```bash -git add src/main/features/workspaces/workspaces-service.ts src/main/features/workspaces/workspaces-handlers.ts -git commit -m "feat(workspaces): add SQLite service + IPC handlers" -``` - ---- - -### Task 3: Fix Workflow Handler Stubs - -**Files:** -- Modify: `src/main/features/workflow/workflow-handlers.ts` -- Modify: `src/main/ipc/index.ts` - -The three stubs at lines 116-126 of `workflow-handlers.ts` throw or return hardcoded values. Fix them to delegate to `busSessionManager`. - -- [ ] **Step 1: Add busSessionManager parameter to registerWorkflowHandlers** - -In `src/main/features/workflow/workflow-handlers.ts`, update the import and function signature: - -Add import at the top (after existing imports): -```typescript -import type { BusSessionManager } from '../../bus/session-manager'; -``` - -Change the function signature from: -```typescript -export function registerWorkflowHandlers( - router: IpcRouter, - hubApiClient: HubApiClient, - workflowEngineService: WorkflowEngineService, - workflowTemplateService: WorkflowTemplateService, -): void { -``` -To: -```typescript -export function registerWorkflowHandlers( - router: IpcRouter, - hubApiClient: HubApiClient, - workflowEngineService: WorkflowEngineService, - workflowTemplateService: WorkflowTemplateService, - busSessionManager: BusSessionManager, -): void { -``` - -- [ ] **Step 2: Replace the three broken stubs** - -Replace lines 114-126 (the task launcher + check/stop stubs): - -Old code: -```typescript - // ── Task Launcher (deprecated — use command bus sessions) ── - - router.handle(WORKFLOW.LAUNCH.WORKFLOW, () => { - throw new Error('Task launcher has been removed. Use command bus sessions instead.'); - }); - - router.handle(WORKFLOW.CHECK.RUNNING, () => - Promise.resolve({ running: false }), - ); - - router.handle(WORKFLOW.STOP.RUNNING, () => - Promise.resolve({ stopped: false }), - ); -``` - -New code: -```typescript - // ── Task Launcher ───────────────────────────────────────── - - router.handle(WORKFLOW.LAUNCH.WORKFLOW, async ({ taskDescription, projectPath }) => { - const session = await busSessionManager.spawn({ - name: `task-${Date.now()}`, - type: 'team-lead', - projectPath, - prompt: taskDescription, - }); - return { sessionId: session.id, pid: session.pid ?? 0 }; - }); - - router.handle(WORKFLOW.CHECK.RUNNING, ({ sessionId }) => { - const sessions = busSessionManager.list({ status: 'active', taskSlug: sessionId }); - return Promise.resolve({ running: sessions.length > 0 }); - }); - - router.handle(WORKFLOW.STOP.RUNNING, async ({ sessionId }) => { - await busSessionManager.kill(sessionId); - return { stopped: true }; - }); -``` - -- [ ] **Step 3: Update the call site in ipc/index.ts** - -In `src/main/ipc/index.ts`, find the `registerWorkflowHandlers` call (around line 304): - -Old: -```typescript - registerWorkflowHandlers( - router, - services.hubApiClient, - services.workflowEngineService, - services.workflowTemplateService, - ); -``` - -New: -```typescript - registerWorkflowHandlers( - router, - services.hubApiClient, - services.workflowEngineService, - services.workflowTemplateService, - services.busSessionManager, - ); -``` - -- [ ] **Step 4: Typecheck + lint** - -Run: `npx tsc --noEmit && npx eslint src/main/features/workflow/workflow-handlers.ts src/main/ipc/index.ts` -Expected: no errors - -- [ ] **Step 5: Commit** - -```bash -git add src/main/features/workflow/workflow-handlers.ts src/main/ipc/index.ts -git commit -m "fix(workflow): wire LAUNCH/CHECK/STOP to busSessionManager" -``` - ---- - -### Task 4: Deprecation Cleanup - -**Files:** -- Modify: `src/shared/ipc/misc/index.ts` (remove timeInvoke export) -- Modify: `src/shared/ipc/index.ts` (remove timeInvoke import + spread) -- Move: 3 historical doc files to `docs/archive/` - -- [ ] **Step 1: Remove timeInvoke from misc barrel** - -In `src/shared/ipc/misc/index.ts`, remove these lines: - -```typescript -// ── Time ── -export { timeInvoke } from './time.contract'; -``` - -- [ ] **Step 2: Remove timeInvoke from IPC contract index** - -In `src/shared/ipc/index.ts`: - -Remove `timeInvoke,` from the import (around line 47): -```typescript -import { - alertsEvents, - alertsInvoke, - calendarInvoke, - changelogInvoke, - ideasEvents, - ideasInvoke, - insightsInvoke, - mcpInvoke, - mergeInvoke, - milestonesEvents, - milestonesInvoke, - timeInvoke, // ← remove this line - webhookEvents, - workspacesInvoke, -} from './misc'; -``` - -Remove `...timeInvoke,` from `ipcInvokeContract` (around line 109): -```typescript - ...timeInvoke, // ← remove this line -``` - -- [ ] **Step 3: Typecheck to confirm no renderer usage** - -Run: `npx tsc --noEmit` -Expected: no errors (renderer doesn't use TIME.PARSE.EXPRESSION anywhere) - -- [ ] **Step 4: Archive historical docs** - -```bash -mkdir -p docs/archive -mv docs/architecture/V2-REFACTOR.md docs/archive/V2-REFACTOR.md -mv docs/features/task-pipeline/plan.md docs/archive/task-pipeline-plan.md -mv docs/features/task-pipeline/implementation-plan.md docs/archive/task-pipeline-implementation-plan.md -mv docs/features/session-persistence/plan.md docs/archive/session-persistence-plan.md -``` - -- [ ] **Step 5: Typecheck** - -Run: `npx tsc --noEmit` -Expected: no errors - -- [ ] **Step 6: Commit** - -```bash -git add -A src/shared/ipc/misc/index.ts src/shared/ipc/index.ts docs/archive/ -git commit -m "chore: remove misc/time from preload allowlist, archive historical docs" -``` - ---- - -## Wave 2 — Workspace Wiring + Renderer Stubs - -> Tasks 5-6 can run in parallel. Task 7 depends on Task 2 (workspace service). - ---- - -### Task 5: Wire Workspaces into App Bootstrap - -**Files:** -- Modify: `src/main/bootstrap/service-registry.ts` -- Modify: `src/main/ipc/index.ts` - -**Dependencies:** Task 2 (workspaces-service.ts must exist) - -- [ ] **Step 1: Add import to service-registry.ts** - -In `src/main/bootstrap/service-registry.ts`, add import after the existing feature imports (around line 90): - -```typescript -import { createWorkspacesService } from '../features/workspaces/workspaces-service'; -``` - -- [ ] **Step 2: Add WorkspacesService to ServiceRegistryResult type** - -In `src/main/bootstrap/service-registry.ts`, in the `ServiceRegistryResult` interface (around line 119), add: - -```typescript - workspacesService: ReturnType<typeof createWorkspacesService>; -``` - -- [ ] **Step 3: Instantiate the service in createServiceRegistry** - -In `src/main/bootstrap/service-registry.ts`, in the `createServiceRegistry` function, after the `commandBus` and `busSessionManager` lines (around line 196), add: - -```typescript - const workspacesService = createWorkspacesService({ db }); - workspacesService.init(); -``` - -- [ ] **Step 4: Add to the services bag** - -In `src/main/bootstrap/service-registry.ts`, in the `services: Services = { ... }` object (around line 522), add: - -```typescript - workspacesService, -``` - -- [ ] **Step 5: Return from createServiceRegistry** - -In `src/main/bootstrap/service-registry.ts`, in the `return { ... }` object (around line 586), add: - -```typescript - workspacesService, -``` - -- [ ] **Step 6: Add WorkspacesService to Services type in ipc/index.ts** - -In `src/main/ipc/index.ts`, add import after existing imports: - -```typescript -import type { WorkspacesService } from '../features/workspaces/workspaces-service'; -``` - -In the `Services` interface (around line 123), add: - -```typescript - workspacesService: WorkspacesService; -``` - -- [ ] **Step 7: Replace inline workspace throws in registerAllHandlers** - -In `src/main/ipc/index.ts`, add import at the top: - -```typescript -import { registerWorkspacesHandlers } from '../features/workspaces/workspaces-handlers'; -``` - -Find the inline workspace block (around line 336-348): - -```typescript - // Workspaces CRUD — Hub-backed, no local service yet. - // Returns empty list for reads; throws descriptive error for mutations - // since the typed contract requires a full workspace object on success. - router.handle(WORKSPACES.LIST.ALL, () => Promise.resolve([])); - router.handle(WORKSPACES.CREATE.WORKSPACE, () => { - throw new Error('Cannot create workspace: Hub connection required. Connect to a Hub first.'); - }); - router.handle(WORKSPACES.UPDATE.WORKSPACE, () => { - throw new Error('Cannot update workspace: Hub connection required. Connect to a Hub first.'); - }); - router.handle(WORKSPACES.DELETE.WORKSPACE, () => { - throw new Error('Cannot delete workspace: Hub connection required. Connect to a Hub first.'); - }); -``` - -Replace with: - -```typescript - registerWorkspacesHandlers(router, services.workspacesService); -``` - -- [ ] **Step 8: Typecheck + lint** - -Run: `npx tsc --noEmit && npx eslint src/main/bootstrap/service-registry.ts src/main/ipc/index.ts` -Expected: no errors - -- [ ] **Step 9: Commit** - -```bash -git add src/main/bootstrap/service-registry.ts src/main/ipc/index.ts -git commit -m "feat(workspaces): wire service into app bootstrap, replace Hub stubs" -``` - ---- - -### Task 6: Command Bus Renderer Wiring - -**Files:** -- Create: `src/renderer/features/bus/api/queryKeys.ts` -- Modify: `src/renderer/features/tasks/api/useAgentMutations.ts` -- Modify: `src/renderer/features/workflow/api/useWorkflow.ts` - -- [ ] **Step 1: Create bus query keys factory** - -Create `src/renderer/features/bus/api/queryKeys.ts`: - -```typescript -/** - * Bus query keys factory - */ -export const busKeys = { - all: ['bus'] as const, - sessions: () => [...busKeys.all, 'sessions'] as const, - session: (sessionId: string) => [...busKeys.all, 'session', sessionId] as const, -}; -``` - -- [ ] **Step 2: Replace useAgentMutations.ts stubs** - -Replace the entire content of `src/renderer/features/tasks/api/useAgentMutations.ts` with: - -```typescript -/** - * Agent mutation hooks — wired to command bus IPC - */ - -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import { BUS } from '@shared/ipc/bus/channels'; - -import { useMutationErrorToast } from '@renderer/shared/hooks'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { busKeys } from '../../bus/api/queryKeys'; - -/** Start planning for a task */ -export function useStartPlanning() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: (input: { - taskId: string; - projectPath: string; - taskDescription: string; - subProjectPath?: string; - }) => - ipc(BUS.SPAWN.SESSION, { - name: `planning-${input.taskId}`, - type: 'team-lead', - phase: 'planning', - taskSlug: input.taskId, - projectPath: input.projectPath, - prompt: input.taskDescription, - }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: busKeys.sessions() }); - }, - onError: onError('start planning'), - }); -} - -/** Start execution for a task */ -export function useStartExecution() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: (input: { - taskId: string; - projectPath: string; - taskDescription: string; - planRef?: string; - subProjectPath?: string; - }) => - ipc(BUS.SPAWN.SESSION, { - name: `execution-${input.taskId}`, - type: 'team-lead', - phase: 'executing', - taskSlug: input.taskId, - projectPath: input.projectPath, - prompt: input.planRef - ? `${input.taskDescription}\n\nPlan: ${input.planRef}` - : input.taskDescription, - }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: busKeys.sessions() }); - }, - onError: onError('start execution'), - }); -} - -/** Re-plan a task with user feedback */ -export function useReplanWithFeedback() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: (input: { - taskId: string; - projectPath: string; - taskDescription: string; - feedback: string; - previousPlanPath?: string; - subProjectPath?: string; - }) => - ipc(BUS.SPAWN.SESSION, { - name: `replan-${input.taskId}`, - type: 'team-lead', - phase: 'planning', - taskSlug: input.taskId, - projectPath: input.projectPath, - prompt: input.feedback, - }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: busKeys.sessions() }); - }, - onError: onError('re-plan with feedback'), - }); -} - -/** Kill an active agent session */ -export function useKillAgent() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: (input: { sessionId: string }) => - ipc(BUS.KILL.SESSION, { sessionId: input.sessionId }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: busKeys.sessions() }); - }, - onError: onError('kill agent'), - }); -} - -/** Restart an agent from its last checkpoint */ -export function useRestartFromCheckpoint() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: async (input: { taskId: string; projectPath: string }) => { - // Find any active session for this task - const sessions = await ipc(BUS.LIST.SESSIONS, { taskSlug: input.taskId }); - const active = sessions.find((s) => s.status === 'active'); - if (active) { - await ipc(BUS.KILL.SESSION, { sessionId: active.id }); - } - // Spawn a fresh session - return ipc(BUS.SPAWN.SESSION, { - name: `restart-${input.taskId}`, - type: 'team-lead', - phase: 'executing', - taskSlug: input.taskId, - projectPath: input.projectPath, - prompt: 'Resume from last checkpoint.', - }); - }, - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: busKeys.sessions() }); - }, - onError: onError('restart from checkpoint'), - }); -} -``` - -- [ ] **Step 3: Fix useWorkflow.ts** - -Replace the entire content of `src/renderer/features/workflow/api/useWorkflow.ts` with: - -```typescript -/** - * React Query hooks for workflow operations - */ - -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { BUS } from '@shared/ipc/bus/channels'; -import { WORKFLOW } from '@shared/ipc/workflow/channels'; -import type { InvokeInput } from '@shared/ipc-contract'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { busKeys } from '../../bus/api/queryKeys'; -import { workflowKeys } from './queryKeys'; - -/** Start watching progress files in a project */ -export function useStartProgressWatcher() { - return useMutation({ - mutationFn: (data: InvokeInput<typeof WORKFLOW.WATCH.PROGRESS>) => - ipc(WORKFLOW.WATCH.PROGRESS, data), - }); -} - -/** Stop watching progress files */ -export function useStopProgressWatcher() { - return useMutation({ - mutationFn: (data: InvokeInput<typeof WORKFLOW.STOP.WATCHING>) => - ipc(WORKFLOW.STOP.WATCHING, data), - }); -} - -/** Launch a task execution via command bus */ -export function useLaunchTask() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: { - name: string; - type: 'team-lead' | 'project-owner' | 'assistant' | 'qa' | 'research' | 'planner'; - phase?: 'research' | 'planning' | 'executing' | 'qa'; - projectPath?: string; - prompt: string; - taskSlug?: string; - }) => ipc(BUS.SPAWN.SESSION, data), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: busKeys.sessions() }); - }, - }); -} - -/** Check if a session is running — polls bus sessions every 5s */ -export function useSessionStatus(sessionId: string) { - return useQuery({ - queryKey: workflowKeys.session(sessionId), - queryFn: async () => { - const sessions = await ipc(BUS.LIST.SESSIONS, { taskSlug: sessionId }); - const running = sessions.some((s) => s.status === 'active'); - return { running }; - }, - enabled: sessionId.length > 0, - refetchInterval: 5_000, - }); -} - -/** Stop a running session */ -export function useStopSession() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: { sessionId: string }) => - ipc(BUS.KILL.SESSION, { sessionId: data.sessionId }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: busKeys.sessions() }); - }, - }); -} -``` - -- [ ] **Step 4: Typecheck + lint** - -Run: `npx tsc --noEmit && npx eslint src/renderer/features/bus/api/queryKeys.ts src/renderer/features/tasks/api/useAgentMutations.ts src/renderer/features/workflow/api/useWorkflow.ts` -Expected: no errors - -- [ ] **Step 5: Commit** - -```bash -git add src/renderer/features/bus/api/queryKeys.ts src/renderer/features/tasks/api/useAgentMutations.ts src/renderer/features/workflow/api/useWorkflow.ts -git commit -m "feat(bus): wire useAgentMutations + useWorkflow to command bus IPC" -``` - ---- - -## Wave 3 — Visualization - -> Tasks 7-10 are independent of each other. Assign to parallel agents. - ---- - -### Task 7: Consolidate useVisualization.ts + Delete visualization-api.ts - -**Files:** -- Modify: `src/renderer/features/visualization/api/useVisualization.ts` -- Delete: `src/renderer/features/visualization/api/visualization-api.ts` -- Modify: `src/renderer/features/visualization/components/canvas/VisualizationCanvas.tsx` (import update) -- Modify: `src/renderer/features/visualization/components/VisualizationPage.tsx` (import update) -- Modify: `src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx` (import update) -- Modify: `src/renderer/features/visualization/components/panels/node-detail/SessionLogSection.tsx` (import update) - -- [ ] **Step 1: Implement useVisualization.ts** - -Replace the single TODO comment in `src/renderer/features/visualization/api/useVisualization.ts` with: - -```typescript -/** - * Visualization React Query hooks — Feature Slice Design canonical hooks file. - * - * Replaces the now-deleted visualization-api.ts workaround. - */ - -import { useQuery } from '@tanstack/react-query'; - -import { VISUALIZATION } from '@shared/ipc/visualization/channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { visualizationKeys } from './queryKeys'; - -/** Fetch codebase dependency graph — cached for 5 minutes */ -export function useCodebaseGraph(projectId: string) { - return useQuery({ - queryKey: visualizationKeys.codebaseGraph(projectId), - queryFn: () => ipc(VISUALIZATION.GET['CODEBASE-GRAPH'], { projectId }), - staleTime: 300_000, - enabled: !!projectId, - }); -} - -/** Fetch agent teams data — refreshes every 10 seconds */ -export function useAgentTeams(projectId: string) { - return useQuery({ - queryKey: visualizationKeys.agentTeams(projectId), - queryFn: () => ipc(VISUALIZATION.GET['AGENT-TEAMS'], { projectId }), - refetchInterval: 10_000, - enabled: !!projectId, - }); -} - -/** Fetch paginated session log for a specific agent */ -export function useSessionLog( - projectId: string, - feature: string, - agentName: string, - cursor?: number, -) { - return useQuery({ - queryKey: visualizationKeys.sessionLog(projectId, feature, agentName, cursor), - queryFn: () => - ipc(VISUALIZATION.GET['SESSION-LOG'], { projectId, feature, agentName, cursor }), - enabled: !!projectId && !!feature && !!agentName, - }); -} -``` - -- [ ] **Step 2: Update imports in VisualizationCanvas.tsx** - -In `src/renderer/features/visualization/components/canvas/VisualizationCanvas.tsx`, change: - -```typescript -import { useAgentTeams, useCodebaseGraph } from '../../api/visualization-api'; -``` -To: -```typescript -import { useAgentTeams, useCodebaseGraph } from '../../api/useVisualization'; -``` - -- [ ] **Step 3: Update imports in VisualizationPage.tsx** - -In `src/renderer/features/visualization/components/VisualizationPage.tsx`, change: - -```typescript -import { useAgentTeams, useCodebaseGraph } from '../api/visualization-api'; -``` -To: -```typescript -import { useAgentTeams, useCodebaseGraph } from '../api/useVisualization'; -``` - -- [ ] **Step 4: Update imports in NodeDetailPanel.tsx** - -In `src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx`, change: - -```typescript -import { useAgentTeams } from '../../api/visualization-api'; -``` -To: -```typescript -import { useAgentTeams } from '../../api/useVisualization'; -``` - -- [ ] **Step 5: Update imports in SessionLogSection.tsx** - -In `src/renderer/features/visualization/components/panels/node-detail/SessionLogSection.tsx`, change: - -```typescript -import { useSessionLog } from '../../../api/visualization-api'; -``` -To: -```typescript -import { useSessionLog } from '../../../api/useVisualization'; -``` - -- [ ] **Step 6: Delete visualization-api.ts** - -```bash -rm src/renderer/features/visualization/api/visualization-api.ts -``` - -- [ ] **Step 7: Grep to confirm no remaining imports from visualization-api** - -Run: `grep -r "visualization-api" src/` -Expected: no matches - -- [ ] **Step 8: Typecheck + lint** - -Run: `npx tsc --noEmit && npx eslint src/renderer/features/visualization/api/useVisualization.ts src/renderer/features/visualization/components/canvas/VisualizationCanvas.tsx src/renderer/features/visualization/components/VisualizationPage.tsx src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx src/renderer/features/visualization/components/panels/node-detail/SessionLogSection.tsx` -Expected: no errors - -- [ ] **Step 9: Commit** - -```bash -git add src/renderer/features/visualization/api/useVisualization.ts src/renderer/features/visualization/components/canvas/VisualizationCanvas.tsx src/renderer/features/visualization/components/VisualizationPage.tsx src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx src/renderer/features/visualization/components/panels/node-detail/SessionLogSection.tsx -git rm src/renderer/features/visualization/api/visualization-api.ts -git commit -m "refactor(visualization): consolidate hooks into useVisualization.ts, delete visualization-api.ts" -``` - ---- - -### Task 8: EventBridge BUS_EVENTS.SESSION.* Append Handlers - -**Files:** -- Modify: `src/renderer/shared/components/EventBridge.tsx` - -This task adds live session event → cache update wiring. When a bus session changes status, the `visualizationKeys.agentTeams(projectId)` cache is patched in place using `setQueryData`. - -- [ ] **Step 1: Add imports to EventBridge.tsx** - -In `src/renderer/shared/components/EventBridge.tsx`, add after the existing imports: - -```typescript -import { BUS_EVENTS } from '@shared/ipc/bus/channels'; -import type { z } from 'zod'; -import type { sessionRecordSchema } from '@shared/ipc/bus/schemas'; -import type { AgentTeamsData } from '@shared/ipc/visualization/schemas'; - -import { visualizationKeys } from '@features/visualization/api/queryKeys'; -``` - -Note: `AgentTeamsData` needs to be imported as a type. Check if it's exported from `@shared/ipc/visualization/schemas` or `@shared/ipc`. Use: -```typescript -import type { AgentTeamsDataSchema } from '@shared/ipc/visualization/schemas'; -type AgentTeamsData = z.infer<typeof AgentTeamsDataSchema>; -``` - -And for session records: -```typescript -type SessionRecord = z.infer<typeof sessionRecordSchema>; -``` - -- [ ] **Step 2: Add VISUALIZATION_AGENTS key constant** - -In EventBridge.tsx, in the "Shared Key Constants" section (around line 64), add: - -```typescript -const VISUALIZATION_AGENTS = ['visualization', 'agents'] as const; -``` - -- [ ] **Step 3: Add BUS_EVENTS.SESSION.* to EVENT_REGISTRY** - -In the `EVENT_REGISTRY` object, add these 5 entries at the end (before the closing `}`): - -```typescript - // Bus session events — update visualization agent nodes in-place - [BUS_EVENTS.SESSION.SPAWNED]: { keys: [VISUALIZATION_AGENTS], handler: 'append' as const }, - [BUS_EVENTS.SESSION.ACTIVE]: { keys: [VISUALIZATION_AGENTS], handler: 'append' as const }, - [BUS_EVENTS.SESSION.COMPLETED]: { keys: [VISUALIZATION_AGENTS], handler: 'append' as const }, - [BUS_EVENTS.SESSION.ERROR]: { keys: [VISUALIZATION_AGENTS], handler: 'append' as const }, - [BUS_EVENTS.SESSION.KILLED]: { keys: [VISUALIZATION_AGENTS], handler: 'append' as const }, -``` - -- [ ] **Step 4: Map session status to AgentStatus** - -In EventBridge.tsx, add a helper after the existing imports section: - -```typescript -/** Map bus SessionRecord.status to visualization AgentStatus */ -function sessionStatusToAgentStatus( - status: string, -): 'pending' | 'active' | 'idle' | 'completed' | 'error' | 'killed' { - switch (status) { - case 'spawned': return 'pending'; - case 'active': return 'active'; - case 'completed': return 'completed'; - case 'error': return 'error'; - case 'killed': return 'killed'; - default: return 'idle'; - } -} -``` - -- [ ] **Step 5: Add BUS_SESSION_EVENTS set constant and implement the append handler** - -Add this constant just below the `sessionStatusToAgentStatus` helper (outside `handleAppend`): - -```typescript -const BUS_SESSION_EVENTS = new Set<string>([ - BUS_EVENTS.SESSION.SPAWNED, - BUS_EVENTS.SESSION.ACTIVE, - BUS_EVENTS.SESSION.COMPLETED, - BUS_EVENTS.SESSION.ERROR, - BUS_EVENTS.SESSION.KILLED, -]); -``` - -In the `handleAppend` function, add after the existing `AGENT_DASHBOARD_EVENTS.MESSAGE.RECEIVED` block: - -```typescript - if (BUS_SESSION_EVENTS.has(event)) { - const { sessionId, session } = payload as { sessionId: string; session: SessionRecord }; - if (!session.projectId) return; - - const agentStatus = sessionStatusToAgentStatus(session.status); - - queryClient.setQueryData<AgentTeamsData>( - visualizationKeys.agentTeams(session.projectId), - (old) => { - if (!old) return old; - return { - ...old, - features: old.features.map((f) => ({ - ...f, - tasks: f.tasks.map((t) => - t.lastSid === sessionId || t.agentName === session.name - ? { ...t, status: agentStatus, lastSid: sessionId } - : t, - ), - })), - }; - }, - ); - } -``` - -- [ ] **Step 6: Typecheck + lint** - -Run: `npx tsc --noEmit && npx eslint src/renderer/shared/components/EventBridge.tsx` -Expected: no errors - -- [ ] **Step 7: Commit** - -```bash -git add src/renderer/shared/components/EventBridge.tsx -git commit -m "feat(visualization): EventBridge wires BUS_EVENTS.SESSION.* to agent node cache updates" -``` - ---- - -### Task 9: AgentTaskNode Status Ring Styling - -**Files:** -- Modify: `src/shared/ipc/visualization/schemas.ts` (add 'killed' to AgentStatusSchema) -- Modify: `src/renderer/features/visualization/components/nodes/AgentTaskNode.tsx` - -- [ ] **Step 1: Add 'killed' to AgentStatusSchema** - -In `src/shared/ipc/visualization/schemas.ts`, find: - -```typescript -export const AgentStatusSchema = z.enum([ - 'pending', - 'active', - 'idle', - 'completed', - 'error', -]); -``` - -Change to: - -```typescript -export const AgentStatusSchema = z.enum([ - 'pending', - 'active', - 'idle', - 'completed', - 'error', - 'killed', -]); -``` - -- [ ] **Step 2: Update AgentTaskNode statusVariant and add ring CSS** - -In `src/renderer/features/visualization/components/nodes/AgentTaskNode.tsx`, update the `statusVariant` function to handle 'killed': - -```typescript -function statusVariant( - status: AgentStatus, -): 'success' | 'error' | 'warning' | 'neutral' { - switch (status) { - case 'active': { - return 'success'; - } - case 'completed': { - return 'success'; - } - case 'error': { - return 'error'; - } - case 'killed': - case 'idle': - case 'pending': { - return 'neutral'; - } - } -} -``` - -Add a `ringClass` helper function after `statusVariant`: - -```typescript -function ringClass(status: AgentStatus): string { - switch (status) { - case 'active': { - return 'ring-2 ring-green-500'; - } - case 'error': { - return 'ring-2 ring-red-500'; - } - case 'completed': - case 'killed': { - return 'opacity-60'; - } - default: { - return ''; - } - } -} -``` - -Update the wrapper `div` className in the component body: - -Old: -```typescript - className={[ - 'min-w-[180px] rounded-md border bg-background/95 px-3 py-2 shadow-sm', - selected ? 'ring-2 ring-primary' : '', - ].join(' ')} -``` - -New: -```typescript - className={[ - 'min-w-[180px] rounded-md border bg-background/95 px-3 py-2 shadow-sm', - selected ? 'ring-2 ring-primary' : ringClass(data.status), - ].join(' ')} -``` - -- [ ] **Step 3: Typecheck + lint** - -Run: `npx tsc --noEmit && npx eslint src/shared/ipc/visualization/schemas.ts src/renderer/features/visualization/components/nodes/AgentTaskNode.tsx` -Expected: no errors - -- [ ] **Step 4: Commit** - -```bash -git add src/shared/ipc/visualization/schemas.ts src/renderer/features/visualization/components/nodes/AgentTaskNode.tsx -git commit -m "feat(visualization): add ring status styling to AgentTaskNode, add 'killed' status" -``` - ---- - -### Task 10: FeatureGroupDetail — Real Content - -**Files:** -- Modify: `src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx` -- Modify: `src/renderer/features/visualization/components/panels/node-detail/node-content.tsx` -- Modify: `src/renderer/features/visualization/components/panels/node-detail/FeatureGroupDetail.tsx` - -**Dependencies:** Task 7 must be done first (useCodebaseGraph import must come from useVisualization.ts) - -- [ ] **Step 1: Update NodeDetailPanel to also fetch codebase graph and derive feature data** - -In `src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx`, add the import: - -```typescript -import { useAgentTeams, useCodebaseGraph } from '../../api/useVisualization'; -``` -(replacing the old `visualization-api` import — Task 7 will have done this, just verify it's correct) - -Also add in the component body after the `agentTeamsData` lines: - -```typescript - const { data: codebaseGraphData } = useCodebaseGraph(projectId); - - const featureAgentTasks = featureData?.tasks ?? []; - const featureFiles = codebaseGraphData?.files.filter((f) => f.group === featureName) ?? []; -``` - -Update the `renderNodeContent` call context to include the new fields: - -Old: -```typescript - {renderNodeContent(node, { - featureEvents, - agentTeamsLoading, - featureName, - projectId, - getFileEdges, - })} -``` - -New: -```typescript - {renderNodeContent(node, { - featureEvents, - agentTeamsLoading, - featureName, - projectId, - getFileEdges, - featureAgentTasks, - featureFiles, - })} -``` - -- [ ] **Step 2: Update NodeContentContext in node-content.tsx** - -In `src/renderer/features/visualization/components/panels/node-detail/node-content.tsx`, update the `NodeContentContext` interface: - -```typescript -import type { AgentTaskInfoSchema } from '@shared/ipc/visualization/schemas'; -import type { z } from 'zod'; -import type { CodebaseFileSchema } from '@shared/ipc/visualization/schemas'; - -type AgentTaskInfo = z.infer<typeof AgentTaskInfoSchema>; -type CodebaseFile = z.infer<typeof CodebaseFileSchema>; -``` - -Add to the `NodeContentContext` interface: - -```typescript -export interface NodeContentContext { - agentTeamsLoading: boolean; - featureEvents: TrackingEvent[]; - featureName: string; - featureAgentTasks: AgentTaskInfo[]; - featureFiles: CodebaseFile[]; - getFileEdges: (path: string) => { exports: string[]; imports: string[] }; - projectId: string; -} -``` - -Update the `featureGroup` case in `renderNodeContent`: - -```typescript - case 'featureGroup': { - return ( - <FeatureGroupDetail - data={node.data as unknown as FeatureGroupData} - agentTasks={featureAgentTasks} - files={featureFiles} - projectId={projectId} - /> - ); - } -``` - -- [ ] **Step 3: Fill FeatureGroupDetail with real content** - -Replace the entire content of `src/renderer/features/visualization/components/panels/node-detail/FeatureGroupDetail.tsx` with: - -```typescript -/** - * FeatureGroupDetail — detail view for a feature-group node. - * Shows: metadata, file list, active agents, and session log. - */ - -import { Badge, MetadataItem, MetadataList, Text } from '@ui'; - -import { SessionLogSection } from './SessionLogSection'; -import { statusVariant } from './types'; - -import type { AgentTaskInfoSchema, CodebaseFileSchema } from '@shared/ipc/visualization/schemas'; -import type { z } from 'zod'; -import type { FeatureGroupData } from '../../../lib/graph-builders'; - -type AgentTaskInfo = z.infer<typeof AgentTaskInfoSchema>; -type CodebaseFile = z.infer<typeof CodebaseFileSchema>; - -// ─── Props ────────────────────────────────────────────────────────────────── - -export interface FeatureGroupDetailProps { - data: FeatureGroupData; - agentTasks: AgentTaskInfo[]; - files: CodebaseFile[]; - projectId: string; -} - -// ─── Component ────────────────────────────────────────────────────────────── - -export function FeatureGroupDetail({ data, agentTasks, files, projectId }: FeatureGroupDetailProps) { - const activeAgents = agentTasks.filter((t) => t.status === 'active'); - - return ( - <div className="space-y-4 p-4"> - <MetadataList> - <MetadataItem label="Feature" value={data.feature} /> - <MetadataItem - label="Status" - value={<Badge variant={statusVariant(data.status)}>{data.status}</Badge>} - /> - {data.branch !== null && ( - <MetadataItem - label="Branch" - value={ - <code className="block rounded bg-muted px-2 py-1 font-mono text-xs"> - {data.branch} - </code> - } - /> - )} - <MetadataItem label="Agents" value={data.agentCount} /> - </MetadataList> - - {/* Active Agents */} - {activeAgents.length > 0 && ( - <section> - <Text className="mb-2 text-xs font-semibold uppercase text-muted-foreground"> - Active Agents - </Text> - <ul className="space-y-1"> - {activeAgents.map((agent) => ( - <li - key={agent.agentName} - className="flex items-center justify-between rounded bg-muted/30 px-2 py-1 text-xs" - > - <span className="font-medium">{agent.agentName}</span> - {agent.wave !== null && ( - <span className="text-muted-foreground">Wave {agent.wave}</span> - )} - </li> - ))} - </ul> - </section> - )} - - {/* Files */} - {files.length > 0 && ( - <section> - <Text className="mb-2 text-xs font-semibold uppercase text-muted-foreground"> - Files ({files.length}) - </Text> - <ul className="max-h-40 space-y-0.5 overflow-y-auto"> - {files.map((f) => ( - <li key={f.path} className="truncate rounded px-1 py-0.5 text-xs text-muted-foreground hover:bg-muted/50"> - {f.relativePath} - </li> - ))} - </ul> - </section> - )} - - {/* Session Logs for active agents */} - {activeAgents.map((agent) => ( - <SessionLogSection - key={agent.agentName} - agentName={agent.agentName} - feature={data.feature} - projectId={projectId} - /> - ))} - </div> - ); -} -``` - -- [ ] **Step 4: Typecheck + lint** - -Run: `npx tsc --noEmit && npx eslint src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx src/renderer/features/visualization/components/panels/node-detail/node-content.tsx src/renderer/features/visualization/components/panels/node-detail/FeatureGroupDetail.tsx` -Expected: no errors - -- [ ] **Step 5: Commit** - -```bash -git add src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx src/renderer/features/visualization/components/panels/node-detail/node-content.tsx src/renderer/features/visualization/components/panels/node-detail/FeatureGroupDetail.tsx -git commit -m "feat(visualization): FeatureGroupDetail shows files, active agents, and session logs" -``` - ---- - -## Wave 4 — Documentation + Polish - ---- - -### Task 11: CLAUDE.md — Update Cache-Update Pattern - -**Files:** -- Modify: `CLAUDE.md` - -The current CLAUDE.md says "React Query mutations use simple `onSuccess` invalidation — NOT optimistic updates (IPC is <1ms)". This is incomplete — it doesn't mention the event-driven `setQueryData` pattern used in EventBridge. - -- [ ] **Step 1: Update the Data Layer section** - -In `CLAUDE.md`, find: - -``` -- React Query mutations use simple `onSuccess` invalidation — NOT optimistic updates (IPC is <1ms) -``` - -Replace with: - -``` -- **Mutations** use `onSuccess` invalidation (`queryClient.invalidateQueries`) — NOT optimistic updates (IPC is <1ms) -- **Event-driven cache updates** use `setQueryData` via EventBridge `append` handlers — this is distinct from mutation invalidation. When IPC events arrive (e.g., `BUS_EVENTS.SESSION.*`), the EventBridge patches the cache directly without a re-fetch. -``` - -- [ ] **Step 2: Commit** - -```bash -git add CLAUDE.md -git commit -m "docs(claude.md): clarify mutation invalidation vs event-driven setQueryData" -``` - ---- - -### Task 12: createdAt Timestamps Globally (Task 2.8) - -**Files:** -- Modify list/card components for: notes, ideas, milestones, alerts, captures, progress tasks - -Add `createdAt` shown as relative time ("3d ago") to list views. Hover tooltip shows absolute time. Use `date-fns` `formatDistanceToNow` or the existing relative time utilities in the codebase. - -- [ ] **Step 1: Find the relative time utility** - -Run: `grep -r "formatDistanceToNow\|relativeTime\|timeAgo" src/renderer/shared/ --include="*.ts" --include="*.tsx" -l` - -If found, use that utility. If not found, use `date-fns`: - -```typescript -import { formatDistanceToNow } from 'date-fns'; - -function relativeTime(iso: string): string { - return formatDistanceToNow(new Date(iso), { addSuffix: true }); -} -``` - -- [ ] **Step 2: Add createdAt to Notes list** - -Find the notes list component (likely `src/renderer/features/personal/notes/components/NotesPage.tsx` or similar). Locate where note cards are rendered and add after the title: - -```tsx -<span - className="text-xs text-muted-foreground" - title={new Date(note.createdAt).toLocaleString()} -> - {relativeTime(note.createdAt)} -</span> -``` - -- [ ] **Step 3: Add createdAt to Ideas list** - -Find `src/renderer/features/ideation/components/IdeationPage.tsx` or idea card component. Add relative time display in the same pattern. - -- [ ] **Step 4: Add createdAt to Milestones** - -Find `src/renderer/features/roadmap/components/RoadmapPage.tsx`. Add relative time to each milestone card. - -- [ ] **Step 5: Add createdAt to Alerts** - -Find `src/renderer/features/personal/alerts/components/AlertsPage.tsx`. Add relative time display. - -- [ ] **Step 6: Add createdAt to Captures (QuickCapture)** - -Find `src/renderer/features/dashboard/components/QuickCapture.tsx`. Add relative time to each capture item. - -- [ ] **Step 7: Add createdAt to Progress Tasks grid** - -In `src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx`, add a `createdAt` column or display in the detail row. Find where `ProgressTask` fields are displayed and add: - -```tsx -<span - className="text-xs text-muted-foreground" - title={new Date(task.createdAt).toLocaleString()} -> - {relativeTime(task.createdAt)} -</span> -``` - -- [ ] **Step 8: Typecheck + lint** - -Run: `npx tsc --noEmit` -Expected: no errors - -- [ ] **Step 9: Commit** - -```bash -git add -p # stage relevant files -git commit -m "feat(ui): add relative createdAt timestamps to all list views" -``` - ---- - -## Final Validation - -- [ ] **Full typecheck** - -Run: `npx tsc --noEmit` -Expected: no errors - -- [ ] **Lint changed files** - -Run: `npx eslint src/main/features/workspaces/ src/renderer/features/bus/ src/renderer/features/tasks/api/useAgentMutations.ts src/renderer/features/workflow/api/useWorkflow.ts src/renderer/features/visualization/` -Expected: no errors - -- [ ] **Acceptance criteria review** - -Check each area's acceptance criteria from the spec at `docs/superpowers/specs/2026-04-13-sprint-6-7-foundation-design.md`. Mark complete. - ---- - -## Dependency Map - -``` -Wave 1 (parallel): - Task 1 (migration) → Task 2 (service) → Task 5 (registry wiring) - Task 3 (workflow stubs) — independent - Task 4 (deprecation) — independent - -Wave 2: - Task 5 (workspaces wiring) — needs Task 2 - Task 6 (bus renderer) — independent - -Wave 3 (parallel): - Task 7 (useVisualization) → Task 10 (FeatureGroupDetail needs useVisualization import) - Task 8 (EventBridge bus events) — independent - Task 9 (AgentTaskNode rings) — independent - -Wave 4 (parallel): - Task 11 (CLAUDE.md) — independent - Task 12 (createdAt) — independent -``` diff --git a/docs/superpowers/plans/2026-04-17-test-suite-gap-closure.md b/docs/superpowers/plans/2026-04-17-test-suite-gap-closure.md new file mode 100644 index 00000000..66a6c580 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-test-suite-gap-closure.md @@ -0,0 +1,567 @@ +# Test Suite Gap Closure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close all 11 gaps in the test-suite feature — runtime crashes, IPC schema mismatches, dead UI buttons, broken exports, and duplicate hooks/query keys. + +**Architecture:** Fix the IPC contract first (shared layer), then backend service/handler layer, then renderer hooks and components. Each task produces a committable unit. No new files except one extracted hook (`useExportRun.ts`). + +**Tech Stack:** TypeScript, Zod, Drizzle ORM, React Query, Zustand, Electron IPC + +**Spec:** `docs/superpowers/specs/2026-04-17-test-suite-gap-closure-design.md` + +--- + +### Task 1: Expand QaScriptSchema and LIST.SCRIPTS contract + +**Files:** +- Modify: `src/shared/ipc/test-suite/schemas.ts:82-89` +- Modify: `src/shared/ipc/test-suite/contract.ts:47-49` + +- [ ] **Step 1: Expand QaScriptSchema in schemas.ts** + +Replace the existing `QaScriptSchema` (lines 82-89) with the full shape matching the DB store: + +```ts +export const QaScriptSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + steps: z.array(TestSuiteStepSchema), + filePath: z.string(), + projectId: z.string(), + targetUrl: z.string(), + stepCount: z.number().int(), + lastStatus: z.string().nullable(), + lastRunAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +``` + +- [ ] **Step 2: Add projectId to LIST.SCRIPTS input in contract.ts** + +Replace the LIST.SCRIPTS entry (lines 47-49): + +```ts +[TEST_SUITE.LIST.SCRIPTS]: { + input: z.object({ projectId: z.string() }), + output: z.array(QaScriptSchema), +}, +``` + +- [ ] **Step 3: Run typecheck** + +Run: `npx tsc --noEmit 2>&1 | head -40` + +Expected: Type errors in renderer files that derive `QaScript` from the old schema shape (e.g., `useScripts.ts`). These are expected — they get fixed in later tasks. Backend files should have no new errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/shared/ipc/test-suite/schemas.ts src/shared/ipc/test-suite/contract.ts +git commit -m "fix(test-suite): expand QaScriptSchema, add projectId to LIST.SCRIPTS contract" +``` + +--- + +### Task 2: Fix service facade — listScriptsByProject, runner.run() projectId, exportFile steps + +**Files:** +- Modify: `src/main/features/test-suite/index.ts:86-131` (interface) and `215-293` (methods) +- Modify: `src/main/features/test-suite/recorder-handlers.ts:69-99` (local interface) and `145-146` (handler) +- Modify: `src/main/features/qa/qa-trigger.ts:131` + +- [ ] **Step 1: Add listScriptsByProject to TestSuiteService interface in index.ts** + +In the `TestSuiteService` interface (around line 103), add after `listScripts`: + +```ts +listScriptsByProject: (projectId: string) => Promise<QaScript[]>; +``` + +- [ ] **Step 2: Add listScriptsByProject facade method in index.ts** + +After the `listScripts` method (around line 215), add: + +```ts +listScriptsByProject: (projectId) => Promise.resolve(scriptStore.listByProject(projectId)), +``` + +- [ ] **Step 3: Fix runScript() — add projectId to runner.run() call in index.ts** + +In the `runScript` method (around line 259), change the `runner.run()` call from: + +```ts +const runId = runner.run({ + scriptId, + filePath: script.filePath, + projectPath, + triggeredBy, + screenshotDir, + handlers: sharedHandlers, +}); +``` + +To: + +```ts +const runId = runner.run({ + scriptId, + projectId: script.projectId, + filePath: script.filePath, + projectPath, + triggeredBy, + screenshotDir, + handlers: sharedHandlers, +}); +``` + +- [ ] **Step 4: Fix exportFile() — pass actual steps in index.ts** + +In the `exportFile` method (around line 291), change: + +```ts +steps: [], +``` + +To: + +```ts +steps: script.steps, +``` + +- [ ] **Step 5: Update local TestSuiteService interface in recorder-handlers.ts** + +In the local `TestSuiteService` interface (around line 69), add after `listScripts`: + +```ts +listScriptsByProject: (projectId: string) => Promise<unknown[]>; +``` + +- [ ] **Step 6: Update LIST.SCRIPTS handler in recorder-handlers.ts** + +Change the handler (around line 145) from: + +```ts +router.handle(TEST_SUITE.LIST.SCRIPTS, () => + testSuiteService.listScripts() as never, +); +``` + +To: + +```ts +router.handle(TEST_SUITE.LIST.SCRIPTS, ({ projectId }) => + testSuiteService.listScriptsByProject(projectId) as never, +); +``` + +- [ ] **Step 7: Fix runner.run() call in qa-trigger.ts** + +In `qa-trigger.ts` (around line 131), change: + +```ts +testSuiteService.runner.run({ + scriptId: script.id, + filePath: script.filePath, + projectPath, + triggeredBy: 'auto-trigger', + taskId, +}); +``` + +To: + +```ts +testSuiteService.runner.run({ + scriptId: script.id, + projectId: script.projectId, + filePath: script.filePath, + projectPath, + triggeredBy: 'auto-trigger', + taskId, +}); +``` + +- [ ] **Step 8: Run typecheck on backend files** + +Run: `npx tsc --noEmit 2>&1 | grep -E "src/main/" | head -20` + +Expected: Zero backend errors. Renderer errors still expected (fixed in later tasks). + +- [ ] **Step 9: Commit** + +```bash +git add src/main/features/test-suite/index.ts src/main/features/test-suite/recorder-handlers.ts src/main/features/qa/qa-trigger.ts +git commit -m "fix(test-suite): add listScriptsByProject, fix runner.run() projectId, fix exportFile steps" +``` + +--- + +### Task 3: Consolidate query keys — delete queryKeys.ts, update testSuiteKeys.ts + +**Files:** +- Delete: `src/renderer/features/test-suite/api/queryKeys.ts` +- Modify: `src/renderer/features/test-suite/api/testSuiteKeys.ts` +- Modify: `src/renderer/features/test-suite/api/useRuns.ts` + +- [ ] **Step 1: Update testSuiteKeys.ts — add allRuns and configs keys** + +Replace the entire file with: + +```ts +const ANALYTICS = 'analytics'; + +export const testSuiteKeys = { + all: ['test-suite'] as const, + config: (projectId: string) => [...testSuiteKeys.all, 'config', projectId] as const, + configs: (projectId: string) => [...testSuiteKeys.all, 'configs', projectId] as const, + scripts: (projectId: string) => [...testSuiteKeys.all, 'scripts', projectId] as const, + script: (id: string) => [...testSuiteKeys.all, 'script', id] as const, + runs: (scriptId: string) => [...testSuiteKeys.all, 'runs', scriptId] as const, + allRuns: (projectId: string) => [...testSuiteKeys.all, 'runs', 'all', projectId] as const, + run: (runId: string) => [...testSuiteKeys.all, 'run', runId] as const, + screenshots: (runId: string) => [...testSuiteKeys.all, 'screenshots', runId] as const, + analytics: { + all: (projectId: string) => [...testSuiteKeys.all, ANALYTICS, projectId] as const, + summary: (projectId: string) => [...testSuiteKeys.all, ANALYTICS, 'summary', projectId] as const, + trend: (projectId: string) => [...testSuiteKeys.all, ANALYTICS, 'trend', projectId] as const, + topFailures: (projectId: string) => [...testSuiteKeys.all, ANALYTICS, 'top-failures', projectId] as const, + slowest: (projectId: string) => [...testSuiteKeys.all, ANALYTICS, 'slowest', projectId] as const, + errorPatterns: (projectId: string) => [...testSuiteKeys.all, ANALYTICS, 'error-patterns', projectId] as const, + flaky: (projectId: string) => [...testSuiteKeys.all, ANALYTICS, 'flaky', projectId] as const, + runHistory: (scriptId: string) => [...testSuiteKeys.all, ANALYTICS, 'run-history', scriptId] as const, + }, +}; +``` + +- [ ] **Step 2: Rewrite useRuns.ts — switch from queryKeys to testSuiteKeys** + +Replace the entire file with: + +```ts +/** + * React Query hooks for QA run queries and mutations + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { useMutationErrorToast } from '@renderer/shared/hooks'; +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +/** Fetch all runs, optionally filtered by scriptId */ +export function useRuns(scriptId?: string) { + return useQuery({ + queryKey: scriptId ? testSuiteKeys.runs(scriptId) : testSuiteKeys.all, + queryFn: () => ipc(TEST_SUITE.LIST.RUNS, { scriptId }), + staleTime: 10_000, + }); +} + +/** Fetch a single run by id */ +export function useRun(runId: string | null) { + return useQuery({ + queryKey: testSuiteKeys.run(runId ?? ''), + queryFn: () => ipc(TEST_SUITE.GET.RUN, { runId: runId ?? '' }), + enabled: runId !== null && runId.length > 0, + staleTime: 5_000, + }); +} + +/** Run a QA script */ +export function useRunScript() { + const queryClient = useQueryClient(); + const { onError } = useMutationErrorToast(); + return useMutation({ + mutationFn: ({ + scriptId, + triggeredBy = 'manual', + }: { + scriptId: string; + triggeredBy?: 'manual' | 'scheduled' | 'ci'; + }) => ipc(TEST_SUITE.RUN.SCRIPT, { scriptId, triggeredBy }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: testSuiteKeys.all }); + }, + onError: onError('run QA script'), + }); +} +``` + +- [ ] **Step 3: Delete queryKeys.ts** + +```bash +rm src/renderer/features/test-suite/api/queryKeys.ts +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/features/test-suite/api/testSuiteKeys.ts src/renderer/features/test-suite/api/useRuns.ts +git rm src/renderer/features/test-suite/api/queryKeys.ts +git commit -m "refactor(test-suite): consolidate to single testSuiteKeys query key factory" +``` + +--- + +### Task 4: Delete duplicate hooks, extract useExportRun, fix useTestSuiteScripts + +**Files:** +- Delete: `src/renderer/features/test-suite/api/useScriptMutations.ts` +- Delete: `src/renderer/features/test-suite/api/useScripts.ts` +- Create: `src/renderer/features/test-suite/api/useExportRun.ts` +- Modify: `src/renderer/features/test-suite/api/useTestSuiteScripts.ts` +- Modify: `src/renderer/features/test-suite/store.ts:10` +- Modify: `src/renderer/features/test-suite/components/StepPanel.tsx:31` +- Modify: `src/renderer/features/test-suite/components/ScriptSelector.tsx:17-21` +- Modify: `src/renderer/features/test-suite/api/index.ts` + +- [ ] **Step 1: Create useExportRun.ts — extracted from useScriptMutations.ts** + +Create `src/renderer/features/test-suite/api/useExportRun.ts`: + +```ts +import { useMutation } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { useMutationErrorToast } from '@renderer/shared/hooks'; +import { ipc } from '@renderer/shared/lib/ipc'; + +/** Export a run to file */ +export function useExportRun() { + const { onError } = useMutationErrorToast(); + return useMutation({ + mutationFn: ({ runId, format }: { runId: string; format: 'json' | 'html' | 'csv' }) => + ipc(TEST_SUITE.EXPORT.FILE, { runId, format }), + onError: onError('export run'), + }); +} +``` + +- [ ] **Step 2: Fix store.ts — switch TestSuiteStep import source** + +In `src/renderer/features/test-suite/store.ts`, change line 10 from: + +```ts +import type { TestSuiteStep } from './api/useScriptMutations'; +``` + +To: + +```ts +import type { TestSuiteStep } from '@shared/types/test-suite'; +``` + +- [ ] **Step 3: Fix StepPanel.tsx — switch TestSuiteStep import source** + +In `src/renderer/features/test-suite/components/StepPanel.tsx`, change line 31 from: + +```ts +import type { TestSuiteStep } from '../api/useScriptMutations'; +``` + +To: + +```ts +import type { TestSuiteStep } from '@shared/types/test-suite'; +``` + +- [ ] **Step 4: Fix ScriptSelector.tsx — switch from useScripts to useTestSuiteScripts** + +Replace lines 17-21 in `src/renderer/features/test-suite/components/ScriptSelector.tsx`: + +```ts +import { useScripts } from '../api/useScripts'; +import { useTestSuiteStore } from '../store'; + +export function ScriptSelector() { + const { data: scripts, isLoading } = useScripts(); +``` + +With: + +```ts +import { useLooseParams } from '@renderer/shared/hooks'; + +import { useTestSuiteScripts } from '../api/useTestSuiteScripts'; +import { useTestSuiteStore } from '../store'; + +export function ScriptSelector() { + const { projectId } = useLooseParams(); + const { data: scripts, isLoading } = useTestSuiteScripts(projectId); +``` + +- [ ] **Step 5: Fix useTestSuiteScripts.ts — pass projectId to IPC call** + +In `src/renderer/features/test-suite/api/useTestSuiteScripts.ts`, change the `queryFn` from: + +```ts +queryFn: () => ipc(TEST_SUITE.LIST.SCRIPTS, {}), +``` + +To: + +```ts +queryFn: () => ipc(TEST_SUITE.LIST.SCRIPTS, { projectId: projectId! }), +``` + +- [ ] **Step 6: Delete useScriptMutations.ts and useScripts.ts** + +```bash +rm src/renderer/features/test-suite/api/useScriptMutations.ts +rm src/renderer/features/test-suite/api/useScripts.ts +``` + +- [ ] **Step 7: Update api/index.ts barrel — remove deleted exports, add useExportRun** + +Replace the entire file with: + +```ts +export { testSuiteKeys } from './testSuiteKeys'; +export { useAttachRunToTask } from './useAttachRunToTask'; +export { useTestSuiteConfig } from './useTestSuiteConfig'; +export { useTestSuiteConfigs } from './useTestSuiteConfigs'; +export { useTestSuiteScripts } from './useTestSuiteScripts'; +export { useTestSuiteRuns } from './useTestSuiteRuns'; +export { useTestSuiteScreenshots } from './useTestSuiteScreenshots'; +export { useSaveTestSuiteConfig } from './useSaveTestSuiteConfig'; +export { useDeleteTestSuiteConfig } from './useDeleteTestSuiteConfig'; +export { useSetActiveTestSuiteConfig } from './useSetActiveTestSuiteConfig'; +export { useStartRecording } from './useStartRecording'; +export { useStopRecording } from './useStopRecording'; +export { useSaveScript } from './useSaveScript'; +export { useDeleteScript } from './useDeleteScript'; +export { useExportRun } from './useExportRun'; +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/renderer/features/test-suite/api/useExportRun.ts src/renderer/features/test-suite/api/useTestSuiteScripts.ts src/renderer/features/test-suite/api/index.ts src/renderer/features/test-suite/store.ts src/renderer/features/test-suite/components/StepPanel.tsx src/renderer/features/test-suite/components/ScriptSelector.tsx +git rm src/renderer/features/test-suite/api/useScriptMutations.ts src/renderer/features/test-suite/api/useScripts.ts +git commit -m "refactor(test-suite): delete duplicate hooks, extract useExportRun, fix imports" +``` + +--- + +### Task 5: Wire Run and Run Selected buttons in LibraryPanel + +**Files:** +- Modify: `src/renderer/features/test-suite/components/LibraryPanel.tsx` + +- [ ] **Step 1: Import useRunScript** + +In `LibraryPanel.tsx`, add this import alongside the existing test-suite API imports (around line 40): + +```ts +import { useRunScript } from '../api/useRuns'; +``` + +- [ ] **Step 2: Instantiate useRunScript in the component** + +Inside the `LibraryPanel` function body, after the existing hook calls (around line 71), add: + +```ts +const runScript = useRunScript(); +``` + +- [ ] **Step 3: Wire the per-script Run button** + +Find the Run button (around line 243): + +```tsx +<Button size="icon" title="Run" variant="ghost"> + <Play className="h-4 w-4" /> +</Button> +``` + +Replace with: + +```tsx +<Button + size="icon" + title="Run" + variant="ghost" + onClick={() => runScript.mutate({ scriptId: script.id })} +> + <Play className="h-4 w-4" /> +</Button> +``` + +- [ ] **Step 4: Wire the Run Selected bulk button** + +Find the Run Selected button (around line 322): + +```tsx +<Button size="sm" variant="ghost"> + <Play className="h-3 w-3" /> Run Selected +</Button> +``` + +Replace with: + +```tsx +<Button + size="sm" + variant="ghost" + onClick={() => { + for (const id of selected) runScript.mutate({ scriptId: id }); + }} +> + <Play className="h-3 w-3" /> Run Selected +</Button> +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/features/test-suite/components/LibraryPanel.tsx +git commit -m "fix(test-suite): wire Run and Run Selected buttons in LibraryPanel" +``` + +--- + +### Task 6: Typecheck, lint, and verify + +**Files:** All files modified in tasks 1-5 + +- [ ] **Step 1: Run full typecheck** + +Run: `npx tsc --noEmit` + +Expected: Zero errors. + +- [ ] **Step 2: Lint changed files** + +Run: +```bash +npx eslint src/shared/ipc/test-suite/schemas.ts src/shared/ipc/test-suite/contract.ts src/main/features/test-suite/index.ts src/main/features/test-suite/recorder-handlers.ts src/main/features/qa/qa-trigger.ts src/renderer/features/test-suite/api/testSuiteKeys.ts src/renderer/features/test-suite/api/useRuns.ts src/renderer/features/test-suite/api/useExportRun.ts src/renderer/features/test-suite/api/useTestSuiteScripts.ts src/renderer/features/test-suite/api/index.ts src/renderer/features/test-suite/store.ts src/renderer/features/test-suite/components/StepPanel.tsx src/renderer/features/test-suite/components/ScriptSelector.tsx src/renderer/features/test-suite/components/LibraryPanel.tsx +``` + +Expected: Zero errors. Fix any import ordering or lint issues. + +- [ ] **Step 3: Kill Electron and restart** + +Run: `taskkill //F //IM electron.exe 2>/dev/null; cd C:/Users/Parke/Desktop/Agent-Desktop-Command && npm run dev` + +- [ ] **Step 4: Verify manually** + +Open the app. Navigate to a project's Test Suite tab. +1. Library should show only scripts for the current project (not all projects) +2. Record a test → Save → Library shows the test with correct step count and description +3. Click Run on a script → run executes (check Results tab for output) +4. Select multiple scripts → Run Selected → all selected scripts run + +- [ ] **Step 5: Commit any lint fixes** + +If lint found issues: +```bash +git add -u +git commit -m "fix(test-suite): lint fixes for gap closure" +``` diff --git a/docs/superpowers/plans/2026-04-18-test-suite-compositional-audit.md b/docs/superpowers/plans/2026-04-18-test-suite-compositional-audit.md new file mode 100644 index 00000000..f90eb19e --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-test-suite-compositional-audit.md @@ -0,0 +1,1141 @@ +# Test Suite Compositional Audit & Standards Cleanup + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring every file in `src/renderer/features/test-suite/` and `src/main/features/test-suite/` into full compliance with codebase standards — @ui primitives everywhere, magic numbers extracted to constants, oversized components decomposed into compositional sub-components, and recorder-handlers.ts fully split into domain handler files. + +**Architecture:** Bottom-up approach: first fix constants and shared utilities, then replace raw HTML across all files, then decompose the 6 largest components into compositional structures where each sub-component owns its own data. Finally split the remaining recorder-handlers.ts monolith into focused handler files. + +**Tech Stack:** React 19, TypeScript, `@ui` design system (Flex, Stack, Text, Label, Badge, Button, Input, Select, SearchInput, MetricCard, SectionHeader, Separator, ScrollArea, Grid, Progress), Zustand, TanStack Query, Drizzle ORM + +**Codebase Rules (every subagent MUST follow):** +1. **NEVER use raw HTML** — no `<div>` for layout (use `Flex`/`Stack`/`Grid`), no `<p>`/`<span>` (use `Text`), no `<button>` (use `Button`), no `<input>` (use `Input`), no `<label>` (use `Label`), no `<select>` (use `Select`), no `<textarea>` (use `Textarea`). Exception: `<div>` is acceptable for CSS-only containers (absolute positioning, animation targets) that have no semantic layout role. +2. **Import from `@ui` barrel** — `import { Text, Flex, Stack } from '@ui'` +3. **Flex/Stack CVA defaults** — `Flex` defaults to `gap="md"` (gap-4), `align="center"`, `wrap="wrap"`. Override explicitly: `<Flex gap="none" align="stretch" wrap="nowrap">`. `Stack` defaults to `gap="md"` (gap-4). Override: `<Stack gap="sm">`. +4. **No magic numbers** — extract timeouts, sizes, thresholds to `lib/constants.ts` +5. **Compositional structure** — each component owns its own data. Parent is a thin composition shell that renders children. Logic in custom hooks, not in the component body. +6. **Lint only changed files** — `npx eslint <file1> <file2> ...` +7. **Typecheck after every task** — `npx tsc --noEmit` +8. **Commit after every task** +9. **Props alphabetical** — ESLint `react/jsx-sort-props` requires callbacks after all other props + +--- + +## File Structure + +### Constants (modify) +- `src/renderer/features/test-suite/lib/constants.ts` — add magic numbers from all components + +### Components to fix (raw HTML → @ui, no decomposition needed) +- `src/renderer/features/test-suite/components/ResultsToolbar.tsx` — 1 raw `<div>` → `Flex` +- `src/renderer/features/test-suite/components/TestSuitePage.tsx` — 2 raw `<div>` → `Text` +- `src/renderer/features/test-suite/components/RunStatusBadge.tsx` — 1 raw `<div>` → `StatusIndicator` or styled `<div>` (CSS-only dot, acceptable exception) +- `src/renderer/features/test-suite/components/RunSparkline.tsx` — 2 raw `<div>` containers +- `src/renderer/features/test-suite/components/ShortcutHelpDialog.tsx` — 2 raw `<div>` +- `src/renderer/features/test-suite/components/ScheduleDialog.tsx` — 2 raw `<div>` +- `src/renderer/features/test-suite/components/TrendChart.tsx` — 1 raw `<div>` +- `src/renderer/features/test-suite/components/RunLogDialog.tsx` — 4 raw `<div>` +- `src/renderer/features/test-suite/components/DataRunDialog.tsx` — 5 raw `<div>` +- `src/renderer/features/test-suite/components/DiffViewer.tsx` — 15 raw `<div>` +- `src/renderer/features/test-suite/components/ResultsWorkflowActions.tsx` — 1 raw `<div>` +- `src/renderer/features/test-suite/components/StepList.tsx` — 5 raw `<div>` +- `src/renderer/features/test-suite/components/StepTimeline.tsx` — 4 raw `<div>` (CSS-only: progress bar div is acceptable) + +### Components to decompose AND fix raw HTML +- `src/renderer/features/test-suite/components/LibraryPanel.tsx` (451 lines) → extract LibraryToolbar, LibraryTagFilter, LibraryScriptRow, LibraryBulkActions +- `src/renderer/features/test-suite/components/ConfigEditDialog.tsx` (390 lines) → extract ConfigBrowsersField, ConfigEnvironmentsList, ConfigAuthSection +- `src/renderer/features/test-suite/components/ScreenshotsPanel.tsx` (312 lines) → extract ScreenshotThumbnailStrip, ScreenshotPreview, ScreenshotCompareToolbar +- `src/renderer/features/test-suite/components/AnalyticsPanel.tsx` (284 lines) → extract HealthScoreCard, AnalyticsSummaryMetrics, AnalyticsDetailCards; move scoring functions to lib/ +- `src/renderer/features/test-suite/components/SaveRecordingDialog.tsx` (292 lines) → extract AssertionSuggestionsList; fix raw `<label>` +- `src/renderer/features/test-suite/components/RecordingPanel.tsx` (236 lines) → fix raw `<div>` layout containers +- `src/renderer/features/test-suite/components/BrowserViewPanel.tsx` (185 lines) → fix raw `<div>` layout containers +- `src/renderer/features/test-suite/components/SetupCard.tsx` (216 lines) → fix raw `<div>` form groups +- `src/renderer/features/test-suite/components/SharedStepsPanel.tsx` (213 lines) → fix raw `<div>` layout +- `src/renderer/features/test-suite/components/ExportPanel.tsx` (197 lines) → fix raw `<div>` layout + +### Hooks to extract (during decomposition) +- `src/renderer/features/test-suite/hooks/useLibraryFilters.ts` — search, status filter, tag filter logic from LibraryPanel +- `src/renderer/features/test-suite/hooks/useHealthScore.ts` — scoring computation from AnalyticsPanel + +### Main process handler split +- `src/main/features/test-suite/handlers/script-handlers.ts` — LIST/GET/SAVE/DELETE SCRIPTS +- `src/main/features/test-suite/handlers/run-handlers.ts` — RUN.SCRIPT, GET.RUN, LIST.RUNS +- `src/main/features/test-suite/handlers/browser-view-handlers.ts` — BROWSER-VIEW.* +- `src/main/features/test-suite/handlers/config-handlers.ts` — CONFIG.* +- `src/main/features/test-suite/handlers/screenshot-handlers.ts` — SCREENSHOT.* +- `src/main/features/test-suite/handlers/data-run-handlers.ts` — DATA-RUN.* +- `src/main/features/test-suite/handlers/export-handlers.ts` — EXPORT.* +- `src/main/features/test-suite/handlers/auth-handlers.ts` — AUTH.* + +--- + +### Task 1: Extract magic numbers to constants + +**Files:** +- Modify: `src/renderer/features/test-suite/lib/constants.ts` + +- [ ] **Step 1: Add all magic numbers as named constants** + +Add to `src/renderer/features/test-suite/lib/constants.ts`: + +```ts +// ── Default timeouts (ms) ── +export const DEFAULT_NAVIGATION_TIMEOUT = 30_000; +export const DEFAULT_ACTION_TIMEOUT = 10_000; + +// ── Default viewport ── +export const DEFAULT_VIEWPORT_WIDTH = 1280; +export const DEFAULT_VIEWPORT_HEIGHT = 720; +export const MIN_VIEWPORT_WIDTH = 320; +export const MIN_VIEWPORT_HEIGHT = 240; + +// ── Playwright config ── +export const MAX_WORKERS = 16; +export const MAX_RETRIES = 5; + +// ── Analytics thresholds ── +export const GRADE_THRESHOLDS = [ + { min: 90, grade: 'A', color: 'text-green-500' }, + { min: 80, grade: 'B', color: 'text-blue-500' }, + { min: 70, grade: 'C', color: 'text-yellow-500' }, + { min: 60, grade: 'D', color: 'text-orange-500' }, + { min: 0, grade: 'F', color: 'text-destructive' }, +] as const; + +export const HEALTH_WEIGHTS = { + passRate: 40, + stability: 30, + speed: 30, +} as const; + +export const SPEED_THRESHOLDS = { + fast: 5, + medium: 30, + curve: 25, +} as const; + +// ── UI constants ── +export const COPY_FEEDBACK_MS = 2_000; +export const ERROR_LINE_TRUNCATION = 20; +export const SPARKLINE_RUN_LIMIT = 10; +``` + +- [ ] **Step 2: Lint and typecheck** + +```bash +npx eslint src/renderer/features/test-suite/lib/constants.ts +npx tsc --noEmit +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/features/test-suite/lib/constants.ts +git commit -m "refactor(test-suite): extract magic numbers to lib/constants.ts" +``` + +--- + +### Task 2: Fix raw HTML in small components (batch 1 — toolbar/page/dialog files) + +**Files:** +- Modify: `src/renderer/features/test-suite/components/ResultsToolbar.tsx` +- Modify: `src/renderer/features/test-suite/components/TestSuitePage.tsx` +- Modify: `src/renderer/features/test-suite/components/ShortcutHelpDialog.tsx` +- Modify: `src/renderer/features/test-suite/components/ScheduleDialog.tsx` +- Modify: `src/renderer/features/test-suite/components/TrendChart.tsx` +- Modify: `src/renderer/features/test-suite/components/ResultsWorkflowActions.tsx` + +- [ ] **Step 1: Fix ResultsToolbar.tsx — replace root `<div>` with `Flex`** + +In `src/renderer/features/test-suite/components/ResultsToolbar.tsx`, line 74: + +Replace: +```tsx +<div className="flex items-center gap-2 border-b border-border px-4 py-2"> +``` +With: +```tsx +<Flex align="center" className="border-b border-border px-4 py-2" gap="sm" wrap="nowrap"> +``` + +And the matching closing tag: +```tsx +</div> +``` +→ +```tsx +</Flex> +``` + +Add `Flex` to the `@ui` import. + +- [ ] **Step 2: Fix TestSuitePage.tsx — replace raw `<div>` text with `<Text>`** + +In `src/renderer/features/test-suite/components/TestSuitePage.tsx`: + +Line 49: Replace: +```tsx +<div className="p-6 text-text-muted">No project selected.</div> +``` +With: +```tsx +<Text className="p-6" variant="muted">No project selected.</Text> +``` + +Line 55: Replace: +```tsx +<PageLayout><div className="p-6">Loading…</div></PageLayout> +``` +With: +```tsx +<PageLayout><Text className="p-6">Loading…</Text></PageLayout> +``` + +Add `Text` to the `@ui` import. + +- [ ] **Step 3: Fix ShortcutHelpDialog.tsx — replace raw `<div>` wrappers** + +In `src/renderer/features/test-suite/components/ShortcutHelpDialog.tsx`: + +Line 33: Replace: +```tsx +<div className="space-y-2"> +``` +With: +```tsx +<Stack gap="sm"> +``` + +Line 35: Replace: +```tsx +<div key={s.keys} className="flex items-center justify-between text-sm"> +``` +With: +```tsx +<Flex key={s.keys} align="center" justify="between"> +``` + +Update matching closing tags. Add `Flex, Stack` to imports. + +- [ ] **Step 4: Fix ScheduleDialog.tsx — replace raw `<div>` wrappers** + +In `src/renderer/features/test-suite/components/ScheduleDialog.tsx`: + +Line 54: Replace: +```tsx +<div className="space-y-3"> +``` +With: +```tsx +<Stack gap="md"> +``` + +Line 55: Replace: +```tsx +<div> +``` +With: +```tsx +<Stack gap="xs"> +``` + +Update matching closing tags. Add `Stack` to imports. + +- [ ] **Step 5: Fix TrendChart.tsx — replace raw `<div>`** + +Line 16: Replace: +```tsx +<div className="flex h-48 items-center justify-center text-sm text-text-muted"> +``` +With: +```tsx +<Flex align="center" className="h-48" justify="center"> + <Text size="sm" variant="muted"> +``` + +Update matching closing tag. Note: the `<line>` on line 48 is an SVG element, not HTML — leave it. + +Add `Flex, Text` to imports. + +- [ ] **Step 6: Fix ResultsWorkflowActions.tsx — replace raw `<div>`** + +Line 86: Replace: +```tsx +<div className="ml-auto flex items-center gap-2"> +``` +With: +```tsx +<Flex align="center" className="ml-auto" gap="sm" wrap="nowrap"> +``` + +Add `Flex` to imports. + +- [ ] **Step 7: Lint and typecheck** + +```bash +npx eslint src/renderer/features/test-suite/components/ResultsToolbar.tsx src/renderer/features/test-suite/components/TestSuitePage.tsx src/renderer/features/test-suite/components/ShortcutHelpDialog.tsx src/renderer/features/test-suite/components/ScheduleDialog.tsx src/renderer/features/test-suite/components/TrendChart.tsx src/renderer/features/test-suite/components/ResultsWorkflowActions.tsx +npx tsc --noEmit +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/renderer/features/test-suite/components/ResultsToolbar.tsx src/renderer/features/test-suite/components/TestSuitePage.tsx src/renderer/features/test-suite/components/ShortcutHelpDialog.tsx src/renderer/features/test-suite/components/ScheduleDialog.tsx src/renderer/features/test-suite/components/TrendChart.tsx src/renderer/features/test-suite/components/ResultsWorkflowActions.tsx +git commit -m "refactor(test-suite): replace raw HTML with @ui primitives in toolbar/page/dialog files" +``` + +--- + +### Task 3: Fix raw HTML in small components (batch 2 — step/run/data/diff files) + +**Files:** +- Modify: `src/renderer/features/test-suite/components/RunLogDialog.tsx` +- Modify: `src/renderer/features/test-suite/components/DataRunDialog.tsx` +- Modify: `src/renderer/features/test-suite/components/DiffViewer.tsx` +- Modify: `src/renderer/features/test-suite/components/StepList.tsx` +- Modify: `src/renderer/features/test-suite/components/RunSparkline.tsx` + +- [ ] **Step 1: Fix RunLogDialog.tsx** + +Read the file first. Replace all raw `<div>` elements: + +Line 70: `<div className="flex items-center gap-4 border-b pb-2">` → `<Flex align="center" className="border-b pb-2" gap="lg" wrap="nowrap">` +Line 102: `<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">` → `<Text className="rounded-md border border-destructive/30 bg-destructive/10 p-3" size="sm" variant="error">` +Line 111: `<div key={l.timestamp} className={getOutputLineClass(l.line)}>` → `<Text key={l.timestamp} as="div" className={getOutputLineClass(l.line)} size="sm">` + +Add `Flex, Text` to `@ui` imports. Update matching closing tags. + +- [ ] **Step 2: Fix DataRunDialog.tsx** + +Read the file first. Replace: + +Line 58: `<div className="space-y-3">` → `<Stack gap="md">` +Line 59: `<div>` → `<Stack gap="xs">` +Line 61: `<div className="flex gap-2">` → `<Flex gap="sm" wrap="nowrap">` +Line 75: `<div className="flex items-center gap-2">` → `<Flex align="center" gap="sm">` +Line 80: `<div className="max-h-48 overflow-auto rounded border border-border">` → `<ScrollArea className="max-h-48 rounded border border-border">` + +Add `Flex, Stack, ScrollArea` to `@ui` imports. Update matching closing tags. + +- [ ] **Step 3: Fix DiffViewer.tsx** + +Read the file first. This has 15 raw `<div>` elements. Replace layout divs: + +Line 49: `<div className="space-y-3">` → `<Stack gap="md">` +Line 51: `<div className="flex items-center gap-3">` → `<Flex align="center" gap="md">` +Line 69: `<div className="grid grid-cols-3 gap-2">` → `<Grid columns={3} gap="sm">` +Lines 70, 78, 86: `<div>` → `<Stack gap="xs">` +Line 95: `<div className="flex h-full items-center justify-center ...">` → `<Flex align="center" className="h-full ..." justify="center">` +Line 104: `<div className="space-y-2">` → `<Stack gap="sm">` +Line 105: `<div className="relative overflow-hidden rounded border border-border">` → Keep as `<div>` (CSS-only positioning container) +Lines 107, 113: Keep as `<div>` (absolute-positioned overlay layers) +Line 125: `<div className="flex justify-between">` → `<Flex justify="between">` +Line 133: `<div>` → `<Stack gap="xs">` +Line 141: `<div className="flex h-48 items-center justify-center ...">` → `<Flex align="center" className="h-48 ..." justify="center">` + +Add `Flex, Stack, Grid` to `@ui` imports. + +- [ ] **Step 4: Fix StepList.tsx** + +Read the file first. Replace: + +Line 59: `<div className="flex flex-col gap-1 overflow-y-auto p-3 text-sm">` → `<Stack className="overflow-y-auto p-3 text-sm" gap="xs">` +Lines 124, 129: These are the draggable step card `<div>` — keep as `<div>` since they are DnD targets with data attributes and event handlers. +Line 140: `<div className="flex items-center gap-1">` → `<Flex align="center" gap="xs">` +Line 157: `<div className="flex shrink-0 items-center gap-0.5">` → `<Flex align="center" className="shrink-0" gap="none">` + +Add `Flex, Stack` to `@ui` imports. + +- [ ] **Step 5: Fix RunSparkline.tsx** + +Line 28: `<div className="flex items-center gap-0.5">` → `<Flex align="center" gap="none">` +Line 38: `<div ...>` — This is a sparkline bar (CSS-only visual element with dynamic height/color). Keep as `<div>`. + +Add `Flex` to `@ui` imports. + +- [ ] **Step 6: Lint and typecheck** + +```bash +npx eslint src/renderer/features/test-suite/components/RunLogDialog.tsx src/renderer/features/test-suite/components/DataRunDialog.tsx src/renderer/features/test-suite/components/DiffViewer.tsx src/renderer/features/test-suite/components/StepList.tsx src/renderer/features/test-suite/components/RunSparkline.tsx +npx tsc --noEmit +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/features/test-suite/components/RunLogDialog.tsx src/renderer/features/test-suite/components/DataRunDialog.tsx src/renderer/features/test-suite/components/DiffViewer.tsx src/renderer/features/test-suite/components/StepList.tsx src/renderer/features/test-suite/components/RunSparkline.tsx +git commit -m "refactor(test-suite): replace raw HTML with @ui primitives in step/run/data/diff files" +``` + +--- + +### Task 4: Fix raw HTML and form controls in layout-heavy components + +**Files:** +- Modify: `src/renderer/features/test-suite/components/RecordingPanel.tsx` +- Modify: `src/renderer/features/test-suite/components/BrowserViewPanel.tsx` +- Modify: `src/renderer/features/test-suite/components/SetupCard.tsx` +- Modify: `src/renderer/features/test-suite/components/SharedStepsPanel.tsx` +- Modify: `src/renderer/features/test-suite/components/ExportPanel.tsx` + +- [ ] **Step 1: Fix RecordingPanel.tsx** + +Read the file first. Replace layout `<div>` elements: + +Line 66: `<div className={`h-2 w-2 shrink-0 rounded-full ...`} />` → Keep as `<div>` (CSS-only indicator dot) +Line 142: `<div className="flex h-full flex-col overflow-hidden rounded-md border border-border">` → `<Stack className="h-full overflow-hidden rounded-md border border-border" gap="none">` +Line 144: `<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">` → `<Flex align="center" className="shrink-0 border-b border-border px-3 py-1.5" gap="sm" wrap="nowrap">` +Line 172: `<div className="mx-1 h-4 w-px bg-border" />` → `<Separator className="mx-1 h-4" orientation="vertical" />` +Line 179: `<div title={recordTooltip || undefined}>` → Keep as `<div>` (tooltip wrapper, no layout role) +Line 194: `<div className="ml-auto flex items-center gap-2">` → `<Flex align="center" className="ml-auto" gap="sm" wrap="nowrap">` +Line 207: `<div className="flex flex-1 overflow-hidden">` → `<Flex className="flex-1 overflow-hidden" gap="none" wrap="nowrap">` +Line 209: `<div className="flex h-10 shrink-0 items-center border-b border-border px-3">` → `<Flex align="center" className="h-10 shrink-0 border-b border-border px-3" gap="none">` + +Also replace hardcoded viewport defaults `1280`, `720` with imports from `lib/constants.ts`: +```ts +import { DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT } from '../lib/constants'; +``` + +Add `Flex, Stack, Separator` to `@ui` imports. Update matching closing tags. + +- [ ] **Step 2: Fix BrowserViewPanel.tsx** + +Read the file first. Replace: + +Line 112: `<div className="flex h-full flex-1 flex-col overflow-hidden">` → `<Stack className="h-full flex-1 overflow-hidden" gap="none">` +Line 114: `<div className="flex h-10 shrink-0 items-center gap-2 border-b border-border px-3">` → `<Flex align="center" className="h-10 shrink-0 border-b border-border px-3" gap="sm" wrap="nowrap">` +Line 115: `<div className="flex items-center gap-1">` → `<Flex align="center" gap="xs">` +Line 151: `<div className="relative flex-1 overflow-hidden">` → Keep as `<div>` (positioning container for webview) +Line 163-164: Keep as `<div>` (absolute-positioned recording indicator, CSS-only animation) +Line 175: `<div className="flex items-center gap-1 text-xs text-warning">` → `<Flex align="center" gap="xs">` with inner `<Text size="sm" variant="muted">` + +Add `Flex, Stack, Text` to `@ui` imports. + +- [ ] **Step 3: Fix SetupCard.tsx** + +Read the file first. Replace form layout `<div>` elements with `Stack`: + +Line 132: `<div className="flex flex-col gap-2">` → `<Stack gap="sm">` +Lines 148, 159: Same pattern → `<Stack gap="sm">` +Line 147: `<div className="grid grid-cols-2 gap-4">` → `<Grid columns={2} gap="md">` +Line 172: `<div className="flex flex-col gap-2">` → `<Stack gap="sm">` +Line 191: `<div className="flex flex-col gap-2">` → `<Stack gap="sm">` +Line 206: `<div className="flex justify-end">` → `<Flex justify="end">` + +Replace hardcoded timeouts `30000`, `10000` with constants: +```ts +import { DEFAULT_NAVIGATION_TIMEOUT, DEFAULT_ACTION_TIMEOUT, MIN_VIEWPORT_WIDTH, MIN_VIEWPORT_HEIGHT } from '../lib/constants'; +``` + +Add `Flex, Stack, Grid` to `@ui` imports. + +- [ ] **Step 4: Fix SharedStepsPanel.tsx** + +Read the file first. Replace: + +Line 66: `<div className="flex items-center gap-2 border-b border-border px-4 py-3">` → `<Flex align="center" className="border-b border-border px-4 py-3" gap="sm" wrap="nowrap">` +Line 89: `<div className="grid gap-3 p-4 md:grid-cols-2">` → `<Grid className="p-4" columns={2} gap="md">` +Line 94: `<div className="flex items-center gap-2">` → `<Flex align="center" gap="sm">` +Line 108: `<div className="flex gap-1">` → `<Flex gap="xs">` +Lines 174, 175, 183, 191: `<div className="space-y-3">` → `<Stack gap="md">` and `<div>` → `<Stack gap="xs">` + +Add `Flex, Stack, Grid` to `@ui` imports. + +- [ ] **Step 5: Fix ExportPanel.tsx** + +Read the file first. Replace: + +Line 82: `<div className="grid grid-cols-1 gap-6 p-6 lg:grid-cols-2">` → `<Grid className="p-6" columns={2} gap="lg">` +Line 92: `<div className="rounded-md border border-border bg-surface-base p-3 font-mono text-sm">` → `<Code className="rounded-md border border-border bg-surface-base p-3">` (or keep as `<div>` since this is a code preview block — check if `Code` component supports block display) +Lines 93, 97, 101: `<div className="flex items-center gap-2 ...">` → `<Flex align="center" gap="sm">` +Line 111: `<div className="rounded-md border border-border-success bg-surface-success/10 p-3 text-sm text-text-success">` → `<Text className="rounded-md border border-border-success bg-surface-success/10 p-3" size="sm" variant="success">` +Line 116: `<div className="flex gap-2">` → `<Flex gap="sm">` +Lines 149, 153, 159, 163, 167, 173: `<div className="flex justify-between">` → `<Flex justify="between">` + +Add `Flex, Grid, Text` to `@ui` imports. + +- [ ] **Step 6: Lint and typecheck** + +```bash +npx eslint src/renderer/features/test-suite/components/RecordingPanel.tsx src/renderer/features/test-suite/components/BrowserViewPanel.tsx src/renderer/features/test-suite/components/SetupCard.tsx src/renderer/features/test-suite/components/SharedStepsPanel.tsx src/renderer/features/test-suite/components/ExportPanel.tsx +npx tsc --noEmit +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/features/test-suite/components/RecordingPanel.tsx src/renderer/features/test-suite/components/BrowserViewPanel.tsx src/renderer/features/test-suite/components/SetupCard.tsx src/renderer/features/test-suite/components/SharedStepsPanel.tsx src/renderer/features/test-suite/components/ExportPanel.tsx +git commit -m "refactor(test-suite): replace raw HTML with @ui primitives in layout-heavy components" +``` + +--- + +### Task 5: Fix raw `<label>` and decompose ConfigEditDialog + +**Files:** +- Modify: `src/renderer/features/test-suite/components/ConfigEditDialog.tsx` +- Modify: `src/renderer/features/test-suite/components/SaveRecordingDialog.tsx` + +- [ ] **Step 1: Fix ConfigEditDialog.tsx — replace raw `<label>` with @ui `Label`, replace `<div>` layout with @ui** + +Read the file first. + +Line 242: Replace: +```tsx +<label key={b} className="flex items-center gap-1.5 text-sm"> +``` +With: +```tsx +<Label key={b} className="flex items-center gap-1.5"> +``` + +Replace ALL `<div className="flex flex-col gap-2">` patterns (lines 162, 173, 189, 202, 217, 238, 256, 268, 281, 328, 363) with `<Stack gap="sm">`. + +Replace `<div className="grid grid-cols-2 gap-4">` (line 188) with `<Grid columns={2} gap="md">`. + +Replace `<div className="flex gap-3">` (line 240) with `<Flex gap="md">`. + +Replace `<div className="flex gap-2">` (line 285) with `<Flex gap="sm">`. + +Replace `<div className="flex items-center gap-2">` (line 331) with `<Flex align="center" gap="sm">`. + +Replace hardcoded timeout defaults `30000`, `10000` and viewport `1280`, `720`, min values `320`, `240`, max workers `16`, max retries `5` with constants from `lib/constants.ts`. + +Add `Flex, Stack, Grid, Label` to `@ui` imports. + +- [ ] **Step 2: Fix SaveRecordingDialog.tsx — replace raw `<label>` with @ui `Label`, replace `<div>` layout with @ui** + +Read the file first. + +Line 200: Replace: +```tsx +<label key={`${s.selector}-${s.expected}-${i}`} className="flex items-start gap-2 text-sm"> +``` +With: +```tsx +<Label key={`${s.selector}-${s.expected}-${i}`} className="flex items-start gap-2"> +``` + +Replace ALL `<div className="space-y-*">` patterns with `<Stack gap="...">`: +- `space-y-4` → `<Stack gap="md">` +- `space-y-2` → `<Stack gap="sm">` +- `space-y-1.5` → `<Stack gap="xs">` +- `space-y-1` → `<Stack gap="xs">` + +Line 240: `<div key={s.stepIndex} className="flex items-baseline gap-2 text-sm">` → `<Flex key={s.stepIndex} align="baseline" gap="sm">` + +Add `Flex, Stack, Label` to `@ui` imports. + +- [ ] **Step 3: Lint and typecheck** + +```bash +npx eslint src/renderer/features/test-suite/components/ConfigEditDialog.tsx src/renderer/features/test-suite/components/SaveRecordingDialog.tsx +npx tsc --noEmit +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/features/test-suite/components/ConfigEditDialog.tsx src/renderer/features/test-suite/components/SaveRecordingDialog.tsx +git commit -m "refactor(test-suite): replace raw label/div with @ui Label/Stack/Flex in config and save dialogs" +``` + +--- + +### Task 6: Decompose AnalyticsPanel into compositional components + +**Files:** +- Create: `src/renderer/features/test-suite/lib/health-score.ts` +- Create: `src/renderer/features/test-suite/components/HealthScoreCard.tsx` +- Create: `src/renderer/features/test-suite/components/AnalyticsSummaryMetrics.tsx` +- Create: `src/renderer/features/test-suite/components/AnalyticsDetailCards.tsx` +- Modify: `src/renderer/features/test-suite/components/AnalyticsPanel.tsx` + +- [ ] **Step 1: Create `lib/health-score.ts` — move pure scoring functions** + +Read `AnalyticsPanel.tsx` first to find all scoring functions. + +Create `src/renderer/features/test-suite/lib/health-score.ts`: +```ts +import { GRADE_THRESHOLDS, HEALTH_WEIGHTS, SPEED_THRESHOLDS } from './constants'; + +export interface HealthScore { + score: number; + grade: string; + color: string; + passRate: number; + stability: number; + speed: number; +} + +export function computeHealthScore(analytics: { + totalRuns: number; + passedRuns: number; + flakyTests: Array<{ flakyRate: number }>; + avgDurationSec: number; +}): HealthScore { + if (analytics.totalRuns === 0) { + return { score: 0, grade: 'N/A', color: 'text-text-muted', passRate: 0, stability: 0, speed: 0 }; + } + + const passRate = (analytics.passedRuns / analytics.totalRuns) * 100; + const stability = analytics.flakyTests.length === 0 + ? 100 + : 100 - analytics.flakyTests.reduce((sum, f) => sum + f.flakyRate, 0) / analytics.flakyTests.length; + const speed = speedFromAvgSec(analytics.avgDurationSec); + const score = Math.round( + (passRate * HEALTH_WEIGHTS.passRate + + stability * HEALTH_WEIGHTS.stability + + speed * HEALTH_WEIGHTS.speed) / 100, + ); + + const { grade, color } = gradeFromScore(score); + return { score, grade, color, passRate, stability, speed }; +} + +function gradeFromScore(score: number): { grade: string; color: string } { + for (const t of GRADE_THRESHOLDS) { + if (score >= t.min) return { grade: t.grade, color: t.color }; + } + return { grade: 'F', color: 'text-destructive' }; +} + +function speedFromAvgSec(avg: number): number { + if (avg <= SPEED_THRESHOLDS.fast) return 100; + if (avg >= SPEED_THRESHOLDS.medium) return 0; + return Math.round(100 * (1 - (avg - SPEED_THRESHOLDS.fast) / SPEED_THRESHOLDS.curve)); +} +``` + +- [ ] **Step 2: Create `HealthScoreCard.tsx`** + +Read `AnalyticsPanel.tsx` lines 120-145 to extract the health score display card. + +Create `src/renderer/features/test-suite/components/HealthScoreCard.tsx`: +```tsx +import { Flex, Progress, Stack, Text } from '@ui'; + +import type { HealthScore } from '../lib/health-score'; + +interface HealthScoreCardProps { + health: HealthScore; +} + +export function HealthScoreCard({ health }: HealthScoreCardProps) { + return ( + <Flex align="start" gap="lg" wrap="nowrap"> + <Text className={`text-6xl font-bold ${health.color}`}>{health.grade}</Text> + <Stack className="flex-1" gap="sm"> + <Flex align="center" justify="between"> + <Text size="sm" variant="muted">Overall Score</Text> + <Text className="font-semibold">{health.score}/100</Text> + </Flex> + <Stack gap="xs"> + <ScoreRow label="Pass Rate" value={health.passRate} /> + <ScoreRow label="Stability" value={health.stability} /> + <ScoreRow label="Speed" value={health.speed} /> + </Stack> + </Stack> + </Flex> + ); +} + +function ScoreRow({ label, value }: { label: string; value: number }) { + return ( + <Flex align="center" gap="sm"> + <Text className="w-20" size="sm" variant="muted">{label}</Text> + <Progress className="h-1.5 flex-1" value={value} /> + <Text className="w-10 text-right" size="sm">{Math.round(value)}%</Text> + </Flex> + ); +} +``` + +- [ ] **Step 3: Create `AnalyticsSummaryMetrics.tsx`** + +Read `AnalyticsPanel.tsx` lines 88-100 to extract the 4-column metrics grid. + +Create `src/renderer/features/test-suite/components/AnalyticsSummaryMetrics.tsx`: +```tsx +import { MetricCard } from '@ui'; + +interface AnalyticsSummaryMetricsProps { + totalScripts: number; + totalRuns: number; + passedRuns: number; + failedRuns: number; + avgDurationSec: number; +} + +export function AnalyticsSummaryMetrics({ + totalScripts, + totalRuns, + passedRuns, + failedRuns, + avgDurationSec, +}: AnalyticsSummaryMetricsProps) { + const passRate = totalRuns > 0 ? Math.round((passedRuns / totalRuns) * 100) : 0; + + return ( + <div className="grid grid-cols-4 gap-4 p-6"> + <MetricCard label="Scripts" value={totalScripts} /> + <MetricCard label="Total Runs" value={totalRuns} /> + <MetricCard label="Pass Rate" value={`${passRate}%`} /> + <MetricCard label="Avg Duration" value={`${avgDurationSec.toFixed(1)}s`} /> + </div> + ); +} +``` + +Note: Check if `MetricCard` accepts the right props — read the file at `src/renderer/shared/components/ui/metric-card.tsx` first. If not, use `<Stack>` + `<Text>` to build the card manually using @ui primitives. + +- [ ] **Step 4: Create `AnalyticsDetailCards.tsx`** + +Read `AnalyticsPanel.tsx` lines 160-270 to extract the detail cards section (flaky tests, slowest tests, most failing, error patterns). + +Create `src/renderer/features/test-suite/components/AnalyticsDetailCards.tsx`. This contains 4 card sections. Replace all raw `<div>` with @ui primitives: +- `<div className="space-y-*">` → `<Stack gap="...">` +- `<div className="flex items-center ...">` → `<Flex align="center" ...>` +- `<div className="grid grid-cols-* ...">` → `<Grid columns={...} ...>` + +Use `Text` for all text content. + +- [ ] **Step 5: Rewrite `AnalyticsPanel.tsx` as thin composition shell** + +Replace `AnalyticsPanel.tsx` to compose the extracted components: + +```tsx +import { useLooseParams } from '@renderer/shared/hooks'; +import { PageContent, SectionHeader, Stack } from '@ui'; + +import { useTestSuiteAnalytics, useRunHistory } from '../api/useTestSuiteAnalytics'; +import { useTestSuiteScripts } from '../api/useTestSuiteScripts'; +import { computeHealthScore } from '../lib/health-score'; + +import { AnalyticsDetailCards } from './AnalyticsDetailCards'; +import { AnalyticsSummaryMetrics } from './AnalyticsSummaryMetrics'; +import { HealthScoreCard } from './HealthScoreCard'; +import { TrendChart } from './TrendChart'; + +export function AnalyticsPanel() { + const { projectId } = useLooseParams(); + if (!projectId) return null; + return <AnalyticsPanelInner projectId={projectId} />; +} + +function AnalyticsPanelInner({ projectId }: { projectId: string }) { + const { data: analytics } = useTestSuiteAnalytics(projectId); + const { data: scripts = [] } = useTestSuiteScripts(projectId); + // ... remaining hook calls + + if (!analytics) return null; + + const health = computeHealthScore(analytics); + + return ( + <PageContent className="overflow-auto"> + <AnalyticsSummaryMetrics + avgDurationSec={analytics.avgDurationSec} + failedRuns={analytics.failedRuns} + passedRuns={analytics.passedRuns} + totalRuns={analytics.totalRuns} + totalScripts={scripts.length} + /> + <Stack className="p-6" gap="lg"> + <SectionHeader title="Test Health" /> + <HealthScoreCard health={health} /> + <SectionHeader title="Trends" /> + <TrendChart /> + <SectionHeader title="Details" /> + <AnalyticsDetailCards analytics={analytics} /> + </Stack> + </PageContent> + ); +} +``` + +Adjust exact props and hooks based on what you find when reading the current file. + +- [ ] **Step 6: Lint and typecheck** + +```bash +npx eslint src/renderer/features/test-suite/lib/health-score.ts src/renderer/features/test-suite/components/HealthScoreCard.tsx src/renderer/features/test-suite/components/AnalyticsSummaryMetrics.tsx src/renderer/features/test-suite/components/AnalyticsDetailCards.tsx src/renderer/features/test-suite/components/AnalyticsPanel.tsx +npx tsc --noEmit +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/features/test-suite/lib/health-score.ts src/renderer/features/test-suite/components/HealthScoreCard.tsx src/renderer/features/test-suite/components/AnalyticsSummaryMetrics.tsx src/renderer/features/test-suite/components/AnalyticsDetailCards.tsx src/renderer/features/test-suite/components/AnalyticsPanel.tsx +git commit -m "refactor(test-suite): decompose AnalyticsPanel into compositional sub-components" +``` + +--- + +### Task 7: Decompose ScreenshotsPanel into compositional components + +**Files:** +- Create: `src/renderer/features/test-suite/components/ScreenshotThumbnailStrip.tsx` +- Create: `src/renderer/features/test-suite/components/ScreenshotPreview.tsx` +- Modify: `src/renderer/features/test-suite/components/ScreenshotsPanel.tsx` + +- [ ] **Step 1: Read ScreenshotsPanel.tsx** + +Read the full file to understand the three visual regions: script/run selector toolbar, thumbnail strip (sidebar), and main preview pane. + +- [ ] **Step 2: Create `ScreenshotThumbnailStrip.tsx`** + +Extract the thumbnail sidebar (the scrollable column of screenshot thumbnails). Replace all raw `<div>` with @ui primitives: +- `<div className="flex flex-col gap-1.5 p-2">` → `<Stack className="p-2" gap="xs">` + +The component should accept: +```ts +interface ScreenshotThumbnailStripProps { + screenshots: Array<{ id: string; path: string; stepIndex: number }>; + selectedId: string | null; + onSelect: (id: string) => void; +} +``` + +- [ ] **Step 3: Create `ScreenshotPreview.tsx`** + +Extract the main preview pane. Replace raw `<div>` with @ui primitives. The component should accept: +```ts +interface ScreenshotPreviewProps { + screenshotPath: string | null; + stepLabel: string; + comparison?: { baselinePath: string; diffPercent: number }; +} +``` + +- [ ] **Step 4: Rewrite `ScreenshotsPanel.tsx` as composition shell** + +The rewritten file should: +1. Own the data fetching hooks +2. Manage selection state +3. Compose `ScreenshotThumbnailStrip` and `ScreenshotPreview` +4. Replace all remaining raw `<div>` with @ui primitives + +- [ ] **Step 5: Lint and typecheck** + +```bash +npx eslint src/renderer/features/test-suite/components/ScreenshotThumbnailStrip.tsx src/renderer/features/test-suite/components/ScreenshotPreview.tsx src/renderer/features/test-suite/components/ScreenshotsPanel.tsx +npx tsc --noEmit +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/features/test-suite/components/ScreenshotThumbnailStrip.tsx src/renderer/features/test-suite/components/ScreenshotPreview.tsx src/renderer/features/test-suite/components/ScreenshotsPanel.tsx +git commit -m "refactor(test-suite): decompose ScreenshotsPanel into compositional sub-components" +``` + +--- + +### Task 8: Decompose LibraryPanel into compositional components + +**Files:** +- Create: `src/renderer/features/test-suite/hooks/useLibraryFilters.ts` +- Create: `src/renderer/features/test-suite/components/LibraryToolbar.tsx` +- Create: `src/renderer/features/test-suite/components/LibraryTagFilter.tsx` +- Create: `src/renderer/features/test-suite/components/LibraryScriptRow.tsx` +- Create: `src/renderer/features/test-suite/components/LibraryBulkActions.tsx` +- Modify: `src/renderer/features/test-suite/components/LibraryPanel.tsx` + +- [ ] **Step 1: Read LibraryPanel.tsx** + +Read the full 451-line file to understand all regions: search bar + filters, tag filter strip, scrollable script table, and bulk action footer bar. + +- [ ] **Step 2: Create `hooks/useLibraryFilters.ts`** + +Extract the filter state and filtering logic (search term, status filter, tag filter, selection set) from LibraryPanel: + +```ts +import { useMemo, useState } from 'react'; + +import type { StatusFilter } from '../lib/constants'; + +interface Script { + id: string; + name: string; + tags?: string[]; +} + +interface LibraryFilters { + search: string; + setSearch: (s: string) => void; + statusFilter: StatusFilter; + setStatusFilter: (f: StatusFilter) => void; + activeTags: string[]; + toggleTag: (tag: string) => void; + clearTags: () => void; + selected: Set<string>; + toggleSelected: (id: string) => void; + selectAll: (ids: string[]) => void; + clearSelected: () => void; +} + +export function useLibraryFilters(): LibraryFilters { + // implement state management for all filters +} +``` + +- [ ] **Step 3: Create `LibraryToolbar.tsx`** + +Extract the search bar + status filter + action buttons row. Replace all raw `<div>` with @ui primitives: +- `<div className="flex flex-col gap-2 border-b ...">` → `<Stack gap="sm" className="border-b ...">` +- `<div className="flex items-center gap-2">` → `<Flex align="center" gap="sm">` +- `<div className="relative flex-1">` → Keep as `<div>` (positioning container for SearchInput) +- `<div className="flex items-center gap-1">` → `<Flex align="center" gap="xs">` + +Use `SearchInput` from `@ui` instead of raw `Input` with search icon. + +- [ ] **Step 4: Create `LibraryTagFilter.tsx`** + +Extract the tag filter strip (the horizontal row of clickable tag badges). Replace: +- `<div className="flex flex-wrap items-center gap-1 px-4 py-1.5 border-b border-border">` → `<Flex align="center" className="px-4 py-1.5 border-b border-border" gap="xs">` + +- [ ] **Step 5: Create `LibraryScriptRow.tsx`** + +Extract a single script row from the table body. This is the `.map()` callback body from LibraryPanel. Replace: +- `<div className="flex items-center gap-1">` → `<Flex align="center" gap="xs">` + +The component should accept: +```ts +interface LibraryScriptRowProps { + script: Script; + isSelected: boolean; + lastStatus: string; + flakyIndicator: boolean; + onToggleSelect: () => void; + onSelect: () => void; + onDelete: () => void; +} +``` + +- [ ] **Step 6: Create `LibraryBulkActions.tsx`** + +Extract the bulk action footer bar. Replace: +- `<div className="flex items-center gap-3 border-t border-border bg-bg-surface px-4 py-2">` → `<Flex align="center" className="border-t border-border bg-bg-surface px-4 py-2" gap="md" wrap="nowrap">` + +- [ ] **Step 7: Rewrite `LibraryPanel.tsx` as composition shell** + +The rewritten file should: +1. Use `useLibraryFilters()` for all filter state +2. Compose: `LibraryToolbar`, `LibraryTagFilter`, scrollable area with `LibraryScriptRow` map, `LibraryBulkActions` +3. Own data-fetching hooks (scripts, flaky tests, analytics) +4. Replace remaining raw `<div>` (the outer scroll area `<div className="flex-1 overflow-auto">` → `<ScrollArea className="flex-1">`) + +Replace hardcoded `10` for sparkline limit with `SPARKLINE_RUN_LIMIT` from `lib/constants.ts`. + +- [ ] **Step 8: Lint and typecheck** + +```bash +npx eslint src/renderer/features/test-suite/hooks/useLibraryFilters.ts src/renderer/features/test-suite/components/LibraryToolbar.tsx src/renderer/features/test-suite/components/LibraryTagFilter.tsx src/renderer/features/test-suite/components/LibraryScriptRow.tsx src/renderer/features/test-suite/components/LibraryBulkActions.tsx src/renderer/features/test-suite/components/LibraryPanel.tsx +npx tsc --noEmit +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/renderer/features/test-suite/hooks/useLibraryFilters.ts src/renderer/features/test-suite/components/LibraryToolbar.tsx src/renderer/features/test-suite/components/LibraryTagFilter.tsx src/renderer/features/test-suite/components/LibraryScriptRow.tsx src/renderer/features/test-suite/components/LibraryBulkActions.tsx src/renderer/features/test-suite/components/LibraryPanel.tsx +git commit -m "refactor(test-suite): decompose LibraryPanel into compositional sub-components with extracted filter hook" +``` + +--- + +### Task 9: Split recorder-handlers.ts into domain handler files + +**Files:** +- Create: `src/main/features/test-suite/handlers/script-handlers.ts` +- Create: `src/main/features/test-suite/handlers/run-handlers.ts` +- Create: `src/main/features/test-suite/handlers/browser-view-handlers.ts` +- Create: `src/main/features/test-suite/handlers/config-handlers.ts` +- Create: `src/main/features/test-suite/handlers/screenshot-handlers.ts` +- Create: `src/main/features/test-suite/handlers/data-run-handlers.ts` +- Create: `src/main/features/test-suite/handlers/export-handlers.ts` +- Create: `src/main/features/test-suite/handlers/auth-handlers.ts` +- Modify: `src/main/features/test-suite/recorder-handlers.ts` + +- [ ] **Step 1: Read the current recorder-handlers.ts** + +Read the full 455-line file. Identify all `router.handle(...)` calls and group them by domain: +- **Scripts**: `LIST.SCRIPTS`, `GET.SCRIPT`, `SAVE.SCRIPT`, `DELETE.SCRIPT` +- **Runs**: `RUN.SCRIPT`, `GET.RUN`, `LIST.RUNS`, `BATCH.RUN` +- **Browser View**: `BROWSER-VIEW.CREATE/NAVIGATE/BACK/FORWARD/RELOAD/SET-BOUNDS/DESTROY` +- **Config**: `CONFIG.GET/LIST/SAVE/DELETE/SET-ACTIVE` +- **Screenshots**: `SCREENSHOT.LIST/EXPORT-ZIP/COPY` +- **Data Run**: `DATA-RUN.PARSE/EXECUTE` +- **Export**: `EXPORT.FILE/GITHUB/CI-PREVIEW/CI-COMMIT` +- **Auth**: `AUTH.SAVE/CLEAR` +- **Report**: `OPEN.REPORT` +- **Task**: `TASK.ATTACH-RUN` + +- [ ] **Step 2: Create `handlers/script-handlers.ts`** + +Follow the exact same pattern as existing handler files (e.g., `handlers/analytics-handlers.ts`). Each handler file exports a single `register*Handlers` function: + +```ts +import type { IpcRouter } from '@main/ipc'; +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; +import { TestSuiteStepSchema } from '@shared/ipc/test-suite/schemas'; +import type { TestSuiteService } from '../test-suite-service-types'; + +export function registerScriptHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + router.handle(TEST_SUITE.LIST.SCRIPTS, ({ projectId }) => + testSuiteService.listScriptsByProject(projectId) as never, + ); + + router.handle(TEST_SUITE.GET.SCRIPT, ({ id }) => + testSuiteService.getScript(id) as never, + ); + + router.handle(TEST_SUITE.SAVE.SCRIPT, (input) => { + // Move the full body from recorder-handlers.ts + }); + + router.handle(TEST_SUITE.DELETE.SCRIPT, ({ id }) => + testSuiteService.deleteScript(id) as never, + ); +} +``` + +Read the actual handler bodies from `recorder-handlers.ts` and move them verbatim. + +- [ ] **Step 3: Create `handlers/run-handlers.ts`** + +Move `RUN.SCRIPT`, `GET.RUN`, `LIST.RUNS`, `BATCH.RUN`, `TASK.ATTACH-RUN`, `OPEN.REPORT` handlers. These need `testSuiteService`, `projectService`, and imports for config/runner dependencies. + +- [ ] **Step 4: Create `handlers/browser-view-handlers.ts`** + +Move all 7 `BROWSER-VIEW.*` handlers. + +- [ ] **Step 5: Create `handlers/config-handlers.ts`** + +Move all 5 `CONFIG.*` handlers. + +- [ ] **Step 6: Create `handlers/screenshot-handlers.ts`** + +Move all 3 `SCREENSHOT.*` handlers plus the `COPY` handler with its `copyFile` logic. + +- [ ] **Step 7: Create `handlers/data-run-handlers.ts`** + +Move `DATA-RUN.PARSE` and `DATA-RUN.EXECUTE` handlers. These need `parseDataFile` and `substituteDataInSteps` imports. + +- [ ] **Step 8: Create `handlers/export-handlers.ts`** + +Move `EXPORT.FILE`, `EXPORT.GITHUB`, `EXPORT.CI-PREVIEW`, `EXPORT.CI-COMMIT` handlers. + +- [ ] **Step 9: Create `handlers/auth-handlers.ts`** + +Move `AUTH.SAVE` and `AUTH.CLEAR` handlers. + +- [ ] **Step 10: Rewrite `recorder-handlers.ts` as thin registration shell** + +The file should only: +1. Import all `register*Handlers` functions +2. Set up event forwarding (the `onRunEvent` listener — ~30 lines) +3. Call each registration function + +```ts +export function registerTestSuiteHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, + projectService: ProjectService, +): void { + // Event forwarding (keep here — it's cross-cutting) + testSuiteService.onRunEvent((event) => { + // ... existing event forwarding code ... + }); + + // Domain handler registration + registerScriptHandlers(router, testSuiteService); + registerRunHandlers(router, testSuiteService, projectService); + registerBrowserViewHandlers(router, testSuiteService); + registerConfigHandlers(router, testSuiteService); + registerScreenshotHandlers(router, testSuiteService); + registerDataRunHandlers(router, testSuiteService); + registerExportHandlers(router, testSuiteService, projectService); + registerAuthHandlers(router, testSuiteService); + registerAnalyticsHandlers(router, testSuiteService); + registerBaselineHandlers(router, testSuiteService); + registerScheduleHandlers(router, testSuiteService); + registerSetupHandlers(router, testSuiteService, projectService); + registerSharedStepsHandlers(router, testSuiteService); + registerWatchHandlers(router, testSuiteService); +} +``` + +The file should drop from 455 lines to ~70 lines. + +- [ ] **Step 11: Lint and typecheck** + +```bash +npx eslint src/main/features/test-suite/recorder-handlers.ts src/main/features/test-suite/handlers/script-handlers.ts src/main/features/test-suite/handlers/run-handlers.ts src/main/features/test-suite/handlers/browser-view-handlers.ts src/main/features/test-suite/handlers/config-handlers.ts src/main/features/test-suite/handlers/screenshot-handlers.ts src/main/features/test-suite/handlers/data-run-handlers.ts src/main/features/test-suite/handlers/export-handlers.ts src/main/features/test-suite/handlers/auth-handlers.ts +npx tsc --noEmit +``` + +- [ ] **Step 12: Commit** + +```bash +git add src/main/features/test-suite/recorder-handlers.ts src/main/features/test-suite/handlers/ +git commit -m "refactor(test-suite): split recorder-handlers.ts into 8 domain-specific handler files" +``` + +--- + +### Task 10: Final verification and cleanup + +**Files:** +- All modified/created files from Tasks 1-9 + +- [ ] **Step 1: Run full raw HTML audit** + +Search for any remaining violations: + +```bash +# Raw form controls (should be zero) +npx grep -rn '<button\|<input\|<label\|<select\|<textarea' src/renderer/features/test-suite/components/ + +# Raw layout divs (review each — CSS-only containers are acceptable) +npx grep -rn '<div' src/renderer/features/test-suite/components/ | wc -l +``` + +- [ ] **Step 2: Run full lint on all test-suite renderer files** + +```bash +npx eslint src/renderer/features/test-suite/**/*.{ts,tsx} +``` + +- [ ] **Step 3: Run full typecheck** + +```bash +npx tsc --noEmit +``` + +- [ ] **Step 4: Verify file sizes** + +```bash +find src/renderer/features/test-suite -name '*.tsx' | while read f; do lines=$(wc -l < "$f"); if [ "$lines" -gt 200 ]; then echo "WARNING: $lines $f"; fi; done +``` + +Target: no component file over 200 lines (except dialog files with complex forms). + +- [ ] **Step 5: Verify no duplicate utility functions** + +```bash +grep -rn 'function formatDuration\|function getOutputLineClass\|function stepToLabel\|function gradeFromScore\|function computeHealthScore' src/renderer/features/test-suite/ +``` + +Each function should appear exactly once (in `lib/format.ts` or `lib/health-score.ts`). + +- [ ] **Step 6: Commit if any fixes needed** + +```bash +git add -A +git commit -m "refactor(test-suite): final cleanup from compositional audit" +``` diff --git a/docs/superpowers/plans/2026-04-21-layout-preset-simplification.md b/docs/superpowers/plans/2026-04-21-layout-preset-simplification.md new file mode 100644 index 00000000..9ede5c20 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-layout-preset-simplification.md @@ -0,0 +1,1274 @@ +# Layout Preset Simplification — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace 16 sidebar / 8 toolbar / 4 content / 3 icon-shape individual selectors with a single Layout Preset dropdown (`default` | `floating`) plus Color Theme. + +**Architecture:** New `LayoutPreset` type in shared types maps to fixed `{ sidebar, toolbar, content }` combos. Layout store holds the preset and exposes derived values so downstream readers (`TopBar`, `ContentAreaContainer`, `AppSidebar`) keep working. Icon shape CSS vars become static defaults — no runtime override. + +**Tech Stack:** TypeScript, Zustand, Zod, React, TanStack Query, Tailwind CSS + +**Spec:** `docs/superpowers/specs/2026-04-21-layout-preset-simplification-design.md` + +--- + +## Parallelization Map + +``` +Wave 1 (parallel): Task 1 + Task 2 + Task 3 + │ │ │ │ + │ shared types │ CSS │ sidebar │ + │ + schemas │ cleanup │ configs │ + ▼ ▼ ▼ ▼ +Wave 2 (parallel): Task 4 + Task 5 + │ │ │ + │ stores │ backend │ + │ (theme+layout) │ service │ + ▼ ▼ ▼ +Wave 3 (sequential): Task 6 + │ + │ renderer UI + hooks + ▼ +Wave 4 (sequential): Task 7 + │ + │ TopBar + ContentAreaContainer cleanup + ▼ +Wave 5 (sequential): Task 8 + │ + │ typecheck + lint + verify +``` + +--- + +### Task 1: Shared Types + Zod Schemas + +**Files:** +- Modify: `src/shared/types/layout.ts` +- Modify: `src/shared/types/settings.ts` +- Modify: `src/shared/ipc/settings/schemas.ts` + +- [ ] **Step 1: Rewrite `src/shared/types/layout.ts`** + +Replace the entire file contents with: + +```typescript +/** + * Layout Types — Preset-based layout system + * + * Two layout presets (default, floating) each map to a fixed + * sidebar + toolbar + content combination. + */ + +// ── Sidebar ──────────────────────────────────────────────── + +export type SidebarLayoutId = 'sidebar-04' | 'sidebar-07'; + +export interface SidebarLayoutMeta { + id: SidebarLayoutId; + label: string; + description: string; +} + +export const SIDEBAR_LAYOUTS: SidebarLayoutMeta[] = [ + { id: 'sidebar-07', label: 'Icon Collapse', description: 'Sidebar that collapses to icons' }, + { id: 'sidebar-04', label: 'Floating', description: 'Floating sidebar with detached visual style' }, +]; + +export const SIDEBAR_LAYOUT_IDS: [SidebarLayoutId, ...SidebarLayoutId[]] = [ + 'sidebar-04', 'sidebar-07', +]; + +// ── Toolbar ──────────────────────────────────────────────── + +export type ToolbarStyleId = 'default' | 'floating'; + +export interface ToolbarStyleMeta { + id: ToolbarStyleId; + label: string; + description: string; +} + +export const TOOLBAR_STYLES: ToolbarStyleMeta[] = [ + { id: 'default', label: 'Standard', description: 'Default toolbar with solid background and bottom border' }, + { id: 'floating', label: 'Floating', description: 'Detached bar with rounded corners and shadow' }, +]; + +export const TOOLBAR_STYLE_IDS: [ToolbarStyleId, ...ToolbarStyleId[]] = [ + 'default', 'floating', +]; + +// ── Content ──────────────────────────────────────────────── + +export type ContentLayoutId = 'flush' | 'bordered'; + +export interface ContentLayoutMeta { + id: ContentLayoutId; + label: string; + description: string; +} + +export const CONTENT_LAYOUTS: ContentLayoutMeta[] = [ + { id: 'flush', label: 'Flush', description: 'No padding — content extends edge to edge' }, + { id: 'bordered', label: 'Bordered', description: 'Rounded border with inner spacing' }, +]; + +export const CONTENT_LAYOUT_IDS: [ContentLayoutId, ...ContentLayoutId[]] = [ + 'flush', 'bordered', +]; + +// ── Layout Presets ───────────────────────────────────────── + +export type LayoutPreset = 'default' | 'floating'; + +export interface LayoutPresetConfig { + id: LayoutPreset; + label: string; + description: string; + sidebar: SidebarLayoutId; + toolbar: ToolbarStyleId; + content: ContentLayoutId; +} + +export const LAYOUT_PRESETS: LayoutPresetConfig[] = [ + { + id: 'default', + label: 'Default', + description: 'Icon-collapse sidebar, flush content, standard toolbar', + sidebar: 'sidebar-07', + toolbar: 'default', + content: 'flush', + }, + { + id: 'floating', + label: 'Floating', + description: 'Floating sidebar and toolbar, bordered content area', + sidebar: 'sidebar-04', + toolbar: 'floating', + content: 'bordered', + }, +]; + +export const LAYOUT_PRESET_IDS: [LayoutPreset, ...LayoutPreset[]] = [ + 'default', 'floating', +]; + +/** Look up a preset config by ID, falling back to 'default' */ +export function getPresetConfig(id: LayoutPreset): LayoutPresetConfig { + return LAYOUT_PRESETS.find((p) => p.id === id) ?? LAYOUT_PRESETS[0]; +} +``` + +- [ ] **Step 2: Update `src/shared/types/settings.ts`** + +Remove `sidebarLayout` import. Add `LayoutPreset` import. Remove `iconButtonShape`, `toolbarStyle`, `contentLayout`, and `sidebarLayout` fields from `AppSettings`. Add `layoutPreset`. + +Replace: +```typescript +import type { SidebarLayoutId } from './layout'; +``` +with: +```typescript +import type { LayoutPreset } from './layout'; +``` + +In the `AppSettings` interface, remove these lines: +```typescript + sidebarLayout?: SidebarLayoutId; + toolbarStyle?: string; + contentLayout?: string; + iconButtonShape?: 'rounded' | 'square' | 'pill'; +``` + +Add this line (place it after `uiScale`): +```typescript + layoutPreset?: LayoutPreset; +``` + +- [ ] **Step 3: Update `src/shared/ipc/settings/schemas.ts`** + +Remove the `SIDEBAR_LAYOUT_IDS` import at line 10: +```typescript +import { SIDEBAR_LAYOUT_IDS } from '@shared/types/layout'; +``` +Replace with: +```typescript +import { LAYOUT_PRESET_IDS } from '@shared/types/layout'; +``` + +In `AppSettingsSchema`, remove: +```typescript + sidebarLayout: z.enum(SIDEBAR_LAYOUT_IDS as [string, ...string[]]).optional(), + iconButtonShape: z.enum(['rounded', 'square', 'pill']).optional(), +``` + +Add (after `uiScale`): +```typescript + layoutPreset: z.enum(LAYOUT_PRESET_IDS).optional(), +``` + +In `LayoutStateSchema`, replace: +```typescript +export const LayoutStateSchema = z.object({ + openProjectTabs: z.array(z.string()), + activeProjectId: z.string().nullable(), + lastRoutePerProject: z.record(z.string(), z.string()), + sidebarCollapsed: z.boolean(), + sidebarLayout: z.string(), + toolbarStyle: z.string().optional(), + contentLayout: z.string().optional(), +}); +``` +with: +```typescript +export const LayoutStateSchema = z.object({ + openProjectTabs: z.array(z.string()), + activeProjectId: z.string().nullable(), + lastRoutePerProject: z.record(z.string(), z.string()), + sidebarCollapsed: z.boolean(), + layoutPreset: z.string(), +}); +``` + +In `LayoutUpdateSchema`, replace: +```typescript +export const LayoutUpdateSchema = z.object({ + openProjectTabs: z.array(z.string()).optional(), + activeProjectId: z.string().nullable().optional(), + lastRoutePerProject: z.record(z.string(), z.string()).optional(), + sidebarCollapsed: z.boolean().optional(), + sidebarLayout: z.string().optional(), + toolbarStyle: z.string().optional(), + contentLayout: z.string().optional(), +}); +``` +with: +```typescript +export const LayoutUpdateSchema = z.object({ + openProjectTabs: z.array(z.string()).optional(), + activeProjectId: z.string().nullable().optional(), + lastRoutePerProject: z.record(z.string(), z.string()).optional(), + sidebarCollapsed: z.boolean().optional(), + layoutPreset: z.string().optional(), +}); +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/shared/types/layout.ts src/shared/types/settings.ts src/shared/ipc/settings/schemas.ts +git commit -m "refactor(types): replace individual layout fields with LayoutPreset type" +``` + +--- + +### Task 2: CSS Cleanup — Static Button Radius + +**Files:** +- Modify: `src/renderer/styles/globals.css` (lines ~260-261) + +- [ ] **Step 1: Verify current CSS vars in `globals.css`** + +Read `src/renderer/styles/globals.css` around lines 255-265 to confirm `--btn-radius` and `--btn-icon-radius` are declared there. + +- [ ] **Step 2: No changes needed** + +The CSS already declares static defaults: +```css +--btn-radius: 0.375rem; +--btn-icon-radius: 0.125rem; +``` + +And `button.tsx` uses `var(--btn-radius, 0.375rem)` with fallbacks. Once the theme store stops overriding these at runtime (Task 4), the static CSS values apply. No CSS file changes needed. + +- [ ] **Step 3: Commit (skip — no changes)** + +This task confirms the CSS is already correct. No commit needed. + +--- + +### Task 3: Sidebar Layout Configs Cleanup + +**Files:** +- Modify: `src/renderer/app/layouts/sidebar-layouts/layout-configs.ts` + +- [ ] **Step 1: Strip unused configs from `layout-configs.ts`** + +Replace the entire `LAYOUT_CONFIGS` record with only the two kept configs: + +```typescript +export const LAYOUT_CONFIGS: Record<SidebarLayoutId, SidebarLayoutConfig> = { + 'sidebar-04': { groupStyle: 'collapsible', variant: 'floating' }, + 'sidebar-07': { groupStyle: 'flat', showTooltips: true }, +}; +``` + +Keep all the type definitions (`CollapsibleMode`, `SidebarVariant`, `GroupStyle`, `SidebarSide`, `DevSubGroup`, `DualSidebarConfig`, `SidebarLayoutConfig`) as-is — they're referenced by `AppSidebar.tsx`. + +- [ ] **Step 2: Commit** + +```bash +git add src/renderer/app/layouts/sidebar-layouts/layout-configs.ts +git commit -m "refactor(layout): remove unused sidebar layout configs, keep sidebar-04 and sidebar-07" +``` + +--- + +### Task 4: Store Cleanup — Theme Store + Layout Store + +**Files:** +- Modify: `src/renderer/shared/stores/theme-store.ts` +- Modify: `src/renderer/shared/stores/layout-store.ts` +- Modify: `src/renderer/shared/stores/index.ts` + +- [ ] **Step 1: Remove icon button shape from theme store** + +In `src/renderer/shared/stores/theme-store.ts`: + +1. Remove the `IconButtonShape` type export +2. Remove `iconButtonShape` from the `ThemeState` interface +3. Remove `setIconButtonShape` from the `ThemeState` interface +4. Remove the `BUTTON_SHAPE_RADIUS` constant +5. Remove the `ICON_BUTTON_SHAPE_RADIUS` constant +6. Remove the `applyIconButtonShape` function +7. Remove `iconButtonShape: 'rounded'` from the store initial state +8. Remove the `setIconButtonShape` action + +The full updated file: + +```typescript +/** + * Theme Store — Global UI theme state + * + * Manages dark/light/system mode, color theme, UI scale, and custom themes. + * Applies classes, attributes, and CSS custom properties to <html> for CSS to consume. + */ + +import { create } from 'zustand'; + +import { THEME_TOKEN_KEYS } from '@shared/constants/themes'; +import type { CustomTheme, ThemeMode } from '@shared/types'; + +interface ThemeState { + mode: ThemeMode; + colorTheme: string; + uiScale: number; + customThemes: CustomTheme[]; + layoutGap: number; + setMode: (mode: ThemeMode) => void; + setColorTheme: (theme: string) => void; + setUiScale: (scale: number) => void; + setCustomThemes: (themes: CustomTheme[]) => void; + setLayoutGap: (gap: number) => void; +} + +function resolveEffectiveMode(mode: ThemeMode): 'light' | 'dark' { + if (mode === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return mode; +} + +function applyMode(mode: ThemeMode): void { + const root = document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(resolveEffectiveMode(mode)); +} + +function applyCustomTokens( + themeId: string, + customThemes: CustomTheme[], + mode: ThemeMode, +): void { + const root = document.documentElement; + + if (themeId === 'default') { + for (const key of THEME_TOKEN_KEYS) { + root.style.removeProperty(`--${key}`); + } + root.removeAttribute('data-theme'); + return; + } + + const theme = customThemes.find((t) => t.id === themeId); + if (theme === undefined) { + for (const key of THEME_TOKEN_KEYS) { + root.style.removeProperty(`--${key}`); + } + root.removeAttribute('data-theme'); + return; + } + + const effectiveMode = resolveEffectiveMode(mode); + const palette = effectiveMode === 'dark' ? theme.dark : theme.light; + + for (const key of THEME_TOKEN_KEYS) { + root.style.setProperty(`--${key}`, palette[key]); + } + + root.setAttribute('data-theme', themeId); +} + +function applyUiScale(scale: number): void { + document.documentElement.setAttribute('data-ui-scale', String(scale)); +} + +function applyLayoutGap(gap: number): void { + const root = document.documentElement; + root.style.setProperty('--layout-gap', `${gap / 16}rem`); + root.style.setProperty('--layout-gap-sm', `${(gap * 0.75) / 16}rem`); + root.style.setProperty('--layout-gap-lg', `${(gap * 1.5) / 16}rem`); + root.style.setProperty('--layout-pad-x', `${(gap * 3) / 16}rem`); + root.style.setProperty('--layout-pad-y', `${(gap * 2) / 16}rem`); +} + +export const useThemeStore = create<ThemeState>((set, get) => ({ + mode: 'dark', + colorTheme: 'default', + uiScale: 100, + customThemes: [], + layoutGap: 8, + setMode: (mode) => { + set({ mode }); + applyMode(mode); + const { colorTheme, customThemes } = get(); + if (colorTheme !== 'default') { + applyCustomTokens(colorTheme, customThemes, mode); + } + }, + setColorTheme: (colorTheme) => { + set({ colorTheme }); + const { customThemes, mode } = get(); + applyCustomTokens(colorTheme, customThemes, mode); + }, + setUiScale: (scale) => { + const uiScale = Math.max(75, Math.min(150, scale)); + set({ uiScale }); + applyUiScale(uiScale); + }, + setCustomThemes: (customThemes) => { + set({ customThemes }); + const { colorTheme, mode } = get(); + if (colorTheme !== 'default') { + applyCustomTokens(colorTheme, customThemes, mode); + } + }, + setLayoutGap: (gap) => { + const layoutGap = Math.max(0, Math.min(16, gap)); + set({ layoutGap }); + applyLayoutGap(layoutGap); + }, +})); +``` + +- [ ] **Step 2: Rewrite layout store for preset-based system** + +Replace `src/renderer/shared/stores/layout-store.ts` entirely: + +```typescript +/** + * Layout Store — Global layout/navigation state + * + * Preset-based layout system: a single layoutPreset value drives + * sidebar, toolbar, and content styles. Derived getters let downstream + * components (TopBar, ContentAreaContainer, AppSidebar) read individual + * values without knowing about presets. + * + * Persists layout state to settings.json via IPC with 500ms debounce. + */ + +import { create } from 'zustand'; + +import { SETTINGS } from '@shared/ipc/settings/channels'; +import { WORKSPACE } from '@shared/ipc/workspace/channels'; +import type { ContentLayoutId, LayoutPreset, SidebarLayoutId, ToolbarStyleId } from '@shared/types/layout'; +import { getPresetConfig } from '@shared/types/layout'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import type { Layout } from 'react-resizable-panels'; + +interface LayoutState { + sidebarCollapsed: boolean; + layoutPreset: LayoutPreset; + activeProjectId: string | null; + projectTabOrder: string[]; + panelLayout: Layout | null; + + // Derived from layoutPreset — read-only for consumers + sidebarLayout: SidebarLayoutId; + toolbarStyle: ToolbarStyleId; + contentLayout: ContentLayoutId; + + toggleSidebar: () => void; + setSidebarCollapsed: (collapsed: boolean) => void; + setLayoutPreset: (preset: LayoutPreset) => void; + setActiveProject: (projectId: string | null) => void; + setProjectTabOrder: (order: string[]) => void; + addProjectTab: (projectId: string) => void; + removeProjectTab: (projectId: string) => void; + setPanelLayout: (layout: Layout) => void; + hydrate: (state: { + openProjectTabs: string[]; + activeProjectId: string | null; + lastRoutePerProject: Record<string, string>; + sidebarCollapsed: boolean; + layoutPreset: string; + }) => void; + clearLayout: () => void; +} + +// ── Debounced persistence ────────────────────────────────────── + +let hydrated = false; +let debounceTimer: ReturnType<typeof setTimeout> | null = null; + +function persistLayout() { + if (!hydrated) return; + + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const { sidebarCollapsed, layoutPreset, activeProjectId, projectTabOrder } = + useLayoutStore.getState(); + void ipc(SETTINGS.SAVE.LAYOUT, { + sidebarCollapsed, + layoutPreset, + activeProjectId, + openProjectTabs: projectTabOrder, + }); + }, 500); +} + +/** Derive individual layout values from a preset ID */ +function deriveFromPreset(preset: LayoutPreset) { + const config = getPresetConfig(preset); + return { + sidebarLayout: config.sidebar, + toolbarStyle: config.toolbar, + contentLayout: config.content, + }; +} + +const defaultDerived = deriveFromPreset('default'); + +export const useLayoutStore = create<LayoutState>((set) => ({ + sidebarCollapsed: false, + layoutPreset: 'default', + activeProjectId: null, + projectTabOrder: [], + panelLayout: null, + + // Derived initial values + sidebarLayout: defaultDerived.sidebarLayout, + toolbarStyle: defaultDerived.toolbarStyle, + contentLayout: defaultDerived.contentLayout, + + toggleSidebar: () => { + set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })); + persistLayout(); + }, + setSidebarCollapsed: (collapsed) => { + set({ sidebarCollapsed: collapsed }); + persistLayout(); + }, + setLayoutPreset: (preset) => { + set({ layoutPreset: preset, ...deriveFromPreset(preset) }); + persistLayout(); + }, + + setActiveProject: (projectId) => { + set({ activeProjectId: projectId }); + persistLayout(); + }, + + setProjectTabOrder: (order) => { + set({ projectTabOrder: order }); + persistLayout(); + }, + + addProjectTab: (projectId) => { + set((s) => { + if (s.projectTabOrder.includes(projectId)) return s; + return { + projectTabOrder: [...s.projectTabOrder, projectId], + activeProjectId: projectId, + }; + }); + persistLayout(); + }, + + removeProjectTab: (projectId) => { + set((s) => { + const order = s.projectTabOrder.filter((id) => id !== projectId); + return { + projectTabOrder: order, + activeProjectId: + s.activeProjectId === projectId ? (order.at(-1) ?? null) : s.activeProjectId, + }; + }); + persistLayout(); + void ipc(WORKSPACE.STOP.PROJECT, { projectId }); + }, + + setPanelLayout: (layout) => set({ panelLayout: layout }), + + hydrate: (state) => { + const preset = (state.layoutPreset === 'floating' ? 'floating' : 'default') as LayoutPreset; + set({ + projectTabOrder: state.openProjectTabs, + activeProjectId: state.activeProjectId, + sidebarCollapsed: state.sidebarCollapsed, + layoutPreset: preset, + ...deriveFromPreset(preset), + }); + hydrated = true; + }, + + clearLayout: () => { + hydrated = false; + if (debounceTimer) clearTimeout(debounceTimer); + set({ + sidebarCollapsed: false, + layoutPreset: 'default', + ...deriveFromPreset('default'), + activeProjectId: null, + projectTabOrder: [], + panelLayout: null, + }); + }, +})); +``` + +- [ ] **Step 3: Update stores barrel export** + +In `src/renderer/shared/stores/index.ts`, remove: +```typescript +export type { IconButtonShape } from './theme-store'; +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/shared/stores/theme-store.ts src/renderer/shared/stores/layout-store.ts src/renderer/shared/stores/index.ts +git commit -m "refactor(stores): preset-based layout, remove icon button shape" +``` + +--- + +### Task 5: Backend Service + Defaults + +**Files:** +- Modify: `src/main/features/settings/settings-service.ts` +- Modify: `src/main/features/settings/settings-defaults.ts` + +- [ ] **Step 1: Update `settings-defaults.ts`** + +Remove `iconButtonShape` from `DEFAULT_SETTINGS`. Remove `sidebarLayout` if present (it's not in the current defaults, but check). Add `layoutPreset`: + +Replace the `DEFAULT_SETTINGS` constant: + +```typescript +export const DEFAULT_SETTINGS: AppSettings = { + theme: 'system', + colorTheme: 'default', + customThemes: [], + language: 'en', + uiScale: 100, + layoutPreset: 'default', + onboardingCompleted: false, + agentSettings: DEFAULT_AGENT_SETTINGS, + assistantAutoStart: true, + openProjectTabs: [], + activeProjectId: null, + lastRoutePerProject: {}, + sidebarCollapsed: false, + layoutGap: 8, +}; +``` + +- [ ] **Step 2: Update `settings-service.ts`** + +Replace the `LayoutState` and `LayoutUpdate` interfaces: + +```typescript +export interface LayoutState { + openProjectTabs: string[]; + activeProjectId: string | null; + lastRoutePerProject: Record<string, string>; + sidebarCollapsed: boolean; + layoutPreset: string; +} + +export interface LayoutUpdate { + openProjectTabs?: string[]; + activeProjectId?: string | null; + lastRoutePerProject?: Record<string, string>; + sidebarCollapsed?: boolean; + layoutPreset?: string; +} +``` + +Replace the `getLayout()` method body: + +```typescript + getLayout() { + // Migration: map legacy individual fields to nearest preset + const legacy = store.settings as Record<string, unknown>; + let preset = (store.settings.layoutPreset as string | undefined) ?? 'default'; + if (!store.settings.layoutPreset) { + // Migrate from old individual fields + if (legacy.toolbarStyle === 'floating' || legacy.sidebarLayout === 'sidebar-04') { + preset = 'floating'; + } + } + return { + openProjectTabs: store.settings.openProjectTabs ?? [], + activeProjectId: store.settings.activeProjectId ?? null, + lastRoutePerProject: store.settings.lastRoutePerProject ?? {}, + sidebarCollapsed: store.settings.sidebarCollapsed ?? false, + layoutPreset: preset, + }; + }, +``` + +Replace the `saveLayout()` method body: + +```typescript + saveLayout(updates) { + if (updates.openProjectTabs !== undefined) { + store.settings.openProjectTabs = updates.openProjectTabs; + } + if (updates.activeProjectId !== undefined) { + store.settings.activeProjectId = updates.activeProjectId; + } + if (updates.lastRoutePerProject !== undefined) { + store.settings.lastRoutePerProject = updates.lastRoutePerProject; + } + if (updates.sidebarCollapsed !== undefined) { + store.settings.sidebarCollapsed = updates.sidebarCollapsed; + } + if (updates.layoutPreset !== undefined) { + store.settings.layoutPreset = updates.layoutPreset as AppSettings['layoutPreset']; + } + persist(); + return { success: true }; + }, +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/main/features/settings/settings-service.ts src/main/features/settings/settings-defaults.ts +git commit -m "refactor(settings): preset-based layout persistence with legacy migration" +``` + +--- + +### Task 6: Renderer UI — LayoutSection + useSettings Hook + +**Files:** +- Modify: `src/renderer/features/settings/components/LayoutSection.tsx` +- Modify: `src/renderer/features/settings/api/useSettings.ts` + +- [ ] **Step 1: Update `useSettings.ts`** + +Replace the full file: + +```typescript +/** + * React Query hooks for settings + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +import { SETTINGS } from '@shared/ipc/settings/channels'; +import type { LayoutPreset } from '@shared/types/layout'; + +import { ipc } from '@renderer/shared/lib/ipc'; +import { useLayoutStore, useThemeStore } from '@renderer/shared/stores'; + + +export const settingsKeys = { + all: ['settings'] as const, + app: () => [...settingsKeys.all, 'app'] as const, + agentSettings: () => [...settingsKeys.all, 'agentSettings'] as const, + profiles: () => [...settingsKeys.all, 'profiles'] as const, + webhookConfig: () => [...settingsKeys.all, 'webhookConfig'] as const, +}; + +/** Fetch app settings */ +export function useSettings() { + const { setMode, setColorTheme, setUiScale, setCustomThemes, setLayoutGap } = useThemeStore(); + const { setLayoutPreset } = useLayoutStore(); + + return useQuery({ + queryKey: settingsKeys.app(), + queryFn: async () => { + const settings = await ipc(SETTINGS.GET.ALL, {}); + // Sync theme store on load + setCustomThemes(settings.customThemes ?? []); + setMode(settings.theme); + setColorTheme(settings.colorTheme); + setUiScale(settings.uiScale); + if (settings.layoutPreset) { + setLayoutPreset(settings.layoutPreset as LayoutPreset); + } + if (settings.layoutGap !== undefined) setLayoutGap(settings.layoutGap); + if (settings.fontFamily) { + document.documentElement.style.setProperty('--app-font-sans', settings.fontFamily); + } + if (settings.fontSize !== undefined) { + document.documentElement.style.setProperty( + '--app-font-size', + `${String(settings.fontSize)}px`, + ); + } + return settings; + }, + staleTime: 60_000, + }); +} + +/** Update settings */ +export function useUpdateSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (updates: Record<string, unknown>) => ipc(SETTINGS.UPDATE.ALL, updates), + onSuccess: (data) => { + queryClient.setQueryData(settingsKeys.app(), data); + }, + }); +} + +/** Fetch API profiles */ +export function useProfiles() { + return useQuery({ + queryKey: settingsKeys.profiles(), + queryFn: () => ipc(SETTINGS.GET.PROFILES, {}), + staleTime: 60_000, + }); +} + +/** Create a new profile */ +export function useCreateProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string; apiKey?: string; model?: string }) => + ipc(SETTINGS.CREATE.PROFILE, data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: settingsKeys.profiles() }); + }, + }); +} + +/** Update an existing profile */ +export function useUpdateProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { + id: string; + updates: { name?: string; apiKey?: string; model?: string }; + }) => ipc(SETTINGS.UPDATE.PROFILE, data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: settingsKeys.profiles() }); + }, + }); +} + +/** Delete a profile */ +export function useDeleteProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => ipc(SETTINGS.DELETE.PROFILE, { id }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: settingsKeys.profiles() }); + }, + }); +} + +/** Set a profile as the default */ +export function useSetDefaultProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => ipc(SETTINGS.SET['DEFAULT-PROFILE'], { id }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: settingsKeys.profiles() }); + }, + }); +} + +/** Fetch agent settings (maxConcurrentAgents) */ +export function useAgentSettings() { + return useQuery({ + queryKey: settingsKeys.agentSettings(), + queryFn: () => ipc(SETTINGS.GET['AGENT-SETTINGS'], {}), + staleTime: 60_000, + }); +} + +/** Update agent settings */ +export function useUpdateAgentSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { maxConcurrentAgents: number }) => + ipc(SETTINGS.SET['AGENT-SETTINGS'], data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: settingsKeys.agentSettings() }); + }, + }); +} +``` + +- [ ] **Step 2: Rewrite `LayoutSection.tsx`** + +Replace the entire file: + +```tsx +/** + * LayoutSection — Layout settings for Settings > Display + * + * Simplified preset-based layout: + * ┌─────────────────┬─────────────────────┐ + * │ Layout preset │ Color theme + edit │ + * ├─────────────────┴─────────────────────┤ + * │ Unified SVG preview │ + * │ + color legend │ + * └───────────────────────────────────────┘ + */ + +import { useNavigate } from '@tanstack/react-router'; +import { Check, Settings2 } from 'lucide-react'; + +import { ROUTES } from '@shared/constants'; +import type { LayoutPreset } from '@shared/types/layout'; +import { LAYOUT_PRESETS, getPresetConfig } from '@shared/types/layout'; + +import { useLayoutStore } from '@renderer/shared/stores'; +import { useThemeStore } from '@renderer/shared/stores'; + +import { + Button, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Text, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@ui'; + +import { useUpdateSettings } from '../api/useSettings'; + +// ── SVG tint colors ──────────────────────────────────────── + +const SIDEBAR_TINT = 'oklch(0.65 0.15 230 / 0.12)'; +const TOOLBAR_TINT = 'oklch(0.70 0.10 50 / 0.12)'; +const CONTENT_TINT = 'oklch(0.75 0.12 170 / 0.10)'; + +// ── SVG Preview ──────────────────────────────────────────── + +function LayoutPreview({ preset }: { preset: LayoutPreset }) { + const config = getPresetConfig(preset); + const w = 320; + const h = 160; + const isFloating = preset === 'floating'; + const pad = isFloating ? 4 : 0; + const gap = 3; + const sidebarW = isFloating ? 55 : 20; + const headerH = isFloating ? 14 : 16; + const contentX = sidebarW + gap; + const contentW = w - sidebarW - gap; + const floatingRx = isFloating ? 4 : 0; + + const isBordered = config.content === 'bordered'; + const contentInset = isBordered ? 5 : 0; + const innerX = contentX + contentInset; + const innerY = headerH + contentInset; + const innerW = contentW - contentInset * 2; + const innerH = h - headerH - contentInset * 2; + + return ( + <svg className="h-full w-full" viewBox={`0 0 ${String(w)} ${String(h)}`}> + <rect className="fill-muted/20" height={h} rx={6} width={w} x={0} y={0} /> + + {/* Sidebar tint */} + <rect fill={SIDEBAR_TINT} height={h - pad * 2} rx={floatingRx} width={sidebarW - pad} x={pad} y={pad} /> + + {/* Toolbar tint */} + <rect fill={TOOLBAR_TINT} height={headerH} width={contentW} x={contentX} y={0} /> + + {/* Content tint */} + <rect fill={CONTENT_TINT} height={h - headerH} width={contentW} x={contentX} y={headerH} /> + + {/* Sidebar border */} + <rect className="stroke-border" fill="none" height={h - pad * 2} rx={floatingRx} strokeWidth={0.5} width={sidebarW - pad} x={pad} y={pad} /> + + {/* Sidebar detail */} + {isFloating ? ( + /* Floating: collapsible group sections */ + <> + <rect className="fill-muted-foreground/20" height={3} rx={1} width={sidebarW - pad - 16} x={pad + 8} y={pad + 24} /> + {Array.from({ length: 3 }).map((_, j) => ( + <rect key={`s1-${String(j)}`} className="fill-muted-foreground/10" height={3} rx={1} width={sidebarW - pad - 20} x={pad + 10} y={pad + 32 + j * 7} /> + ))} + <rect className="fill-muted-foreground/20" height={3} rx={1} width={sidebarW - pad - 16} x={pad + 8} y={pad + 65} /> + {Array.from({ length: 3 }).map((_, j) => ( + <rect key={`s2-${String(j)}`} className="fill-muted-foreground/10" height={3} rx={1} width={sidebarW - pad - 20} x={pad + 10} y={pad + 73 + j * 7} /> + ))} + </> + ) : ( + /* Default: icon collapse icons */ + <> + {Array.from({ length: 5 }).map((_, i) => ( + <rect key={`icon-${String(i)}`} className="fill-muted-foreground/25" height={5} rx={1} width={5} x={pad + 7} y={pad + 24 + i * 14} /> + ))} + </> + )} + + {/* Toolbar detail */} + <rect className="fill-muted-foreground/15" height={Math.max(headerH - 8, 4)} rx={2} width={30} x={contentX + 6} y={4} /> + <rect className="fill-muted-foreground/10" height={Math.max(headerH - 8, 4)} rx={2} width={20} x={contentX + contentW - 30} y={4} /> + + {/* Content border (bordered layout only) */} + {isBordered ? ( + <rect className="stroke-border" fill="none" height={innerH} rx={4} strokeWidth={0.5} width={innerW} x={innerX} y={innerY} /> + ) : null} + + {/* Content lines */} + {Array.from({ length: 5 }).map((_, i) => ( + <rect + key={`line-${String(i)}`} + className="fill-muted-foreground/10" + height={5} + rx={2} + width={(innerW - 20) * (i === 4 ? 0.5 : 0.9 - i * 0.08)} + x={innerX + 10} + y={innerY + 10 + i * 12} + /> + ))} + + <rect className="stroke-border" fill="none" height={h} rx={6} strokeWidth={0.5} width={w} x={0} y={0} /> + </svg> + ); +} + +// ── Main Section ─────────────────────────────────────────── + +export function LayoutSection() { + const { layoutPreset, setLayoutPreset } = useLayoutStore(); + const { colorTheme, setColorTheme, customThemes } = useThemeStore(); + const updateSettings = useUpdateSettings(); + const navigate = useNavigate(); + + function handlePresetChange(value: string) { + const preset = value as LayoutPreset; + setLayoutPreset(preset); + updateSettings.mutate({ layoutPreset: preset }); + } + + function handleThemeChange(value: string) { + setColorTheme(value); + updateSettings.mutate({ colorTheme: value }); + } + + function handleCustomizeTheme() { + void navigate({ to: ROUTES.THEMES as '/' }); + } + + const selectedPreset = LAYOUT_PRESETS.find((p) => p.id === layoutPreset); + + return ( + <section className="mb-8"> + <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + Layout + </h2> + + <div className="border-border overflow-hidden rounded-lg border"> + {/* Top row: preset + theme */} + <div className="grid grid-cols-2"> + {/* Layout Preset */} + <div className="border-border space-y-3 border-r p-4"> + <Text className="text-foreground text-sm font-medium">Layout</Text> + <div className="flex flex-col gap-3"> + <Label htmlFor="layout-preset">Preset</Label> + <Select value={layoutPreset} onValueChange={handlePresetChange}> + <SelectTrigger id="layout-preset"> + <SelectValue placeholder="Select layout" /> + </SelectTrigger> + <SelectContent> + {LAYOUT_PRESETS.map((preset) => ( + <SelectItem key={preset.id} value={preset.id}> + {preset.label} + </SelectItem> + ))} + </SelectContent> + </Select> + {selectedPreset ? ( + <Text className="text-xs" variant="muted">{selectedPreset.description}</Text> + ) : null} + </div> + </div> + + {/* Color Theme */} + <div className="space-y-3 p-4"> + <Text className="text-foreground text-sm font-medium">Theme</Text> + <div className="flex flex-col gap-3"> + <Label htmlFor="color-theme">Color Theme</Label> + <div className="flex items-end gap-2"> + <div className="min-w-0 flex-1"> + <Select value={colorTheme} onValueChange={handleThemeChange}> + <SelectTrigger id="color-theme"> + <SelectValue placeholder="Select theme" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="default"> + <span className="flex items-center gap-2"> + Default + {colorTheme === 'default' ? <Check className="text-success h-3 w-3" /> : null} + </span> + </SelectItem> + {customThemes.map((t) => ( + <SelectItem key={t.id} value={t.id}> + <span className="flex items-center gap-2"> + {t.name} + {colorTheme === t.id ? <Check className="text-success h-3 w-3" /> : null} + </span> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <Tooltip> + <TooltipTrigger asChild> + <Button size="icon" variant="outline" onClick={handleCustomizeTheme}> + <Settings2 className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Edit themes</TooltipContent> + </Tooltip> + </div> + </div> + </div> + </div> + + {/* Bottom: SVG preview */} + <div className="border-border border-t p-4"> + <div className="h-[90px] w-full"> + <LayoutPreview preset={layoutPreset} /> + </div> + <div className="mt-2 flex justify-center gap-4"> + <div className="flex items-center gap-1"> + <span className="h-2 w-2 rounded-sm" style={{ backgroundColor: SIDEBAR_TINT }} /> + <Text className="text-[9px]" variant="muted">Sidebar</Text> + </div> + <div className="flex items-center gap-1"> + <span className="h-2 w-2 rounded-sm" style={{ backgroundColor: TOOLBAR_TINT }} /> + <Text className="text-[9px]" variant="muted">Toolbar</Text> + </div> + <div className="flex items-center gap-1"> + <span className="h-2 w-2 rounded-sm" style={{ backgroundColor: CONTENT_TINT }} /> + <Text className="text-[9px]" variant="muted">Content</Text> + </div> + </div> + </div> + </div> + </section> + ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/features/settings/components/LayoutSection.tsx src/renderer/features/settings/api/useSettings.ts +git commit -m "refactor(settings-ui): preset-based LayoutSection, remove individual selectors" +``` + +--- + +### Task 7: TopBar + ContentAreaContainer Cleanup + +**Files:** +- Modify: `src/renderer/app/layouts/TopBar.tsx` +- Modify: `src/renderer/app/layouts/ContentAreaContainer.tsx` + +- [ ] **Step 1: Clean up TopBar toolbar classes** + +In `src/renderer/app/layouts/TopBar.tsx`, replace the `TOOLBAR_CLASSES` record (lines 34-43): + +```typescript +const TOOLBAR_CLASSES: Record<ToolbarStyleId, string> = { + default: 'h-10 bg-card border border-border', + floating: 'h-9 bg-card/90 border border-border rounded-lg shadow-sm [&_.electron-no-drag_button]:rounded-md', +}; +``` + +The `[&_.electron-no-drag_button]:rounded-md` selector ensures icon buttons inside the floating toolbar inherit rounded hover states. + +- [ ] **Step 2: Clean up ContentAreaContainer styles** + +In `src/renderer/app/layouts/ContentAreaContainer.tsx`, replace the style maps (lines 22-35): + +```typescript +/** Applied to the root container — insets both toolbar and content equally */ +const CONTAINER_STYLE: Record<ContentLayoutId, string> = { + flush: '', + bordered: 'p-[var(--layout-gap)] gap-[var(--layout-gap)]', +}; + +/** Applied to the inner content wrapper — decoration only */ +const INNER_STYLE: Record<ContentLayoutId, string> = { + flush: '', + bordered: 'border-border rounded-lg border', +}; +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/app/layouts/TopBar.tsx src/renderer/app/layouts/ContentAreaContainer.tsx +git commit -m "refactor(layout): remove unused toolbar/content styles, fix floating button rounding" +``` + +--- + +### Task 8: Typecheck + Lint + Verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: 0 errors. If there are errors, fix them — likely stale references to removed types (`IconButtonShape`, old `SidebarLayoutId` variants, `toolbarStyle`/`contentLayout`/`sidebarLayout` on old interfaces). + +- [ ] **Step 2: Lint changed files** + +```bash +npx eslint src/shared/types/layout.ts src/shared/types/settings.ts src/shared/ipc/settings/schemas.ts src/renderer/shared/stores/theme-store.ts src/renderer/shared/stores/layout-store.ts src/renderer/shared/stores/index.ts src/main/features/settings/settings-service.ts src/main/features/settings/settings-defaults.ts src/renderer/features/settings/components/LayoutSection.tsx src/renderer/features/settings/api/useSettings.ts src/renderer/app/layouts/TopBar.tsx src/renderer/app/layouts/ContentAreaContainer.tsx src/renderer/app/layouts/sidebar-layouts/layout-configs.ts +``` + +Expected: 0 errors, 0 warnings. Fix any issues. + +- [ ] **Step 3: Grep for stale references** + +Search for any remaining references to removed concepts: + +```bash +# Should return 0 results in src/ (docs are OK) +grep -rn "iconButtonShape\|setIconButtonShape\|IconButtonShape" src/ +grep -rn "'sidebar-01'\|'sidebar-02'\|'sidebar-03'\|'sidebar-05'\|'sidebar-06'" src/ +grep -rn "'compact'\|'spacious'\|'glass'\|'minimal'\|'inset'" src/shared/types/ src/renderer/app/layouts/TopBar.tsx +grep -rn "'padded'\|'inset'" src/shared/types/ src/renderer/app/layouts/ContentAreaContainer.tsx +``` + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A +git commit -m "fix: resolve typecheck/lint issues from layout preset migration" +``` diff --git a/docs/superpowers/plans/2026-04-23-hub-auto-discovery.md b/docs/superpowers/plans/2026-04-23-hub-auto-discovery.md new file mode 100644 index 00000000..c1b50c4d --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-hub-auto-discovery.md @@ -0,0 +1,2152 @@ +# Hub Auto-Discovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the hand-rolled hub-pairing flow with mDNS-based auto-discovery, Ed25519 client identity, self-signed TLS with fingerprint pinning, dedicated two-step pair endpoints, audit + revocation, admin UI, per-hub data isolation on the client, and a Spotify-style picker in Settings → Hub. + +**Architecture:** Hub advertises `_adc-hub._tcp.local` via Bonjour with TLS fingerprint in TXT. Client browses continuously, shows discovered + paired hubs in a picker, pairs via `POST /api/pair/init` then `POST /api/pair/confirm` (Ed25519 signed nonce). Each paired hub gets its own local SQLite and client identity in `${userData}/hubs/${hubId}/`. Legacy installs survive via a one-time migrator + re-pair banner. Full enterprise bundle: ships as one release (0.2.0). + +**Tech Stack:** Node 22, Fastify + `bonjour-service`, Electron 39, Drizzle ORM on SQLite, React 19 + TanStack Query, Vitest + Playwright. `crypto.generateKeyPairSync('ed25519')` for identities, `@peculiar/x509` or similar for self-signed cert generation, `argon2` for admin password hashing. + +**Spec:** `docs/superpowers/specs/2026-04-23-hub-auto-discovery-design.md` — read it before starting. + +--- + +## File structure + +**Hub (`hub/`):** +- Create: `hub/src/lib/hub-id.ts`, `hub/src/lib/tls.ts`, `hub/src/lib/audit-retention.ts` +- Create: `hub/src/mdns/advertiser.ts` +- Create: `hub/src/routes/pair.ts`, `hub/src/routes/admin/clients.ts`, `hub/src/routes/admin/audit.ts`, `hub/src/routes/admin/settings.ts` +- Create: `hub/src/middleware/pair-rate-limit.ts`, `hub/src/middleware/audit.ts`, `hub/src/middleware/admin-auth.ts` +- Create: `hub/src/ui/admin/` (React SPA sources, bundled at build) +- Create: `hub/scripts/hash-admin-password.mjs` +- Create: `hub/migrations/0010_pair_identity.sql` +- Modify: `hub/src/app.ts` (HTTPS listen, wire new middleware/routes, advertiser lifecycle) +- Modify: `hub/src/middleware/api-key.ts` (honor `revoked_at`, close WebSockets with 4003) +- Modify: `hub/src/db/schema.ts` (add columns, new `pairing_events` table) + +**Client main (`src/main/features/hub/`):** +- Create: `src/main/features/hub/hub-discovery.ts` +- Create: `src/main/features/hub/hub-pair.ts` +- Create: `src/main/features/hub/client-identity.ts` +- Create: `src/main/features/hub/fingerprint-agent.ts` +- Create: `src/main/features/hub/network-watcher.ts` +- Modify: `src/main/features/hub/hub-connection.ts` (multi-hub, switchActive, beforeActiveHubChange) +- Modify: `src/main/features/hub/hub-client.ts` (use fingerprint-pinned agent) +- Modify: `src/main/features/hub/hub-ws-client.ts` (close code 4003 handler) +- Modify: `src/main/features/hub/config-store.ts` (hubs[] shape + legacy migration) +- Modify: `src/main/features/hub/hub-handlers.ts` (new IPC channels) +- Modify: `src/main/lib/db.ts` (per-hub resolver) +- Modify: `src/main/index.ts` (start discovery on whenReady, feature flag) + +**Client renderer (`src/renderer/features/hub/`):** +- Create: `src/renderer/features/hub/components/HubPickerPanel/HubPickerPanel.tsx` (+ subcomponents, .module.css, index.ts) +- Create: `src/renderer/features/hub/components/RevocationModal/RevocationModal.tsx` +- Create: `src/renderer/features/hub/api/useHubDiscovery.ts`, `useHubPair.ts`, `useHubSwitchActive.ts`, `useHubRemoveRecord.ts`, `useHubManualPair.ts` +- Modify: `src/renderer/features/hub/components/HubSetupPage/HubSetupPage.tsx` (wrap picker) +- Modify: `src/renderer/features/settings/components/HubSettings/HubSettings.tsx` (use picker) + +**Shared (`src/shared/ipc/hub/`):** +- Modify: `src/shared/ipc/hub/channels.ts` (5 new request channels, 3 event channels) +- Modify: `src/shared/ipc/hub/contract.ts` (Zod schemas) + +**Tests:** +- Integration: `hub/test/pair.integration.test.ts`, `hub/test/admin.integration.test.ts`, `src/main/features/hub/*.integration.test.ts` +- E2E: `tests/e2e/hub-discovery.spec.ts` + +--- + +## Hub codebase reality (read this before any hub-side task) + +The `hub/` package does NOT use Drizzle ORM. Plan snippets that mention Drizzle `schema.ts` are aspirational — adapt to what's actually there: + +- **Migrations:** raw SQL in `hub/src/db/migrations/` with a 3-digit prefix (`001_…`, `002_…`, `005_…`). Next free number: `006_`. +- **Migration runner:** `hub/src/db/migration-runner.ts` exports `runMigrations(db, migrationsDir)`. +- **Schema reference:** `hub/src/db/schema.sql` is a reference file, not a Drizzle-generated artifact. Do not create `hub/src/db/schema.ts`; skip any plan step that says to modify it. +- **Test runner:** `hub/` has no vitest. Use Node's built-in `node:test` + `node:assert`, write tests as `.test.mjs` or `.test.ts` (tsx is already a devDependency) under `hub/test/`. Add a `test` script in `hub/package.json` if one isn't already present. +- **When adding devDependencies,** install only into `hub/`, not the root repo. + +The **client** (`src/main/`, `src/renderer/`) does use Vitest + Drizzle — those plan snippets are correct as-is. + +## Conventions + +- **TDD always:** write the failing test, run it to see it fail with the expected message, write the minimum code to pass, run it green, commit. No exceptions. +- **Commits:** one per task (not one per step). Use Conventional Commits: `feat(hub): …`, `fix(main): …`, `test(hub-pair): …`. +- **Co-author footer:** include `Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>`. +- **Lint + typecheck per task:** before committing, run `npx eslint <changed files>` and `npx tsc --noEmit`. Do not commit red. +- **Never weaken CSP, never disable gating middleware, never `--no-verify`.** +- **If a test is flaky, fix it or quarantine it. Don't retry in a loop.** +- **Single commit per task** — if you discover a sub-fix needed during a task (e.g., a missing import), fold it in before committing. + +--- + +## Task 1 — Hub schema migration for pair identity + audit + +**Files:** +- Create: `hub/migrations/0010_pair_identity.sql` +- Modify: `hub/src/db/schema.ts` +- Test: `hub/test/migrations.test.ts` + +- [ ] **Step 1: Write failing test** + +```ts +// hub/test/migrations.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../src/db/migrate'; + +describe('migration 0010_pair_identity', () => { + let db: Database.Database; + beforeEach(() => { db = new Database(':memory:'); runMigrations(db); }); + + it('api_keys has new columns', () => { + const cols = db.prepare("PRAGMA table_info(api_keys)").all() as Array<{ name: string }>; + const names = cols.map((c) => c.name); + expect(names).toEqual(expect.arrayContaining([ + 'client_id', 'display_name', 'pubkey_fp', 'revoked_at', 'revoked_reason', + ])); + }); + + it('pairing_events table exists with required columns', () => { + const cols = db.prepare("PRAGMA table_info(pairing_events)").all() as Array<{ name: string }>; + expect(cols.map((c) => c.name)).toEqual(expect.arrayContaining([ + 'id', 'ts', 'event_type', 'client_id', 'display_name', + 'source_ip', 'user_agent', 'pubkey_fp', 'outcome', 'reason', + ])); + }); +}); +``` + +- [ ] **Step 2: Run failing** + +``` +npx vitest run hub/test/migrations.test.ts +``` +Expected: fails because migration file doesn't exist yet. + +- [ ] **Step 3: Implement migration** + +```sql +-- hub/migrations/0010_pair_identity.sql +ALTER TABLE api_keys ADD COLUMN client_id TEXT; +ALTER TABLE api_keys ADD COLUMN display_name TEXT; +ALTER TABLE api_keys ADD COLUMN pubkey_fp TEXT; +ALTER TABLE api_keys ADD COLUMN revoked_at INTEGER; +ALTER TABLE api_keys ADD COLUMN revoked_reason TEXT; + +CREATE INDEX IF NOT EXISTS idx_api_keys_client_id ON api_keys(client_id); + +CREATE TABLE pairing_events ( + id TEXT PRIMARY KEY, + ts INTEGER NOT NULL, + event_type TEXT NOT NULL, + client_id TEXT, + display_name TEXT, + source_ip TEXT, + user_agent TEXT, + pubkey_fp TEXT, + outcome TEXT NOT NULL, + reason TEXT +); + +CREATE INDEX idx_pairing_events_ts ON pairing_events(ts); +CREATE INDEX idx_pairing_events_client_id ON pairing_events(client_id); +CREATE INDEX idx_pairing_events_event_type ON pairing_events(event_type); +``` + +Update Drizzle schema in `hub/src/db/schema.ts`: + +```ts +// add to api_keys table definition: +client_id: text('client_id'), +display_name: text('display_name'), +pubkey_fp: text('pubkey_fp'), +revoked_at: integer('revoked_at'), +revoked_reason: text('revoked_reason'), + +// new table: +export const pairingEvents = sqliteTable('pairing_events', { + id: text('id').primaryKey(), + ts: integer('ts').notNull(), + event_type: text('event_type').notNull(), + client_id: text('client_id'), + display_name: text('display_name'), + source_ip: text('source_ip'), + user_agent: text('user_agent'), + pubkey_fp: text('pubkey_fp'), + outcome: text('outcome').notNull(), + reason: text('reason'), +}); +``` + +- [ ] **Step 4: Run green** + +``` +npx vitest run hub/test/migrations.test.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add hub/migrations/0010_pair_identity.sql hub/src/db/schema.ts hub/test/migrations.test.ts +git commit -m "feat(hub/db): add pair identity columns + pairing_events table + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 2 — Shared IPC channels and Zod contracts + +**Files:** +- Modify: `src/shared/ipc/hub/channels.ts` +- Modify: `src/shared/ipc/hub/contract.ts` +- Test: `src/shared/ipc/hub/contract.test.ts` + +- [ ] **Step 1: Write failing test** + +```ts +// src/shared/ipc/hub/contract.test.ts +import { describe, it, expect } from 'vitest'; +import { HUB, HUB_EVENTS } from './channels'; +import { + hubDiscoveredListOutputSchema, + hubPairRequestInputSchema, + hubSwitchActiveInputSchema, + hubManualPairInputSchema, + hubDiscoveryChangedEventSchema, + hubRevokedEventSchema, +} from './contract'; + +describe('hub IPC channels', () => { + it('defines all new channels', () => { + expect(HUB.DISCOVERED.LIST).toBe('hub.discovered.list'); + expect(HUB.PAIR.REQUEST).toBe('hub.pair.request'); + expect(HUB.SWITCH.ACTIVE).toBe('hub.switch.active'); + expect(HUB.REMOVE.RECORD).toBe('hub.remove.record'); + expect(HUB.MANUAL.PAIR).toBe('hub.manual.pair'); + expect(HUB_EVENTS.DISCOVERY.CHANGED).toBe('event:hub.discovery.changed'); + expect(HUB_EVENTS.ACTIVE.CHANGED).toBe('event:hub.active.changed'); + expect(HUB_EVENTS.REVOKED).toBe('event:hub.revoked'); + }); + + it('PAIR.REQUEST accepts {hubId, displayName?}', () => { + expect(hubPairRequestInputSchema.safeParse({ hubId: 'x' }).success).toBe(true); + expect(hubPairRequestInputSchema.safeParse({ hubId: 'x', displayName: 'y' }).success).toBe(true); + expect(hubPairRequestInputSchema.safeParse({}).success).toBe(false); + }); + + it('DISCOVERED.LIST output includes paired + discovered + activeHubId', () => { + const parsed = hubDiscoveredListOutputSchema.safeParse({ + paired: [], discovered: [], activeHubId: null, + }); + expect(parsed.success).toBe(true); + }); + + it('DISCOVERY.CHANGED event payload is typed', () => { + const parsed = hubDiscoveryChangedEventSchema.safeParse({ + paired: [], discovered: [], activeHubId: null, + }); + expect(parsed.success).toBe(true); + }); + + it('REVOKED event payload has hubId + reason', () => { + expect(hubRevokedEventSchema.safeParse({ hubId: 'x', reason: 'y' }).success).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run failing** + +``` +npx vitest run src/shared/ipc/hub/contract.test.ts +``` + +- [ ] **Step 3: Implement channels and schemas** + +In `channels.ts`: +```ts +export const HUB = { + CONNECT: { SERVER: 'hub.connect.server' }, + DISCONNECT: { SERVER: 'hub.disconnect.server' }, + GET: { STATUS: 'hub.get.status', CONFIG: 'hub.get.config' }, + SYNC: { DATA: 'hub.sync.data' }, + GENERATE: { KEY: 'hub.generate.key' }, + REMOVE: { CONFIG: 'hub.remove.config', RECORD: 'hub.remove.record' }, + DISCOVERED: { LIST: 'hub.discovered.list' }, + PAIR: { REQUEST: 'hub.pair.request' }, + SWITCH: { ACTIVE: 'hub.switch.active' }, + MANUAL: { PAIR: 'hub.manual.pair' }, +} as const; + +export const HUB_EVENTS = { + CONNECTION: { CHANGED: 'event:hub.connection.changed' }, + SYNC: { COMPLETED: 'event:hub.sync.completed' }, + DISCOVERY: { CHANGED: 'event:hub.discovery.changed' }, + ACTIVE: { CHANGED: 'event:hub.active.changed' }, + REVOKED: 'event:hub.revoked', +} as const; +``` + +In `contract.ts`, add: +```ts +import { z } from 'zod'; + +export const hubRecordSchema = z.object({ + hubId: z.string(), + displayName: z.string(), + lastKnownUrl: z.string().nullable(), + pinnedFingerprint: z.string().nullable(), + addedAt: z.string(), + lastConnectedAt: z.string().nullable(), + status: z.enum(['connected', 'disconnected', 'connecting', 'error']), +}); + +export const discoveredHubSchema = z.object({ + hubId: z.string(), + displayName: z.string(), + version: z.string(), + channel: z.string(), + addresses: z.array(z.string()), + port: z.number(), + fingerprint: z.string(), + lastSeenAt: z.string(), + stale: z.boolean(), +}); + +export const hubDiscoveredListOutputSchema = z.object({ + paired: z.array(hubRecordSchema), + discovered: z.array(discoveredHubSchema), + activeHubId: z.string().nullable(), +}); + +export const hubPairRequestInputSchema = z.object({ + hubId: z.string(), + displayName: z.string().optional(), +}); + +export const hubPairRequestOutputSchema = z.discriminatedUnion('ok', [ + z.object({ ok: z.literal(true), hubId: z.string() }), + z.object({ ok: z.literal(false), error: z.string() }), +]); + +export const hubSwitchActiveInputSchema = z.object({ hubId: z.string() }); +export const hubManualPairInputSchema = z.object({ + url: z.string().url(), + displayName: z.string().optional(), +}); + +export const hubDiscoveryChangedEventSchema = hubDiscoveredListOutputSchema; +export const hubActiveChangedEventSchema = z.object({ activeHubId: z.string().nullable() }); +export const hubRevokedEventSchema = z.object({ + hubId: z.string(), + reason: z.string(), +}); +``` + +- [ ] **Step 4: Run green** + +``` +npx vitest run src/shared/ipc/hub/contract.test.ts +npx tsc --noEmit +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/ipc/hub/ +git commit -m "feat(ipc): channels + zod schemas for hub discovery, pair, switch, revoke + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 3 — Hub stable ID library + +**Files:** +- Create: `hub/src/lib/hub-id.ts` +- Test: `hub/test/hub-id.test.ts` + +- [ ] **Step 1: Failing test** + +```ts +// hub/test/hub-id.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveHubId } from '../src/lib/hub-id'; + +describe('resolveHubId', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'hub-id-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('generates and persists a UUID on first call', () => { + const id = resolveHubId(dir); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(existsSync(join(dir, 'hub-id'))).toBe(true); + expect(readFileSync(join(dir, 'hub-id'), 'utf8').trim()).toBe(id); + }); + + it('returns the same id on subsequent calls', () => { + const a = resolveHubId(dir); + const b = resolveHubId(dir); + expect(a).toBe(b); + }); +}); +``` + +- [ ] **Step 2: Run failing** + +- [ ] **Step 3: Implement** + +```ts +// hub/src/lib/hub-id.ts +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export function resolveHubId(dataDir: string): string { + mkdirSync(dataDir, { recursive: true }); + const path = join(dataDir, 'hub-id'); + if (existsSync(path)) { + const existing = readFileSync(path, 'utf8').trim(); + if (existing.length > 0) return existing; + } + const id = randomUUID(); + writeFileSync(path, id + '\n', { mode: 0o600 }); + return id; +} +``` + +- [ ] **Step 4: Run green** + +- [ ] **Step 5: Commit** + +```bash +git add hub/src/lib/hub-id.ts hub/test/hub-id.test.ts +git commit -m "feat(hub/lib): persistent hub id generator + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 4 — Self-signed TLS cert generation + +**Files:** +- Create: `hub/src/lib/tls.ts` +- Test: `hub/test/tls.test.ts` +- Modify: `hub/package.json` (add `@peculiar/x509` dependency) + +- [ ] **Step 1: Install dependency** + +```bash +cd hub && npm install @peculiar/x509 @peculiar/webcrypto +``` + +- [ ] **Step 2: Failing test** + +```ts +// hub/test/tls.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createHash } from 'node:crypto'; +import { resolveTls } from '../src/lib/tls'; + +describe('resolveTls', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tls-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('generates cert, key, and fingerprint on first call', async () => { + const r = await resolveTls(dir, 'test-hub-id'); + expect(r.cert).toMatch(/BEGIN CERTIFICATE/); + expect(r.key).toMatch(/BEGIN PRIVATE KEY/); + expect(r.fingerprint).toMatch(/^[0-9a-f]{64}$/); + expect(existsSync(join(dir, 'tls.cert.pem'))).toBe(true); + expect(existsSync(join(dir, 'tls.key.pem'))).toBe(true); + }); + + it('cert files are mode 0600 on POSIX', () => { + if (process.platform === 'win32') return; + const p = join(dir, 'tls.key.pem'); + // touched by earlier test setup, but test is standalone: + }); + + it('reuses existing cert on subsequent calls', async () => { + const a = await resolveTls(dir, 'test-hub-id'); + const b = await resolveTls(dir, 'test-hub-id'); + expect(a.fingerprint).toBe(b.fingerprint); + }); + + it('fingerprint matches SHA-256 of DER cert', async () => { + const r = await resolveTls(dir, 'test-hub-id'); + const der = Buffer.from(r.cert.replace(/-----(BEGIN|END) CERTIFICATE-----/g, '').replace(/\s+/g, ''), 'base64'); + const expected = createHash('sha256').update(der).digest('hex'); + expect(r.fingerprint).toBe(expected); + }); + + it('regenerates when cert expires within 30 days', async () => { + const a = await resolveTls(dir, 'test-hub-id', { notAfter: new Date(Date.now() + 10 * 86400_000) }); + const b = await resolveTls(dir, 'test-hub-id'); + expect(b.fingerprint).not.toBe(a.fingerprint); + }); +}); +``` + +- [ ] **Step 3: Run failing** + +- [ ] **Step 4: Implement** + +```ts +// hub/src/lib/tls.ts +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash, generateKeyPairSync } from 'node:crypto'; +import { networkInterfaces } from 'node:os'; +import * as x509 from '@peculiar/x509'; +import { Crypto } from '@peculiar/webcrypto'; + +const webcrypto = new Crypto(); +x509.cryptoProvider.set(webcrypto); + +export interface TlsMaterial { + cert: string; // PEM + key: string; // PEM + fingerprint: string; // SHA-256 hex of DER +} + +const CERT_VALIDITY_DAYS = 365; +const ROTATE_IF_WITHIN_DAYS = 30; + +function fingerprintOfPem(pem: string): string { + const der = Buffer.from(pem.replace(/-----(BEGIN|END) CERTIFICATE-----/g, '').replace(/\s+/g, ''), 'base64'); + return createHash('sha256').update(der).digest('hex'); +} + +function collectSANs(): string[] { + const sans = ['localhost']; + const interfaces = networkInterfaces(); + for (const list of Object.values(interfaces)) { + if (!list) continue; + for (const entry of list) { + if (entry.family === 'IPv4' && !entry.internal) sans.push(entry.address); + } + } + return sans; +} + +async function generateCert(hubId: string, opts?: { notAfter?: Date }): Promise<TlsMaterial> { + const keyPair = await webcrypto.subtle.generateKey( + { name: 'Ed25519' } as any, + true, + ['sign', 'verify'], + ) as CryptoKeyPair; + + const now = new Date(); + const notAfter = opts?.notAfter ?? new Date(now.getTime() + CERT_VALIDITY_DAYS * 86400_000); + const sans = collectSANs(); + + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: '01', + name: `CN=${hubId}`, + notBefore: now, + notAfter, + signingAlgorithm: { name: 'Ed25519' }, + keys: keyPair, + extensions: [ + new x509.BasicConstraintsExtension(true, undefined, true), + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyCertSign), + new x509.SubjectAlternativeNameExtension(sans.map((s) => (s.includes(':') || /^\d+\.\d+\.\d+\.\d+$/.test(s)) ? { type: 'ip', value: s } : { type: 'dns', value: s })), + ], + }); + + const pkcs8 = await webcrypto.subtle.exportKey('pkcs8', keyPair.privateKey); + const key = pemFromDer('PRIVATE KEY', Buffer.from(pkcs8)); + const certPem = cert.toString('pem'); + return { cert: certPem, key, fingerprint: fingerprintOfPem(certPem) }; +} + +function pemFromDer(label: string, der: Buffer): string { + const b64 = der.toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''; + return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`; +} + +function daysUntilExpiry(pem: string): number { + const cert = new x509.X509Certificate(pem); + return (cert.notAfter.getTime() - Date.now()) / 86400_000; +} + +export async function resolveTls(dataDir: string, hubId: string, opts?: { notAfter?: Date }): Promise<TlsMaterial> { + mkdirSync(dataDir, { recursive: true }); + const certPath = join(dataDir, 'tls.cert.pem'); + const keyPath = join(dataDir, 'tls.key.pem'); + + if (existsSync(certPath) && existsSync(keyPath) && !opts?.notAfter) { + const existing = readFileSync(certPath, 'utf8'); + if (daysUntilExpiry(existing) > ROTATE_IF_WITHIN_DAYS) { + return { cert: existing, key: readFileSync(keyPath, 'utf8'), fingerprint: fingerprintOfPem(existing) }; + } + } + + const material = await generateCert(hubId, opts); + writeFileSync(certPath, material.cert, { mode: 0o600 }); + writeFileSync(keyPath, material.key, { mode: 0o600 }); + if (process.platform !== 'win32') { + chmodSync(certPath, 0o600); + chmodSync(keyPath, 0o600); + } + return material; +} +``` + +- [ ] **Step 5: Run green** + +- [ ] **Step 6: Commit** + +```bash +git add hub/src/lib/tls.ts hub/test/tls.test.ts hub/package.json hub/package-lock.json +git commit -m "feat(hub/lib): self-signed Ed25519 TLS cert with 30-day rotation window + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 5 — Audit middleware + +**Files:** +- Create: `hub/src/middleware/audit.ts` +- Create: `hub/src/lib/audit-repo.ts` +- Test: `hub/test/audit.test.ts` + +- [ ] **Step 1: Failing test** + +```ts +// hub/test/audit.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../src/db/migrate'; +import { createAuditRepo } from '../src/lib/audit-repo'; + +describe('AuditRepo', () => { + let db: Database.Database; + beforeEach(() => { db = new Database(':memory:'); runMigrations(db); }); + + it('records a pair.confirm event', () => { + const repo = createAuditRepo(db); + repo.record({ + event_type: 'pair.confirm', + client_id: 'c1', display_name: 'Test', + source_ip: '127.0.0.1', user_agent: 'ua', pubkey_fp: 'fp', + outcome: 'success', + }); + const rows = db.prepare('SELECT * FROM pairing_events').all() as any[]; + expect(rows).toHaveLength(1); + expect(rows[0].event_type).toBe('pair.confirm'); + expect(rows[0].outcome).toBe('success'); + expect(typeof rows[0].ts).toBe('number'); + }); + + it('list filters by clientId and eventType', () => { + const repo = createAuditRepo(db); + repo.record({ event_type: 'pair.init', client_id: 'a', outcome: 'success' }); + repo.record({ event_type: 'pair.init', client_id: 'b', outcome: 'success' }); + repo.record({ event_type: 'pair.confirm', client_id: 'a', outcome: 'success' }); + const byA = repo.list({ clientId: 'a' }); + expect(byA).toHaveLength(2); + const inits = repo.list({ eventType: 'pair.init' }); + expect(inits).toHaveLength(2); + }); + + it('purgeOlderThan deletes old rows', () => { + const repo = createAuditRepo(db); + const old = Date.now() - 91 * 86400_000; + db.prepare("INSERT INTO pairing_events (id, ts, event_type, outcome) VALUES (?, ?, ?, ?)").run('x', old, 'pair.init', 'success'); + repo.purgeOlderThan(90 * 86400_000); + expect(repo.list({})).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run failing** + +- [ ] **Step 3: Implement** + +```ts +// hub/src/lib/audit-repo.ts +import { ulid } from 'ulid'; +import type Database from 'better-sqlite3'; + +export type PairingEventType = 'pair.init' | 'pair.confirm' | 'pair.reject' | 'key.revoke' | 'key.use'; +export type PairingOutcome = 'success' | 'rate_limited' | 'identity_conflict' | 'bad_signature' | 'expired_nonce' | 'revoked' | 'unknown_key' | 'internal_error'; + +export interface RecordInput { + event_type: PairingEventType; + client_id?: string | null; + display_name?: string | null; + source_ip?: string | null; + user_agent?: string | null; + pubkey_fp?: string | null; + outcome: PairingOutcome; + reason?: string | null; +} + +export interface ListFilter { + from?: number; + to?: number; + clientId?: string; + eventType?: PairingEventType; + limit?: number; + offset?: number; +} + +export interface AuditRepo { + record: (input: RecordInput) => void; + list: (filter: ListFilter) => unknown[]; + purgeOlderThan: (ageMs: number) => number; +} + +export function createAuditRepo(db: Database.Database): AuditRepo { + const insert = db.prepare( + `INSERT INTO pairing_events (id, ts, event_type, client_id, display_name, source_ip, user_agent, pubkey_fp, outcome, reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + + return { + record(input) { + insert.run( + ulid(), Date.now(), input.event_type, + input.client_id ?? null, input.display_name ?? null, + input.source_ip ?? null, input.user_agent ?? null, input.pubkey_fp ?? null, + input.outcome, input.reason ?? null, + ); + }, + list(filter) { + const clauses: string[] = []; + const params: unknown[] = []; + if (filter.from) { clauses.push('ts >= ?'); params.push(filter.from); } + if (filter.to) { clauses.push('ts <= ?'); params.push(filter.to); } + if (filter.clientId) { clauses.push('client_id = ?'); params.push(filter.clientId); } + if (filter.eventType) { clauses.push('event_type = ?'); params.push(filter.eventType); } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const limit = Math.min(filter.limit ?? 100, 1000); + const offset = filter.offset ?? 0; + params.push(limit, offset); + return db.prepare(`SELECT * FROM pairing_events ${where} ORDER BY ts DESC LIMIT ? OFFSET ?`).all(...params); + }, + purgeOlderThan(ageMs) { + const cutoff = Date.now() - ageMs; + const info = db.prepare('DELETE FROM pairing_events WHERE ts < ?').run(cutoff); + return info.changes as number; + }, + }; +} +``` + +```ts +// hub/src/middleware/audit.ts +import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { AuditRepo, PairingEventType, PairingOutcome } from '../lib/audit-repo'; + +export function createAuditMiddleware(audit: AuditRepo) { + return function auditEvent( + req: FastifyRequest, + eventType: PairingEventType, + outcome: PairingOutcome, + extra: { clientId?: string | null; displayName?: string | null; pubkeyFp?: string | null; reason?: string | null }, + ) { + audit.record({ + event_type: eventType, + outcome, + client_id: extra.clientId ?? null, + display_name: extra.displayName ?? null, + pubkey_fp: extra.pubkeyFp ?? null, + source_ip: req.ip, + user_agent: req.headers['user-agent'] ?? null, + reason: extra.reason ?? null, + }); + }; +} +``` + +- [ ] **Step 4: Run green** + +- [ ] **Step 5: Commit** + +```bash +git add hub/src/middleware/audit.ts hub/src/lib/audit-repo.ts hub/test/audit.test.ts +git commit -m "feat(hub): audit repo + middleware for pairing events + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 6 — Pair rate-limit middleware + +**Files:** +- Create: `hub/src/middleware/pair-rate-limit.ts` +- Test: `hub/test/pair-rate-limit.test.ts` + +- [ ] **Step 1: Failing test** + +```ts +// hub/test/pair-rate-limit.test.ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createRateLimiter } from '../src/middleware/pair-rate-limit'; + +describe('pair rate limiter', () => { + beforeEach(() => vi.useFakeTimers({ now: 1_000_000 })); + + it('allows up to N requests in the window', () => { + const limiter = createRateLimiter({ limit: 3, windowMs: 60_000 }); + expect(limiter.take('ip1')).toEqual({ allowed: true, remaining: 2 }); + expect(limiter.take('ip1')).toEqual({ allowed: true, remaining: 1 }); + expect(limiter.take('ip1')).toEqual({ allowed: true, remaining: 0 }); + expect(limiter.take('ip1')).toMatchObject({ allowed: false }); + }); + + it('resets after window elapses', () => { + const limiter = createRateLimiter({ limit: 1, windowMs: 1000 }); + limiter.take('ip1'); + expect(limiter.take('ip1').allowed).toBe(false); + vi.advanceTimersByTime(1001); + expect(limiter.take('ip1').allowed).toBe(true); + }); + + it('isolates by key', () => { + const limiter = createRateLimiter({ limit: 1, windowMs: 60_000 }); + expect(limiter.take('a').allowed).toBe(true); + expect(limiter.take('b').allowed).toBe(true); + expect(limiter.take('a').allowed).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run failing** + +- [ ] **Step 3: Implement** + +```ts +// hub/src/middleware/pair-rate-limit.ts +export interface RateLimiterOpts { limit: number; windowMs: number; } +export interface RateLimiter { + take: (key: string) => { allowed: boolean; remaining: number; retryAfterMs?: number }; + reset: (key: string) => void; +} + +interface Bucket { count: number; resetAt: number; } + +export function createRateLimiter(opts: RateLimiterOpts): RateLimiter { + const buckets = new Map<string, Bucket>(); + + function prune() { + const now = Date.now(); + for (const [k, b] of buckets) { if (b.resetAt <= now) buckets.delete(k); } + } + + return { + take(key) { + prune(); + const now = Date.now(); + const bucket = buckets.get(key); + if (!bucket || bucket.resetAt <= now) { + buckets.set(key, { count: 1, resetAt: now + opts.windowMs }); + return { allowed: true, remaining: opts.limit - 1 }; + } + if (bucket.count >= opts.limit) { + return { allowed: false, remaining: 0, retryAfterMs: bucket.resetAt - now }; + } + bucket.count += 1; + return { allowed: true, remaining: opts.limit - bucket.count }; + }, + reset(key) { buckets.delete(key); }, + }; +} +``` + +- [ ] **Step 4: Run green** + +- [ ] **Step 5: Commit** + +```bash +git add hub/src/middleware/pair-rate-limit.ts hub/test/pair-rate-limit.test.ts +git commit -m "feat(hub): per-key rate limiter for pair endpoints + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 7 — Admin key bootstrap + admin-auth middleware + +**Files:** +- Create: `hub/src/middleware/admin-auth.ts` +- Create: `hub/src/lib/admin-key.ts` +- Create: `hub/scripts/hash-admin-password.mjs` +- Test: `hub/test/admin-key.test.ts` + +- [ ] **Step 1: Install argon2** + +```bash +cd hub && npm install argon2 +``` + +- [ ] **Step 2: Failing test** + +```ts +// hub/test/admin-key.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveAdminKey, rotateAdminKey } from '../src/lib/admin-key'; + +describe('admin key', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'admin-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('generates on first resolve and persists', () => { + const k = resolveAdminKey(dir); + expect(k).toMatch(/^[0-9a-f]{64}$/); + expect(readFileSync(join(dir, 'admin-key.txt'), 'utf8').trim()).toBe(k); + }); + + it('reuses existing key', () => { + const a = resolveAdminKey(dir); + const b = resolveAdminKey(dir); + expect(a).toBe(b); + }); + + it('rotate replaces the key', () => { + const a = resolveAdminKey(dir); + const b = rotateAdminKey(dir); + expect(b).not.toBe(a); + expect(resolveAdminKey(dir)).toBe(b); + }); +}); +``` + +- [ ] **Step 3: Run failing** + +- [ ] **Step 4: Implement** + +```ts +// hub/src/lib/admin-key.ts +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; + +function newKey(): string { return randomBytes(32).toString('hex'); } + +export function resolveAdminKey(dataDir: string): string { + mkdirSync(dataDir, { recursive: true }); + const path = join(dataDir, 'admin-key.txt'); + if (existsSync(path)) { + const existing = readFileSync(path, 'utf8').trim(); + if (existing.length > 0) return existing; + } + const k = newKey(); + writeFileSync(path, k + '\n', { mode: 0o600 }); + if (process.platform !== 'win32') chmodSync(path, 0o600); + return k; +} + +export function rotateAdminKey(dataDir: string): string { + const path = join(dataDir, 'admin-key.txt'); + const k = newKey(); + writeFileSync(path, k + '\n', { mode: 0o600 }); + if (process.platform !== 'win32') chmodSync(path, 0o600); + return k; +} +``` + +```ts +// hub/src/middleware/admin-auth.ts +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { timingSafeEqual } from 'node:crypto'; +import argon2 from 'argon2'; + +export function createAdminKeyMiddleware(getCurrentKey: () => string) { + return async function adminKeyAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> { + const header = req.headers['x-admin-key']; + if (typeof header !== 'string' || header.length === 0) { + await reply.status(401).send({ error: 'Missing X-Admin-Key header' }); + return; + } + const expected = getCurrentKey(); + const a = Buffer.from(header); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + await reply.status(401).send({ error: 'Invalid admin key' }); + } + }; +} + +export function createAdminBasicAuth(user: string | undefined, passwordHash: string | undefined) { + return async function adminBasic(req: FastifyRequest, reply: FastifyReply): Promise<void> { + if (!user || !passwordHash) { + await reply.status(403).send({ error: 'Admin UI disabled — set HUB_ADMIN_USER and HUB_ADMIN_PASSWORD_HASH' }); + return; + } + const header = req.headers.authorization; + if (!header?.startsWith('Basic ')) { + reply.header('WWW-Authenticate', 'Basic realm="ADC Hub Admin"'); + await reply.status(401).send({ error: 'Authentication required' }); + return; + } + const [submittedUser, submittedPass] = Buffer.from(header.slice(6), 'base64').toString('utf8').split(':'); + if (submittedUser !== user) { + await reply.status(401).send({ error: 'Invalid credentials' }); + return; + } + try { + const ok = await argon2.verify(passwordHash, submittedPass ?? ''); + if (!ok) { await reply.status(401).send({ error: 'Invalid credentials' }); return; } + } catch { + await reply.status(500).send({ error: 'Admin auth misconfigured' }); + } + }; +} +``` + +```js +// hub/scripts/hash-admin-password.mjs +import argon2 from 'argon2'; +const pw = process.argv[2]; +if (!pw) { console.error('Usage: node hash-admin-password.mjs <password>'); process.exit(1); } +console.log(await argon2.hash(pw, { type: argon2.argon2id })); +``` + +- [ ] **Step 5: Run green** + +- [ ] **Step 6: Commit** + +```bash +git add hub/src/middleware/admin-auth.ts hub/src/lib/admin-key.ts hub/scripts/hash-admin-password.mjs hub/test/admin-key.test.ts hub/package.json hub/package-lock.json +git commit -m "feat(hub): admin key bootstrap + basic-auth + argon2 password hashing + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 8 — HTTPS Fastify + revoked-key enforcement + +**Files:** +- Modify: `hub/src/app.ts` +- Modify: `hub/src/middleware/api-key.ts` +- Test: `hub/test/app-https.test.ts`, `hub/test/api-key-revoked.test.ts` + +- [ ] **Step 1: Failing test (HTTPS)** + +```ts +// hub/test/app-https.test.ts +import { describe, it, expect } from 'vitest'; +import { buildApp } from '../src/app'; +import { request } from 'node:https'; + +describe('hub HTTPS listen', () => { + it('serves /api/health over TLS with self-signed cert', async () => { + const { app, tls } = await buildApp({ dataDir: 'test-data', port: 0 }); + await app.listen({ host: '127.0.0.1', port: 0 }); + const address = app.server.address(); + if (address === null || typeof address === 'string') throw new Error('no address'); + const body: string = await new Promise((resolve, reject) => { + const req = request({ + host: '127.0.0.1', port: address.port, path: '/api/health', method: 'GET', + ca: tls.cert, rejectUnauthorized: true, servername: 'localhost', + }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }); + req.on('error', reject); req.end(); + }); + expect(body).toContain('"status":"ok"'); + await app.close(); + }); +}); +``` + +- [ ] **Step 2: Failing test (revocation)** + +```ts +// hub/test/api-key-revoked.test.ts +// Set up a hub with a live key, revoke it, verify request rejected with 401 + body mentions revoked. +``` + +- [ ] **Step 3: Implement HTTPS in app.ts** + +```ts +// replace plain http listen with: +import Fastify from 'fastify'; +import { resolveHubId } from './lib/hub-id'; +import { resolveTls } from './lib/tls'; + +export async function buildApp(opts: { dataDir: string; port?: number }) { + const hubId = resolveHubId(opts.dataDir); + const tls = await resolveTls(opts.dataDir, hubId); + const app = Fastify({ + https: { cert: tls.cert, key: tls.key }, + logger: true, + }); + // … existing routes/middleware wiring … + return { app, hubId, tls }; +} +``` + +- [ ] **Step 4: Update api-key.ts** + +```ts +// in apiKeyMiddleware after fetching `row`: +if (!row) { await reply.status(401).send({ error: 'Invalid API key' }); return; } +if (row.revoked_at !== null) { + await reply.status(401).send({ error: `Key revoked: ${row.revoked_reason ?? 'no reason'}` }); + return; +} +(req as any).clientId = row.client_id; // for downstream handlers +``` + +- [ ] **Step 5: Run green (both tests)** + +- [ ] **Step 6: Commit** + +```bash +git add hub/src/app.ts hub/src/middleware/api-key.ts hub/test/app-https.test.ts hub/test/api-key-revoked.test.ts +git commit -m "feat(hub): HTTPS-only listen + honor revoked keys + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 9 — /api/pair/init endpoint + +**Files:** +- Create: `hub/src/routes/pair.ts` (init part) +- Create: `hub/src/lib/nonce-store.ts` +- Test: `hub/test/pair-init.test.ts` + +- [ ] **Step 1: Failing test** + +Write a test that POSTs `{ clientId, clientPubKey, displayName }` to `/api/pair/init` and asserts a 200 with `{ nonce, expiresAt }` where `nonce` is 43-char base64 (32 bytes). A second call with the same `clientId` but different `clientPubKey` returns 409. + +- [ ] **Step 2: Run failing** + +- [ ] **Step 3: Implement nonce-store** + +```ts +// hub/src/lib/nonce-store.ts +import { randomBytes } from 'node:crypto'; + +export interface NonceStore { + mint: (clientId: string) => { nonce: string; expiresAt: number }; + consume: (clientId: string, nonce: string) => boolean; + has: (clientId: string, nonce: string) => boolean; +} + +const TTL_MS = 30_000; + +export function createNonceStore(): NonceStore { + const store = new Map<string, Map<string, number>>(); + + function prune() { + const now = Date.now(); + for (const [cid, nonces] of store) { + for (const [n, exp] of nonces) if (exp <= now) nonces.delete(n); + if (nonces.size === 0) store.delete(cid); + } + } + + return { + mint(clientId) { + prune(); + const nonce = randomBytes(32).toString('base64url'); + const expiresAt = Date.now() + TTL_MS; + if (!store.has(clientId)) store.set(clientId, new Map()); + store.get(clientId)!.set(nonce, expiresAt); + return { nonce, expiresAt }; + }, + has(clientId, nonce) { + prune(); + const exp = store.get(clientId)?.get(nonce); + return exp !== undefined && exp > Date.now(); + }, + consume(clientId, nonce) { + prune(); + const exp = store.get(clientId)?.get(nonce); + if (exp === undefined || exp <= Date.now()) return false; + store.get(clientId)!.delete(nonce); + return true; + }, + }; +} +``` + +- [ ] **Step 4: Implement pair route** + +```ts +// hub/src/routes/pair.ts (skeleton; confirm added in Task 10) +import type { FastifyPluginAsync, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import type Database from 'better-sqlite3'; +import { createNonceStore } from '../lib/nonce-store'; +import { createRateLimiter } from '../middleware/pair-rate-limit'; +import type { AuditRepo } from '../lib/audit-repo'; + +const initSchema = z.object({ + clientId: z.string().min(1).max(64), + // base64url-encoded DER SPKI public key produced by + // crypto.generateKeyPairSync('ed25519', { publicKeyEncoding: { type: 'spki', format: 'der' } }) + clientPubKey: z.string().min(1).max(256), + displayName: z.string().max(64).optional(), +}); + +export interface PairDeps { + db: Database.Database; + audit: AuditRepo; + hubId: string; + nonceStore?: ReturnType<typeof createNonceStore>; + initLimit?: { limit: number; windowMs: number }; + confirmLimit?: { limit: number; windowMs: number }; +} + +export function createPairRoutes(deps: PairDeps): FastifyPluginAsync { + const nonces = deps.nonceStore ?? createNonceStore(); + const initLimiter = createRateLimiter(deps.initLimit ?? { limit: 20, windowMs: 3_600_000 }); + const confirmLimiter = createRateLimiter(deps.confirmLimit ?? { limit: 5, windowMs: 3_600_000 }); + + return async (app) => { + app.post('/api/pair/init', async (req, reply) => { + const lim = initLimiter.take(req.ip); + if (!lim.allowed) { + deps.audit.record({ event_type: 'pair.reject', outcome: 'rate_limited', source_ip: req.ip, user_agent: req.headers['user-agent'] ?? null }); + return reply.status(429).send({ error: 'Rate limited', retryAfterMs: lim.retryAfterMs }); + } + const parsed = initSchema.safeParse(req.body); + if (!parsed.success) { + deps.audit.record({ event_type: 'pair.reject', outcome: 'internal_error', reason: parsed.error.message, source_ip: req.ip }); + return reply.status(400).send({ error: parsed.error.message }); + } + const { clientId, clientPubKey, displayName } = parsed.data; + + // Check for existing pubkey binding + const existing = deps.db.prepare('SELECT pubkey_fp FROM api_keys WHERE client_id = ? AND revoked_at IS NULL').get(clientId) as { pubkey_fp: string } | undefined; + const pubkeyFp = await fingerprintOfPubKey(clientPubKey); + if (existing && existing.pubkey_fp !== pubkeyFp) { + deps.audit.record({ + event_type: 'pair.reject', outcome: 'identity_conflict', + client_id: clientId, display_name: displayName ?? null, + source_ip: req.ip, pubkey_fp: pubkeyFp, + }); + return reply.status(409).send({ error: 'IdentityConflict' }); + } + + // Persist binding on first contact so /api/pair/confirm can verify signatures. + // INSERT OR IGNORE because an existing binding with a matching pubkey is fine. + const pubKeyDer = Buffer.from(clientPubKey, 'base64url'); + deps.db.prepare('INSERT OR IGNORE INTO client_bindings (client_id, pubkey_der, created_at) VALUES (?, ?, ?)') + .run(clientId, pubKeyDer, Date.now()); + + const { nonce, expiresAt } = nonces.mint(clientId); + deps.audit.record({ + event_type: 'pair.init', outcome: 'success', + client_id: clientId, display_name: displayName ?? null, + source_ip: req.ip, pubkey_fp: pubkeyFp, + }); + return reply.send({ nonce, expiresAt }); + }); + }; +} + +async function fingerprintOfPubKey(pubKeyBase64url: string): Promise<string> { + const { createHash } = await import('node:crypto'); + const raw = Buffer.from(pubKeyBase64url, 'base64url'); + return createHash('sha256').update(raw).digest('hex'); +} +``` + +Wire into `app.ts`: +```ts +await app.register(createPairRoutes({ db, audit, hubId })); +``` + +- [ ] **Step 5: Run green** + +- [ ] **Step 6: Commit** + +```bash +git add hub/src/routes/pair.ts hub/src/lib/nonce-store.ts hub/src/app.ts hub/test/pair-init.test.ts +git commit -m "feat(hub): /api/pair/init with nonce issuance + identity conflict detection + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 10 — /api/pair/confirm endpoint + key mint + +**Files:** +- Modify: `hub/src/routes/pair.ts` (add confirm handler) +- Test: `hub/test/pair-confirm.test.ts` + +- [ ] **Step 1: Failing test** + +Test the full init → sign → confirm happy path using Node's `crypto.generateKeyPairSync('ed25519')` locally to produce a signable client keypair, then: +- POST `/api/pair/init` → receive nonce +- Sign nonce with `crypto.sign(null, Buffer.from(nonce, 'base64url'), privateKey)` +- POST `/api/pair/confirm` with `{ clientId, nonce, signature }` → receive `{ hubId, displayName, key }` +- Call `GET /api/projects` with `X-API-Key: key` → 200 + +Additional failing cases: replay nonce → 401; tampered signature → 401; expired nonce → 401. + +- [ ] **Step 2: Run failing** + +- [ ] **Step 3: Implement** + +```ts +// add to pair.ts (inside createPairRoutes plugin): +const confirmSchema = z.object({ + clientId: z.string().min(1).max(64), + nonce: z.string().min(1).max(128), + signature: z.string().min(1).max(256), +}); + +app.post('/api/pair/confirm', async (req, reply) => { + const parsed = confirmSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: parsed.error.message }); + const { clientId, nonce, signature } = parsed.data; + const lim = confirmLimiter.take(clientId); + if (!lim.allowed) { + deps.audit.record({ event_type: 'pair.reject', outcome: 'rate_limited', client_id: clientId, source_ip: req.ip }); + return reply.status(429).send({ error: 'Rate limited', retryAfterMs: lim.retryAfterMs }); + } + if (!nonces.has(clientId, nonce)) { + deps.audit.record({ event_type: 'pair.reject', outcome: 'expired_nonce', client_id: clientId, source_ip: req.ip }); + return reply.status(401).send({ error: 'ExpiredNonce' }); + } + // Pubkey was persisted by /api/pair/init into client_bindings (see migration). + // Read it here and verify the signature. + const binding = deps.db.prepare('SELECT pubkey_der FROM client_bindings WHERE client_id = ?').get(clientId) as { pubkey_der: Buffer } | undefined; + if (!binding) { + deps.audit.record({ event_type: 'pair.reject', outcome: 'bad_signature', client_id: clientId, reason: 'no binding', source_ip: req.ip }); + return reply.status(401).send({ error: 'UnknownClient' }); + } + const { verify, createPublicKey } = await import('node:crypto'); + const publicKey = createPublicKey({ key: binding.pubkey_der, format: 'der', type: 'spki' }); + const ok = verify(null, Buffer.from(nonce, 'base64url'), publicKey, Buffer.from(signature, 'base64url')); + if (!ok) { + deps.audit.record({ event_type: 'pair.reject', outcome: 'bad_signature', client_id: clientId, source_ip: req.ip }); + return reply.status(401).send({ error: 'BadSignature' }); + } + nonces.consume(clientId, nonce); + + // Revoke previous keys for this clientId + deps.db.prepare("UPDATE api_keys SET revoked_at = ?, revoked_reason = 'superseded' WHERE client_id = ? AND revoked_at IS NULL").run(Date.now(), clientId); + // Mint new key + const key = randomBytes(32).toString('hex'); + const keyHash = createHash('sha256').update(key).digest('hex'); + const pubkeyFp = createHash('sha256').update(binding.pubkey_der).digest('hex'); + const displayName = req.body?.displayName ?? `Client ${clientId.slice(0, 8)}`; + deps.db.prepare(`INSERT INTO api_keys (id, key_hash, created_at, client_id, display_name, pubkey_fp) VALUES (?, ?, ?, ?, ?, ?)`) + .run(ulid(), keyHash, Date.now(), clientId, displayName, pubkeyFp); + + deps.audit.record({ event_type: 'pair.confirm', outcome: 'success', client_id: clientId, display_name: displayName, pubkey_fp: pubkeyFp }); + return reply.send({ hubId: deps.hubId, displayName, key }); +}); +``` + +Note: the raw pubkey needs to flow from init to confirm. The simplest durable option is to persist it in init. Refactor: add a `client_bindings` table with `(client_id PK, pubkey_der BLOB, created_at INTEGER)` created in a follow-up migration — *but* since this task already exists, fold into Task 1 migration. Go back and add to `0010_pair_identity.sql`: + +```sql +CREATE TABLE client_bindings ( + client_id TEXT PRIMARY KEY, + pubkey_der BLOB NOT NULL, + created_at INTEGER NOT NULL +); +``` + +Update `init` to write this row (INSERT OR IGNORE), `confirm` to read it. + +- [ ] **Step 4: Run green (all happy + error cases)** + +- [ ] **Step 5: Commit** + +```bash +git add hub/src/routes/pair.ts hub/migrations/0010_pair_identity.sql hub/test/pair-confirm.test.ts +git commit -m "feat(hub): /api/pair/confirm with Ed25519 nonce verification + key mint + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 11 — WebSocket revocation close code 4003 + +**Files:** +- Modify: `hub/src/app.ts` or wherever WS connections are tracked +- Test: `hub/test/ws-revoke.test.ts` + +- [ ] **Step 1: Failing test** + +Spin up hub, pair a client, open a WebSocket to `/ws` with `?apiKey=<key>` (or the auth message), revoke the key via DB, assert the WS receives close code 4003 with body mentioning `revoked`. + +- [ ] **Step 2: Implement** + +Maintain a Map `Map<clientId, Set<WebSocket>>` on the WebSocket router. When a message with `type: 'revoke'` is received on an internal revocation bus, iterate the set and call `ws.close(4003, JSON.stringify({ reason }))`. + +Expose a `revokeClient(clientId: string, reason: string)` function on the app that: +1. `UPDATE api_keys SET revoked_at=?, revoked_reason=? WHERE client_id=?` +2. Emits through the revocation bus so WebSockets close. + +- [ ] **Step 3: Run green** + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(hub): WebSocket close 4003 on client revocation + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 12 — mDNS advertiser + +**Files:** +- Create: `hub/src/mdns/advertiser.ts` +- Modify: `hub/package.json` (`bonjour-service`) +- Modify: `hub/src/app.ts` +- Test: `hub/test/advertiser.test.ts` + +- [ ] **Step 1: Install bonjour-service** + +```bash +cd hub && npm install bonjour-service +``` + +- [ ] **Step 2: Failing test** + +```ts +// hub/test/advertiser.test.ts +import { describe, it, expect } from 'vitest'; +import { createAdvertiser } from '../src/mdns/advertiser'; +import { Bonjour } from 'bonjour-service'; + +describe('mdns advertiser', () => { + it('publishes a service with expected TXT fields', async () => { + const adv = createAdvertiser({ + hubId: 'test-hub', version: '0.2.0', channel: 'dev', + displayName: 'TestHub', port: 31337, fingerprint: 'a'.repeat(64), + }); + await adv.start(); + const bonjour = new Bonjour(); + const found = await new Promise<any>((resolve) => { + const browser = bonjour.find({ type: 'adc-hub' }, (svc) => { resolve(svc); browser.stop(); }); + }); + expect(found.txt.id).toBe('test-hub'); + expect(found.txt.v).toBe('1'); + expect(found.txt.app).toBe('0.2.0'); + expect(found.txt.ch).toBe('dev'); + expect(found.txt.name).toBe('TestHub'); + expect(found.txt.fp).toBe('a'.repeat(64)); + bonjour.destroy(); + await adv.stop(); + }, 15_000); +}); +``` + +- [ ] **Step 3: Implement** + +```ts +// hub/src/mdns/advertiser.ts +import { Bonjour, Service } from 'bonjour-service'; + +export interface AdvertiserOpts { + hubId: string; + version: string; + channel: 'release' | 'local' | 'dev'; + displayName: string; + port: number; + fingerprint: string; +} + +export interface Advertiser { + start: () => Promise<void>; + stop: () => Promise<void>; + refresh: (opts: Partial<AdvertiserOpts>) => Promise<void>; +} + +export function createAdvertiser(opts: AdvertiserOpts): Advertiser { + let bonjour: Bonjour | null = null; + let service: Service | null = null; + let current = { ...opts }; + + async function publish() { + if (!bonjour) bonjour = new Bonjour(); + service = bonjour.publish({ + name: current.hubId, + type: 'adc-hub', + protocol: 'tcp', + port: current.port, + txt: { + id: current.hubId, + v: '1', + app: current.version, + ch: current.channel, + name: current.displayName, + api: '/api', + fp: current.fingerprint, + }, + }); + } + + return { + async start() { await publish(); }, + async stop() { + await new Promise<void>((resolve) => { + if (!service) return resolve(); + service.stop(() => { service = null; resolve(); }); + }); + if (bonjour) { bonjour.destroy(); bonjour = null; } + }, + async refresh(patch) { + current = { ...current, ...patch }; + if (service) { service.stop(); service = null; } + await publish(); + }, + }; +} +``` + +Wire into `hub/src/app.ts` lifecycle: + +```ts +import { createAdvertiser } from './mdns/advertiser'; +// after app.listen resolves: +const advertiser = createAdvertiser({ + hubId, version: pkg.version, channel: (process.env.HUB_CHANNEL as any) ?? 'release', + displayName: process.env.HUB_DISPLAY_NAME ?? hostname(), port: resolvedPort, fingerprint: tls.fingerprint, +}); +await advertiser.start(); +// SIGTERM / SIGINT handler: +for (const sig of ['SIGTERM', 'SIGINT']) { + process.on(sig, async () => { await advertiser.stop(); process.exit(0); }); +} +``` + +- [ ] **Step 4: Run green** + +- [ ] **Step 5: Commit** + +```bash +git add hub/src/mdns/advertiser.ts hub/src/app.ts hub/package.json hub/package-lock.json hub/test/advertiser.test.ts +git commit -m "feat(hub): mdns advertiser with TXT record including TLS fingerprint + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 13 — Hub admin REST endpoints (single task) + +**Files:** +- Create: `hub/src/routes/admin/clients.ts`, `audit.ts`, `settings.ts`, `index.ts` +- Modify: `hub/src/app.ts` +- Test: `hub/test/admin-clients.test.ts`, `hub/test/admin-audit.test.ts` + +- [ ] **Step 1: Failing tests** + +Write tests covering: +- `GET /api/admin/clients` → list paired clients +- `POST /api/admin/clients/:clientId/revoke` → revokes + returns 200; subsequent key use returns 401 "Key revoked" +- `POST /api/admin/clients/:clientId/rename` → updates display_name +- `POST /api/admin/clients/:clientId/reset-identity` → removes client_binding row +- `GET /api/admin/audit` → paginated +- `POST /api/admin/rotate-admin-key` → returns new key, old key rejected next call + +All must reject without `X-Admin-Key` header → 401. + +- [ ] **Step 2: Implement** + +Keep each route handler under 30 lines. The `revoke` handler calls the bus from Task 11 to close WebSockets. + +- [ ] **Step 3: Run green** + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(hub/admin): clients, audit, and settings REST endpoints + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 14 — Admin UI React SPA + +**Files:** +- Create: `hub/src/ui/admin/` (Vite + React + Tailwind project) +- Create: `hub/vite.admin.config.ts` +- Modify: `hub/src/app.ts` (serve built assets at `/admin`, guard with basic-auth middleware) + +- [ ] **Step 1: Scaffold minimal React app** + +Three pages (tabs): Clients, Audit, Settings. Uses `fetch` against `/api/admin/*` with prompted admin key stored in sessionStorage. + +- [ ] **Step 2: Wire static serving under basic-auth** + +```ts +// in app.ts +app.register(import('@fastify/static'), { + root: join(__dirname, '../ui/admin/dist'), prefix: '/admin/', decorateReply: false, + preHandler: createAdminBasicAuth(process.env.HUB_ADMIN_USER, process.env.HUB_ADMIN_PASSWORD_HASH), +}); +``` + +- [ ] **Step 3: Smoke test** + +Open `/admin/`, log in, list clients, revoke one, verify the row shows `revoked` status. + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(hub): admin UI — clients, audit, settings tabs + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 15 — Audit retention cron + +**Files:** +- Create: `hub/src/lib/audit-retention.ts` +- Modify: `hub/src/app.ts` + +- [ ] **Step 1: Test** + +Verify that calling `scheduleAuditRetention(db, 1)` then fast-forwarding 24h deletes rows older than 1 day. + +- [ ] **Step 2: Implement** + +```ts +// hub/src/lib/audit-retention.ts +import type { AuditRepo } from './audit-repo'; +import { appLogger } from './logger'; + +export function scheduleAuditRetention(audit: AuditRepo, retentionDays = 90): () => void { + const ageMs = retentionDays * 86400_000; + const run = () => { + const purged = audit.purgeOlderThan(ageMs); + if (purged > 0) appLogger.info(`[Audit] Purged ${purged} events older than ${retentionDays}d`); + }; + run(); + const id = setInterval(run, 6 * 3_600_000); + return () => clearInterval(id); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(hub): audit retention cron (configurable via HUB_AUDIT_RETENTION_DAYS) + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 16 — Client config-store: hubs[] shape + legacy migration + +**Files:** +- Modify: `src/main/features/hub/config-store.ts` +- Test: `src/main/features/hub/config-store.test.ts` + +- [ ] **Step 1: Failing test** + +Legacy input `{ hubUrl, encryptedApiKey, enabled, lastConnected }` → migrator produces `{ hubs: [{ hubId: 'legacy', displayName, lastKnownUrl, encryptedApiKey, pinnedFingerprint: null, dbPath: 'hubs/legacy/adc.db', clientIdentityRef: null, addedAt, lastConnectedAt }], activeHubId: 'legacy' }`. + +- [ ] **Step 2: Implement migration logic** + +Guard behind a `version` field in the config; bump to 2. + +- [ ] **Step 3: Run green** + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(main/hub): config-store schema v2 — hubs[] + legacy migrator" +``` + +--- + +## Task 17 — Per-hub DB resolver + legacy adc.db move + +**Files:** +- Modify: `src/main/lib/db.ts` +- Create: `src/main/features/hub/db-migrator.ts` +- Test: `src/main/features/hub/db-migrator.test.ts` + +- [ ] **Step 1: Test** that on app boot with legacy layout, `adc.db` is moved to `hubs/legacy/adc.db` once and only once. + +- [ ] **Step 2: Implement** the one-shot move, plus `getActiveDbPath(activeHubId)` that composes `${userData}/hubs/${hubId}/adc.db` creating directories as needed. + +- [ ] **Step 3: Run green** + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(main): per-hub adc.db resolver + legacy move + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 18 — Client Ed25519 identity (per hub) + +**Files:** +- Create: `src/main/features/hub/client-identity.ts` +- Test: `src/main/features/hub/client-identity.test.ts` + +- [ ] **Step 1: Failing test** + +`ensureClientIdentity(hubDir)` returns `{ clientId, publicKeyDer, signNonce(nonce: Buffer) => Buffer }` on first call; second call reads same keypair; `signNonce` produces Ed25519 signature verifiable with the returned pubkey. + +- [ ] **Step 2: Implement** + +Use `crypto.generateKeyPairSync('ed25519', { publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs8', format: 'der' } })`. Wrap private key with `safeStorage.encryptString`, store at `${hubDir}/client-identity.enc`. Public key plaintext at `${hubDir}/client-identity.pub`. `clientId = sha256(pub).slice(0, 32)` (16 bytes hex). + +- [ ] **Step 3: Run green** + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(main/hub): per-hub Ed25519 client identity via safeStorage + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 19 — mDNS discovery service + +**Files:** +- Create: `src/main/features/hub/hub-discovery.ts` +- Modify: `src/main/package.json` references (add `bonjour-service`) +- Test: `src/main/features/hub/hub-discovery.test.ts` + +- [ ] **Step 1: Install** + +```bash +npm install bonjour-service +``` + +- [ ] **Step 2: Failing test** + +Advertise a fake service via Bonjour from the test harness, assert `hub-discovery`'s `getSnapshot()` reports it within 3s with the expected TXT fields and stale=false. + +- [ ] **Step 3: Implement** + +```ts +// signature +export interface DiscoveredHub { hubId, displayName, version, channel, addresses, port, fingerprint, lastSeenAt, stale } +export interface HubDiscovery { + start: () => void; + stop: () => Promise<void>; + getSnapshot: () => DiscoveredHub[]; + on(event: 'changed', cb: (list: DiscoveredHub[]) => void): void; +} +``` + +Internal state: Map<hubId, DiscoveredHub>. 60s → mark stale; 5min → delete. Debounce `changed` events by 250 ms. Filter on `channel === resolveChannel()`. + +- [ ] **Step 4: Run green** + +- [ ] **Step 5: Commit** + +```bash +git commit -m "feat(main/hub): mdns discovery service with stale detection + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 20 — Network change watcher + +**Files:** +- Create: `src/main/features/hub/network-watcher.ts` +- Test: `src/main/features/hub/network-watcher.test.ts` + +- [ ] **Step 1: Failing test** + +Mock `os.networkInterfaces`, change the return value between ticks, assert the watcher fires exactly once per change. + +- [ ] **Step 2: Implement** + +```ts +export function createNetworkWatcher(onChange: () => void): () => void { + let lastHash = hashIfaces(); + const tick = () => { + const current = hashIfaces(); + if (current !== lastHash) { lastHash = current; onChange(); } + }; + const interval = setInterval(tick, 5000); + const { powerMonitor } = require('electron'); + const resumeHandler = () => tick(); + powerMonitor.on('resume', resumeHandler); + return () => { clearInterval(interval); powerMonitor.off('resume', resumeHandler); }; +} +``` + +Hook it up inside `hub-discovery.start()` to trigger a re-browse and Map clear. + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(main/hub): network-change watcher restarts discovery + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 21 — TLS fingerprint-pinning HTTPS agent + +**Files:** +- Create: `src/main/features/hub/fingerprint-agent.ts` +- Test: `src/main/features/hub/fingerprint-agent.test.ts` + +- [ ] **Step 1: Failing test** + +Spin up a local HTTPS server with a known cert, compute its fingerprint, construct `buildPinnedAgent(fingerprint)`, `https.request` through it → 200. Change the expected fingerprint and assert the request fails with `FingerprintMismatch`. + +- [ ] **Step 2: Implement** + +```ts +// src/main/features/hub/fingerprint-agent.ts +import https from 'node:https'; +import { createHash } from 'node:crypto'; + +export function buildPinnedAgent(expectedFingerprint: string): https.Agent { + return new https.Agent({ + rejectUnauthorized: false, // we do our own check + checkServerIdentity: (_host, cert) => { + const actual = createHash('sha256').update((cert.raw as Buffer)).digest('hex'); + if (actual !== expectedFingerprint.toLowerCase()) { + const err = new Error(`FingerprintMismatch: expected ${expectedFingerprint} got ${actual}`); + (err as any).code = 'FINGERPRINT_MISMATCH'; + return err; + } + return undefined; + }, + }); +} +``` + +- [ ] **Step 3: Wire into hub-client.ts** so all outbound HTTPS uses the pinned agent resolved from the active `HubRecord.pinnedFingerprint`. + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(main/hub): fingerprint-pinning https agent + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 22 — Client pair service (two-step init/confirm) + +**Files:** +- Create: `src/main/features/hub/hub-pair.ts` +- Test: `src/main/features/hub/hub-pair.test.ts` + +- [ ] **Step 1: Failing test** + +End-to-end against a test hub started in the same process (using `buildApp` from Task 8). `pairWithDiscoveredHub({ hubId, addresses, port, fingerprint })` → returns `{ key, clientId }`, the hub DB has a fresh `api_keys` row, the fingerprint is pinned in the resulting `HubRecord`. + +- [ ] **Step 2: Implement** + +```ts +export async function pairWithDiscoveredHub(input: { + hubId: string; addresses: string[]; port: number; fingerprint: string; displayName?: string; +}, deps: { hubsDir: string }) { + const hubDir = join(deps.hubsDir, input.hubId); + const identity = ensureClientIdentity(hubDir); + const baseUrl = `https://${input.addresses[0]}:${input.port}`; + const agent = buildPinnedAgent(input.fingerprint); + + const initRes = await fetchJson(`${baseUrl}/api/pair/init`, { + method: 'POST', agent, + body: { clientId: identity.clientId, clientPubKey: identity.publicKeyBase64url, displayName: input.displayName }, + }); + const signature = identity.signNonce(Buffer.from(initRes.nonce, 'base64url')).toString('base64url'); + const confirmRes = await fetchJson(`${baseUrl}/api/pair/confirm`, { + method: 'POST', agent, + body: { clientId: identity.clientId, nonce: initRes.nonce, signature, displayName: input.displayName }, + }); + return { hubId: confirmRes.hubId, displayName: confirmRes.displayName, key: confirmRes.key, clientId: identity.clientId }; +} +``` + +- [ ] **Step 3: Run green** + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(main/hub): hub-pair service orchestrating two-step pair flow + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 23 — Multi-hub `hub-connection.ts` refactor + switchActive + +**Files:** +- Modify: `src/main/features/hub/hub-connection.ts` +- Add: `src/main/features/hub/lifecycle.ts` (tiny event bus for `beforeActiveHubChange`) +- Test: `src/main/features/hub/hub-connection.multi.test.ts` + +- [ ] **Step 1: Failing test** + +Configure two hub records, set activeHubId to A, `switchActive('B')` → subscribers receive `beforeActiveHubChange` then `activeHubChanged`, `getConnection()` reflects B, config store `activeHubId` persists to B. + +- [ ] **Step 2: Implement** + +Turn `persistedConfig` into `persistedHubs[]` + `activeHubId`; move single-config methods (`configure`, `setEnabled`, `removeConfig`) to operate on the active record. Add `addHub(record)`, `removeHub(hubId)`, `setActive(hubId)`, subscription bus. Close current WS before swap; re-open after. + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(main/hub): hub-connection multi-hub with beforeActiveHubChange bus + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 24 — WS client revocation modal wiring + +**Files:** +- Modify: `src/main/features/hub/hub-ws-client.ts` +- Modify: `src/main/features/hub/hub-handlers.ts` (emit `HUB_EVENTS.REVOKED`) +- Test: `src/main/features/hub/hub-ws-revoke.test.ts` + +- [ ] **Step 1: Test** that a close event with code 4003 triggers an IPC event on `event:hub.revoked` carrying `{ hubId, reason }`. + +- [ ] **Step 2: Implement** + +```ts +ws.on('close', (code, reasonBuf) => { + if (code === 4003) { + let reason = 'Access revoked'; + try { reason = JSON.parse(reasonBuf.toString()).reason ?? reason; } catch {} + router.emit(HUB_EVENTS.REVOKED, { hubId: currentHubId, reason }); + } +}); +``` + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(main/hub): emit hub.revoked event on WebSocket close 4003 + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 25 — IPC handlers for the 5 new channels + +**Files:** +- Modify: `src/main/features/hub/hub-handlers.ts` +- Modify: `src/main/features/hub/service-registry.ts` / wiring +- Test: one test per handler (5 tests) + +Each handler is thin: parse the input via the Zod schema from Task 2, call the corresponding service function (`hub-discovery.getSnapshot`, `hub-pair.pairWithDiscoveredHub`, `hub-connection.setActive`, etc.), return result. + +- [ ] **Step 1: Failing tests** — one per handler. +- [ ] **Step 2: Implement**. +- [ ] **Step 3: Run green**. +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(ipc/hub): register 5 new channels + emit discovery/active/revoked events + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 26 — React Query hooks + +**Files:** +- Create: `src/renderer/features/hub/api/useHubDiscovery.ts`, `useHubPair.ts`, `useHubSwitchActive.ts`, `useHubRemoveRecord.ts`, `useHubManualPair.ts` +- Test: `src/renderer/features/hub/api/*.test.tsx` + +- [ ] **Step 1: Failing tests** — one per hook, using React Testing Library + a mock ipc client. + +- [ ] **Step 2: Implement** + +```ts +export function useHubDiscovery() { + const queryClient = useQueryClient(); + useEffect(() => { + const off = onIpcEvent(HUB_EVENTS.DISCOVERY.CHANGED, (snapshot) => { + queryClient.setQueryData(['hub', 'discovery'], snapshot); + }); + return off; + }, [queryClient]); + return useQuery({ queryKey: ['hub', 'discovery'], queryFn: () => ipc(HUB.DISCOVERED.LIST, {}) }); +} + +export function useHubPair() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { hubId: string; displayName?: string }) => ipc(HUB.PAIR.REQUEST, input), + onSuccess: () => qc.invalidateQueries({ queryKey: ['hub'] }), + }); +} +// …and the other three, similarly thin. +``` + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(renderer/hub): React Query hooks for discovery, pair, switch, remove, manual + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 27 — HubPickerPanel component + +**Files:** +- Create: `src/renderer/features/hub/components/HubPickerPanel/{HubPickerPanel.tsx, PairedRow.tsx, DiscoveredRow.tsx, ManualAdd.tsx, index.ts}` +- Test: `src/renderer/features/hub/components/HubPickerPanel/HubPickerPanel.test.tsx` + +- [ ] **Step 1: Test** + +Render picker with fixtures: 2 paired (one connected, one offline), 1 discovered, manual-add collapsed. Assert row states, ARIA roles, that clicking "Pair & Switch" fires the mutation, that keyboard arrow navigation moves the radio selection. + +- [ ] **Step 2: Implement** + +Use only `@ui` primitives (Button, Input, Label, etc.). Follow existing Feature Slice Design conventions. + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(renderer/hub): HubPickerPanel component + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 28 — HubSetupPage + HubSettings rewrite to use picker + +**Files:** +- Modify: `src/renderer/features/hub/components/HubSetupPage/HubSetupPage.tsx` +- Modify: `src/renderer/features/settings/components/HubSettings/HubSettings.tsx` + +Wrap `HubPickerPanel` in the appropriate frames. Delete the legacy URL+key form paths where no longer referenced. + +- [ ] **Step 1: Test** that `HubSetupPage` renders the picker plus a Skip button that creates the `local` HubRecord. +- [ ] **Step 2: Implement** +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(renderer): HubSetupPage + HubSettings now render HubPickerPanel + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 29 — RevocationModal + +**Files:** +- Create: `src/renderer/features/hub/components/RevocationModal/RevocationModal.tsx` + +Listens to `event:hub.revoked`, shows a modal using the app's existing `AlertDialog` @ui primitive with re-pair and switch-hub actions. + +- [ ] **Step 1: Test** +- [ ] **Step 2: Implement** +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(renderer/hub): revocation modal + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 30 — ENABLE_HUB_DISCOVERY flag + README update + +**Files:** +- Modify: `src/main/index.ts` +- Modify: `README.md` + +Gate `hub-discovery.start()` + the new renderer UI on `process.env.ENABLE_HUB_DISCOVERY !== 'false'`. Default on. + +Rewrite README install/pair section to describe the picker. Remove the manual-curl-secret dance. Mention the admin UI + the `scripts/hash-admin-password.mjs` helper. + +- [ ] **Step 1: Commit** + +```bash +git commit -m "feat(release): ENABLE_HUB_DISCOVERY flag + README rewrite + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 31 — Playwright E2E scenarios + +**File:** `tests/e2e/hub-discovery.spec.ts` + +Six scenarios as in the spec §Testing strategy. Each is its own `test()` block; shared fixture spins up a local hub and a test Electron main with ENABLE_HUB_DISCOVERY=true. + +- [ ] **Step 1: Implement and run all 6 green** +- [ ] **Step 2: Commit** + +```bash +git commit -m "test(e2e): 6 hub auto-discovery scenarios + +Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>" +``` + +--- + +## Task 32 — Release 0.2.0 + +- [ ] Bump `package.json` to 0.2.0 (both root and `hub/`) +- [ ] Run full verify: `npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e` +- [ ] Open PR to `production` +- [ ] Merge and let the release workflow publish 0.2.0 +- [ ] Install on Windows + Mac, verify the picker discovers the hub, verify pairing, verify admin UI works, verify revocation flow, verify legacy migrator on a copy of the user's existing `%APPDATA%/ADC/` directory + +--- + +## Execution notes for subagents + +- **Do not skip tests to make progress faster.** Every task's failing test must actually run red before implementation, and green after. +- **If a prior task's output is missing** (e.g., you're asked to do Task 23 and the service from Task 18 doesn't exist), stop and flag it — don't write stubs. +- **If the spec and plan disagree**, the spec wins. Call out the discrepancy in the task's commit message. +- **Lint + typecheck before commit.** No red PRs. +- **Commit once per task.** Do not combine tasks into a single commit. diff --git a/docs/superpowers/plans/2026-04-26-peers-audit-fixes.md b/docs/superpowers/plans/2026-04-26-peers-audit-fixes.md new file mode 100644 index 00000000..ab80bf05 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-peers-audit-fixes.md @@ -0,0 +1,1286 @@ +# Peers (Device-to-Device) Audit Fixes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Each task cites the audit findings it closes — full details in `tmp/audit/01-security.md` … `tmp/audit/05-renderer.md`. + +**Goal:** Close every Critical and High finding from the 5-stream audit of ADC's peer-to-peer (device-to-device) sync system, plus the highest-impact Medium items, without regressing the existing pair + TLS + sync end-to-end test (`tests/e2e` and integration tests). + +**Architecture:** ADC peers replaces the deleted Hub server with a per-device mDNS-discovered, TLS-pinned, PIN-paired WebSocket replication mesh. Files live under `src/main/features/peers/`, `src/renderer/features/peers/`, `src/shared/ipc/peers/`, and `src/shared/replication/`. Fixes land along Feature Slice Design layers (channels → contract → service → handlers → hooks → components). No layer reshuffling. + +**Tech Stack:** Electron 39, Node 20+ (`node:crypto`, `safeStorage`), better-sqlite3 + Drizzle, `ws@8`, `bonjour-service`, `@peculiar/x509`, React 19, TanStack Query, Zod. + +**Branch / worktree:** Run on a dedicated branch `feature/peers-audit-fixes` cut from `production`. The audit reports live at `tmp/audit/0[1-5]-*.md` — every implementer should read the relevant audit before starting. + +**Verification per task:** +- `npx tsc --noEmit` must pass +- `npx eslint <changed-files>` must pass +- Vitest for any unit tests added +- Existing peers integration test (`0ac1cb2e test(peers): end-to-end pair + TLS transport + sync`) must still pass + +--- + +## Task 0 — Branch + worktree setup + +**Files:** none (git only) + +- [ ] **Step 1: Create branch from production** + +```bash +git fetch origin +git checkout -b feature/peers-audit-fixes production +``` + +- [ ] **Step 2: Verify clean tree + baseline build** + +```bash +git status +npx tsc --noEmit +``` +Expected: clean working tree, typecheck passes. + +- [ ] **Step 3: Commit baseline marker** + +```bash +git commit --allow-empty -m "chore(peers): start audit-fix branch" +``` + +--- + +## Task 1 — PIN cryptographic generation + timing-safe compare + +Closes audit findings: **01/Critical** PIN via `Math.random()`; hand-rolled `constantTimeEq`; HMAC stored beside plaintext PIN. + +**Files:** +- Modify: `src/main/features/peers/peer-pairing.ts` (lines 49-50, 60-65, 73-79, 93-95) +- Test: `tests/unit/peers/peer-pairing.test.ts` (create if missing) + +- [ ] **Step 1: Write failing tests** + +```ts +// tests/unit/peers/peer-pairing.test.ts +import { describe, expect, it, vi } from 'vitest'; +import { createPeerPairing } from '@main/features/peers/peer-pairing'; + +describe('peer-pairing PIN generation', () => { + it('default pinRng draws 6 digits from crypto.randomInt range', () => { + const seen = new Set<string>(); + const p = createPeerPairing(); + for (let i = 0; i < 200; i++) { + const { pin } = p.initPair({ peerId: 'x', pubkey: 'y' }); + expect(pin).toMatch(/^\d{6}$/); + seen.add(pin); + } + // crypto.randomInt is uniform; in 200 draws we expect >150 distinct values + expect(seen.size).toBeGreaterThan(150); + }); + + it('uses constant-time compare on decoded HMAC bytes', () => { + const p = createPeerPairing(); + const init = p.initPair({ peerId: 'x', pubkey: 'y' }); + // Different-length HMAC should be rejected without throwing + const r = p.confirmPair(init.sessionId, 'short'); + expect(r.ok).toBe(false); + }); + + it('does not retain plaintext PIN in StoredSession after confirm', () => { + // Verified indirectly: PairConfirmResult never returns the PIN. + const p = createPeerPairing({ pinRng: () => '123456' }); + const init = p.initPair({ peerId: 'x', pubkey: 'y' }); + const hmac = p.computePinHmac('123456', init.challenge); + const r = p.confirmPair(init.sessionId, hmac); + expect(r.ok).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect failures** + +```bash +npx vitest run tests/unit/peers/peer-pairing.test.ts +``` + +- [ ] **Step 3: Implement fixes** + +Replace insecure default and `constantTimeEq` in `peer-pairing.ts`: + +```ts +import { createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto'; + +// inside createPeerPairing(): +const pinRng = + opts.pinRng ?? (() => randomInt(0, 1_000_000).toString().padStart(6, '0')); + +function constantTimeEq(aB64: string, bB64: string): boolean { + const a = Buffer.from(aB64, 'base64'); + const b = Buffer.from(bB64, 'base64'); + if (a.length !== b.length || a.length === 0) return false; + return timingSafeEqual(a, b); +} +``` + +Drop `pin` from `StoredSession` (only `expectedHmac` is needed for verification): + +```ts +interface StoredSession { + challenge: string; + expectedHmac: string; + initiator: PairInitArgs; + createdAt: number; + attemptsRemaining: number; +} +// initPair: do not assign session.pin; return pin only in PairInitResult +``` + +- [ ] **Step 4: Run tests — expect pass** + +```bash +npx vitest run tests/unit/peers/peer-pairing.test.ts +npx tsc --noEmit +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/main/features/peers/peer-pairing.ts tests/unit/peers/peer-pairing.test.ts +git commit -m "fix(peers): use crypto.randomInt for PIN, timingSafeEqual on decoded HMAC, drop plaintext PIN from session" +``` + +--- + +## Task 2 — Pair-server hardening (rate limit, timeouts, session GC, body limits) + +Closes audit findings: **01/Critical** unauthenticated PIN-prompt spam; unbounded sessions Map; **01/High** missing `headersTimeout`/`requestTimeout`; **01/High** body slowloris. + +**Files:** +- Modify: `src/main/features/peers/pair-server.ts` (route handler, server creation, body reader) +- Modify: `src/main/features/peers/peer-pairing.ts` (add periodic sweep / sweep-on-insert) +- Add: `src/main/features/peers/rate-limiter.ts` (token bucket per IP) +- Test: `tests/unit/peers/rate-limiter.test.ts` +- Test: `tests/unit/peers/peer-pairing-sweep.test.ts` + +- [ ] **Step 1: Write failing tests** + +```ts +// tests/unit/peers/rate-limiter.test.ts +import { describe, expect, it, vi } from 'vitest'; +import { createIpRateLimiter } from '@main/features/peers/rate-limiter'; + +describe('rate limiter', () => { + it('allows up to N requests per window then rejects', () => { + let now = 0; + const lim = createIpRateLimiter({ capacity: 3, refillPerMs: 0, now: () => now }); + expect(lim.consume('1.2.3.4')).toBe(true); + expect(lim.consume('1.2.3.4')).toBe(true); + expect(lim.consume('1.2.3.4')).toBe(true); + expect(lim.consume('1.2.3.4')).toBe(false); + expect(lim.consume('5.6.7.8')).toBe(true); + }); + + it('refills tokens over time', () => { + let now = 0; + const lim = createIpRateLimiter({ capacity: 1, refillPerMs: 1 / 1000, now: () => now }); + expect(lim.consume('x')).toBe(true); + expect(lim.consume('x')).toBe(false); + now = 1000; + expect(lim.consume('x')).toBe(true); + }); +}); +``` + +```ts +// tests/unit/peers/peer-pairing-sweep.test.ts +import { describe, expect, it } from 'vitest'; +import { createPeerPairing } from '@main/features/peers/peer-pairing'; + +describe('peer-pairing session sweep', () => { + it('drops expired sessions on insert when over soft-limit', () => { + let t = 0; + const p = createPeerPairing({ now: () => t, sessionTtlMs: 1000, maxActiveSessions: 2 }); + p.initPair({ peerId: 'a', pubkey: '' }); + p.initPair({ peerId: 'b', pubkey: '' }); + t = 5000; // both expired + p.initPair({ peerId: 'c', pubkey: '' }); + expect(p.activeSessionCount()).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect failures** + +```bash +npx vitest run tests/unit/peers/rate-limiter.test.ts tests/unit/peers/peer-pairing-sweep.test.ts +``` + +- [ ] **Step 3: Implement** + +Create `src/main/features/peers/rate-limiter.ts`: + +```ts +export interface IpRateLimiter { + consume(ip: string): boolean; +} +export interface IpRateLimiterOpts { + capacity: number; // max tokens per ip + refillPerMs: number; // tokens added per ms + now?: () => number; +} +export function createIpRateLimiter(opts: IpRateLimiterOpts): IpRateLimiter { + const now = opts.now ?? Date.now; + const buckets = new Map<string, { tokens: number; lastMs: number }>(); + return { + consume(ip: string): boolean { + const cur = buckets.get(ip); + const t = now(); + if (!cur) { + buckets.set(ip, { tokens: opts.capacity - 1, lastMs: t }); + return true; + } + const refilled = Math.min(opts.capacity, cur.tokens + (t - cur.lastMs) * opts.refillPerMs); + if (refilled < 1) { + cur.tokens = refilled; + cur.lastMs = t; + return false; + } + cur.tokens = refilled - 1; + cur.lastMs = t; + return true; + }, + }; +} +``` + +In `peer-pairing.ts`, add a `maxActiveSessions` opt (default 100) and a sweep helper called from `initPair`. Expose `activeSessionCount()` for tests. + +In `pair-server.ts`, add per-IP rate limiting around `/pair/init` and `/pair/confirm` (capacity 5, refill 1/min) using `req.socket.remoteAddress`. Reject with 429. + +Add server timeouts: + +```ts +const httpsServer = https.createServer({ cert, key, minVersion: 'TLSv1.2' }, handler); +httpsServer.headersTimeout = 10_000; +httpsServer.requestTimeout = 15_000; +httpsServer.keepAliveTimeout = 5_000; +``` + +In the body-reader, add `req.setTimeout(5_000, () => req.destroy())` before reading. + +- [ ] **Step 4: Run tests — expect pass + integration test still green** + +```bash +npx vitest run tests/unit/peers/ +npx tsc --noEmit +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/main/features/peers/rate-limiter.ts src/main/features/peers/pair-server.ts src/main/features/peers/peer-pairing.ts tests/unit/peers/rate-limiter.test.ts tests/unit/peers/peer-pairing-sweep.test.ts +git commit -m "fix(peers): per-IP rate limit pair endpoints, sweep expired sessions, set https timeouts" +``` + +--- + +## Task 3 — Identity file safety + +Closes audit findings: **01/Critical** plaintext key fallback; identity file mode default. + +**Files:** +- Modify: `src/main/features/peers/peer-identity.ts` (lines 39-50; add policy + 0o600) +- Add: `src/main/features/peers/peer-identity-policy.ts` (small enum + warning logger) +- Test: `tests/unit/peers/peer-identity.test.ts` + +- [ ] **Step 1: Write failing test** + +```ts +// tests/unit/peers/peer-identity.test.ts +import { describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, statSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: () => Buffer.from(''), + decryptString: () => '', + }, +})); + +import { getOrCreatePeerIdentity } from '@main/features/peers/peer-identity'; + +describe('peer-identity', () => { + it('refuses to write plaintext key without explicit allowPlaintext', () => { + const dir = mkdtempSync(join(tmpdir(), 'pid-')); + expect(() => getOrCreatePeerIdentity(dir)).toThrow(/safeStorage/); + }); + + it('writes file with mode 0o600 when allowPlaintext=true', () => { + if (process.platform === 'win32') return; // skip mode check on Windows + const dir = mkdtempSync(join(tmpdir(), 'pid-')); + getOrCreatePeerIdentity(dir, { allowPlaintext: true }); + const mode = statSync(join(dir, 'peer-identity.json')).mode & 0o777; + expect(mode).toBe(0o600); + }); +}); +``` + +- [ ] **Step 2: Run test — expect failure** + +- [ ] **Step 3: Implement** + +```ts +export interface IdentityOpts { allowPlaintext?: boolean } + +export function getOrCreatePeerIdentity(dataDir: string, opts: IdentityOpts = {}): PeerIdentity { + // ... existing read path ... + const canEncrypt = safeStorage.isEncryptionAvailable(); + if (!canEncrypt && !opts.allowPlaintext) { + throw new Error( + 'peer-identity: safeStorage unavailable. Set ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY=1 to opt in.' + ); + } + if (!canEncrypt) { + serviceLogger.warn('peer-identity: safeStorage unavailable — writing plaintext private key (opt-in)'); + } + // ... + writeFileSync(path, JSON.stringify(stored, null, 2), { mode: 0o600, encoding: 'utf8' }); +} +``` + +Wire `opts.allowPlaintext` from env in `peers-service.ts`: + +```ts +const identity = getOrCreatePeerIdentity(dataDir, { + allowPlaintext: process.env.ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY === '1', +}); +``` + +Also drop the exported `privkey` field from `PeerIdentity` (audit Low: minimize secret surface). Replace usages with `sign`. + +- [ ] **Step 4: Run tests + typecheck** + +- [ ] **Step 5: Commit** + +```bash +git add src/main/features/peers/peer-identity.ts src/main/features/peers/peers-service.ts tests/unit/peers/peer-identity.test.ts +git commit -m "fix(peers): require safeStorage by default, 0o600 identity file, drop privkey from PeerIdentity surface" +``` + +--- + +## Task 4 — TLS pin via `checkServerIdentity` on both client paths + +Closes audit findings: **01/High** post-handshake pinning in `peers-service.ts:99`; **02/Critical (C2)** post-handshake pinning in `ws-transport.ts:174-191`; **02/Medium** repeat HttpsAgent allocation. + +**Files:** +- Modify: `src/main/features/peers/peers-service.ts` (postJson) +- Modify: `src/main/features/peers/ws-transport.ts` (dial) +- Add: `src/main/features/peers/peer-tls-pin.ts` (shared `pinnedTlsOptions(fp)` helper) +- Test: `tests/unit/peers/peer-tls-pin.test.ts` + +- [ ] **Step 1: Write failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { pinnedCheckServerIdentity } from '@main/features/peers/peer-tls-pin'; + +describe('pinnedCheckServerIdentity', () => { + it('returns Error when fingerprint mismatches', () => { + const fakeCert = { raw: Buffer.from('hello') } as unknown as Parameters<ReturnType<typeof pinnedCheckServerIdentity>>[1]; + const check = pinnedCheckServerIdentity('not-the-real-fp'); + const err = check('host', fakeCert); + expect(err).toBeInstanceOf(Error); + }); + it('returns undefined on match', () => { + // hex-sha256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + const fakeCert = { raw: Buffer.from('hello') } as unknown as Parameters<ReturnType<typeof pinnedCheckServerIdentity>>[1]; + const check = pinnedCheckServerIdentity('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'); + expect(check('host', fakeCert)).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +`peer-tls-pin.ts`: + +```ts +import { createHash } from 'node:crypto'; +import type { PeerCertificate } from 'node:tls'; + +export function pinnedCheckServerIdentity(expectedFingerprintHex: string) { + return (_host: string, cert: PeerCertificate): Error | undefined => { + const fp = createHash('sha256').update(cert.raw).digest('hex'); + return fp === expectedFingerprintHex ? undefined : new Error('peer fingerprint mismatch'); + }; +} +``` + +In `peers-service.ts::postJson`, replace the manual post-`'end'` check with TLS-time enforcement and reuse a single Agent per call (still acceptable for one-shot but with `rejectUnauthorized: true`): + +```ts +const agent = new HttpsAgent({ + rejectUnauthorized: true, + checkServerIdentity: pinnedCheckServerIdentity(expectedFingerprint), +}); +``` + +Drop the `cert.raw` post-handshake hash + `agent.destroy()` dance. + +In `ws-transport.ts::dial`, pass `checkServerIdentity` directly to the `WebSocket` ctor: + +```ts +const ws = new WebSocket(url, { + rejectUnauthorized: true, + checkServerIdentity: pinnedCheckServerIdentity(remotePeer.fingerprint), +}); +``` + +Remove the `(ws as any)._socket.getPeerCertificate(true)` block. + +- [ ] **Step 4: Run unit tests + e2e pair smoke** + +```bash +npx vitest run tests/unit/peers/peer-tls-pin.test.ts +npx tsc --noEmit +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/main/features/peers/peer-tls-pin.ts src/main/features/peers/peers-service.ts src/main/features/peers/ws-transport.ts tests/unit/peers/peer-tls-pin.test.ts +git commit -m "fix(peers): enforce TLS fingerprint via checkServerIdentity on both client paths" +``` + +--- + +## Task 5 — Outbound dialer state machine + +Closes audit findings: **02/Critical (C1, C5)** unbounded reconnect, no backoff/jitter/cap, leaked timers, permanent-fail not surfaced. + +**Files:** +- Add: `src/main/features/peers/outbound-dialer.ts` (the state machine) +- Modify: `src/main/features/peers/ws-transport.ts` (replace inline reconnect) +- Test: `tests/unit/peers/outbound-dialer.test.ts` + +- [ ] **Step 1: Write failing test** + +```ts +// states: idle | connecting | open | backoff | permanently_failed | closed +import { describe, it, expect, vi } from 'vitest'; +import { createOutboundDialer } from '@main/features/peers/outbound-dialer'; + +describe('OutboundDialer', () => { + it('exponential backoff with jitter, capped at maxBackoffMs', () => { + const calls: number[] = []; + const fakeTimers = vi.useFakeTimers(); + const d = createOutboundDialer({ + attemptDial: () => Promise.resolve('FAIL' as const), + baseMs: 100, + maxBackoffMs: 1000, + jitterRatio: 0, + onState: (_s) => {}, + }); + d.start(); + // ... fast-forward and capture delays via mock + fakeTimers.useRealTimers(); + }); + + it('close() cancels pending timer', () => { /* ... */ }); + it('FINGERPRINT_MISMATCH transitions to permanently_failed and stops retrying', () => { /* ... */ }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +```ts +// outbound-dialer.ts +type DialResult = 'OK' | 'FAIL' | 'PERMANENT_FAIL'; +export type DialerState = 'idle' | 'connecting' | 'open' | 'backoff' | 'permanently_failed' | 'closed'; + +export interface OutboundDialerOpts { + attemptDial: () => Promise<DialResult>; + baseMs?: number; // default 500 + maxBackoffMs?: number; // default 30_000 + jitterRatio?: number; // default 0.25 + onState?: (s: DialerState) => void; + now?: () => number; +} +export interface OutboundDialer { + start(): void; + close(): void; + state(): DialerState; +} + +export function createOutboundDialer(opts: OutboundDialerOpts): OutboundDialer { + let state: DialerState = 'idle'; + let attempts = 0; + let pending: ReturnType<typeof setTimeout> | null = null; + const baseMs = opts.baseMs ?? 500; + const maxBackoffMs = opts.maxBackoffMs ?? 30_000; + const jitter = opts.jitterRatio ?? 0.25; + + function setState(s: DialerState) { state = s; opts.onState?.(s); } + + async function tryOnce() { + if (state === 'closed' || state === 'permanently_failed') return; + setState('connecting'); + const result = await opts.attemptDial(); + if (state === 'closed') return; + if (result === 'OK') { attempts = 0; setState('open'); return; } + if (result === 'PERMANENT_FAIL') { setState('permanently_failed'); return; } + attempts += 1; + const expo = Math.min(maxBackoffMs, baseMs * 2 ** (attempts - 1)); + const j = expo * jitter * (Math.random() * 2 - 1); + const delay = Math.max(0, Math.floor(expo + j)); + setState('backoff'); + pending = setTimeout(() => { pending = null; void tryOnce(); }, delay); + } + + return { + start() { if (state === 'idle') void tryOnce(); }, + close() { + setState('closed'); + if (pending) { clearTimeout(pending); pending = null; } + }, + state() { return state; }, + }; +} +``` + +In `ws-transport.ts`, replace the inline `setTimeout(dial, 1000)` block with a `createOutboundDialer` instance whose `attemptDial` resolves `'OK' | 'FAIL' | 'PERMANENT_FAIL'`. Fingerprint mismatch → `'PERMANENT_FAIL'`. Hook up `outboundDialer.close()` from `wsTransport.close()`. + +- [ ] **Step 4: Run tests** + +- [ ] **Step 5: Commit** + +```bash +git add src/main/features/peers/outbound-dialer.ts src/main/features/peers/ws-transport.ts tests/unit/peers/outbound-dialer.test.ts +git commit -m "fix(peers): outbound dialer state machine with backoff, jitter, cap, and permanent-fail on fingerprint mismatch" +``` + +--- + +## Task 6 — WSS error listeners + inbound peer auth + connection bound + +Closes audit findings: **02/Critical (C3)** missing `'error'` listeners on `wss` and accepted sockets; **02/Medium (M5)** unbounded `incomingSockets`, no inbound auth. + +**Files:** +- Modify: `src/main/features/peers/ws-transport.ts` +- Modify: `src/main/features/peers/peer-server.ts` (pass `peerStore` in) +- Test: `tests/integration/peers/ws-transport-inbound.test.ts` + +- [ ] **Step 1: Write failing test** + +Test that a WSS client whose TLS cert fingerprint is not in `peerStore` is closed with code `4004`. + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +In `peer-server.ts`, accept `peerStore` from caller (instead of constructing internally — also closes audit M6). + +In `ws-transport.ts`: + +```ts +wss.on('error', (err) => serviceLogger.error({ err }, 'peers.wsTransport.wss error')); + +wss.on('connection', (ws, req) => { + if (incomingSockets.size >= MAX_INBOUND_SOCKETS) { ws.close(1013, 'busy'); return; } + const tlsSocket = req.socket as import('node:tls').TLSSocket; + const cert = tlsSocket.getPeerCertificate?.(true); + // inbound is server-cert (we present); for mutual auth use detached cert mode in future + // Today: validate that the connecting peer's IP corresponds to a known/paired peer fingerprint via separate handshake — at minimum log + bound: + ws.on('error', (err) => serviceLogger.warn({ err }, 'peers.wsTransport.incoming error')); + // existing message handler... +}); +``` + +Add `MAX_INBOUND_SOCKETS = 64` to `peer-constants.ts` (Task 16). + +NOTE on inbound auth: ADC currently presents a server cert, not mutual TLS. The audit-recommended `getPeerCertificate(true)` returns nothing useful in single-sided TLS. Implement *handshake-time* peer auth via the HELLO frame: include `peerId` + a signed nonce, verify against `peerStore.getByPeerId(peerId).pubkey`. Reject with `4004 'untrusted'` on failure. This is the right primitive given the Ed25519 identity already exists. + +- [ ] **Step 4: Run tests + typecheck** + +- [ ] **Step 5: Commit** + +```bash +git add src/main/features/peers/ws-transport.ts src/main/features/peers/peer-server.ts src/main/features/peers/peer-constants.ts tests/integration/peers/ws-transport-inbound.test.ts +git commit -m "fix(peers): wss error listeners, inbound peer auth via HELLO+sig, bound incoming sockets" +``` + +--- + +## Task 7 — Wire-frame Zod schema + +Closes audit findings: **02/High (H1)** `JSON.parse(raw) as WireFrame` cast. + +**Files:** +- Add: `src/main/features/peers/wire-schema.ts` +- Modify: `src/main/features/peers/ws-transport.ts` (handleFrame) +- Test: `tests/unit/peers/wire-schema.test.ts` + +- [ ] **Step 1: Write failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { parseWireFrame } from '@main/features/peers/wire-schema'; + +describe('wire-schema', () => { + it('rejects unknown frame types', () => { + expect(parseWireFrame('{"type":"NOPE"}').ok).toBe(false); + }); + it('accepts well-formed HELLO', () => { + const r = parseWireFrame(JSON.stringify({ type: 'HELLO', peerId: 'x', schemaHash: 'y', sig: 'z', nonce: 'n' })); + expect(r.ok).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +```ts +// wire-schema.ts +import { z } from 'zod'; +export const HelloFrameSchema = z.object({ + type: z.literal('HELLO'), + peerId: z.string().min(1).max(128), + schemaHash: z.string().min(1).max(128), + nonce: z.string().min(1).max(256), + sig: z.string().min(1).max(256), +}); +export const OpsFrameSchema = z.object({ + type: z.literal('OPS'), + ops: z.array(z.unknown()).max(1000), +}); +export const PingFrameSchema = z.object({ type: z.literal('PING') }); +export const WireFrameSchema = z.discriminatedUnion('type', [HelloFrameSchema, OpsFrameSchema, PingFrameSchema]); +export type WireFrame = z.infer<typeof WireFrameSchema>; +export function parseWireFrame(raw: string): + | { ok: true; frame: WireFrame } + | { ok: false; error: string } { + let json: unknown; + try { json = JSON.parse(raw); } catch (err) { return { ok: false, error: 'invalid json' }; } + const r = WireFrameSchema.safeParse(json); + return r.success ? { ok: true, frame: r.data } : { ok: false, error: r.error.message }; +} +``` + +In `ws-transport.ts::handleFrame`, replace `JSON.parse(raw) as WireFrame` with `parseWireFrame(raw)`. On failure, `ws.close(4003, 'malformed frame')`. + +- [ ] **Step 4: Run + typecheck** + +- [ ] **Step 5: Commit** + +```bash +git add src/main/features/peers/wire-schema.ts src/main/features/peers/ws-transport.ts tests/unit/peers/wire-schema.test.ts +git commit -m "fix(peers): Zod-validate WireFrame; reject malformed inputs at trust boundary" +``` + +--- + +## Task 8 — HLC monotonicity + last_seen_hlc + GC frontier fix + +Closes audit findings: **03/Critical (C5, C6, C4)**. + +**Files:** +- Modify: `src/main/features/peers/replication-engine.ts` (init `lastHlc` from DB) +- Modify: `src/main/features/peers/peer-store.ts` or `peer-state-schema.ts` (add `recordObserved(peerId, hlc)`) +- Modify: `src/main/features/peers/replication-engine.ts::applyRemoteOp` (call `recordObserved`) +- Modify: `src/main/features/peers/peers-service.ts::computeGcWatermark` (strip peerIdShort suffix) +- Modify: `src/shared/replication/hlc.ts` (`WALL_PAD = 13`) +- Test: `tests/unit/peers/hlc.test.ts`, `tests/unit/peers/op-log-gc.test.ts` + +- [ ] **Step 1: Write failing tests** + +```ts +// tests/unit/peers/hlc.test.ts +import { describe, it, expect } from 'vitest'; +import { formatHlc, parseHlc, compareHlc } from '@shared/replication/hlc'; + +describe('hlc', () => { + it('parse(format(x)) round-trips for current epoch ms', () => { + const x = { wallClockMs: Date.now(), counter: 1, peerIdShort: 'aaaa1111' }; + expect(parseHlc(formatHlc(x))).toEqual(x); + }); +}); +``` + +```ts +// tests/unit/peers/op-log-gc.test.ts +// pseudo: insert ops from peers A and B, mark both as seen via recordObserved, +// verify computeGcWatermark equals min wall.counter prefix (suffix stripped) and gc deletes correctly. +``` + +```ts +// tests/unit/peers/engine-monotonicity.test.ts +// recordLocalWrite once, then re-create engine (simulating restart), recordLocalWrite again, +// expect new HLC > previous one even when wall-clock is mocked backward. +``` + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +`hlc.ts`: `WALL_PAD = 13` (sufficient through year 5138). Document why. + +`replication-engine.ts`: + +```ts +// at engine init: +const seedRow = client.prepare('SELECT MAX(hlc) AS m FROM op_log').get() as { m?: string }; +let lastHlc: HlcParts | null = seedRow?.m ? parseHlc(seedRow.m) : null; +``` + +`peer-state-schema.ts` (or store): add `recordObserved(peerId: string, hlc: string)` that does an upsert taking `MAX(last_seen_hlc, hlc)`. + +In `applyRemoteOp`'s transaction, call `peerStore.recordObserved(op.originPeerId, op.hlc)` before commit. + +`peers-service.ts::computeGcWatermark`: when comparing `lastSeenHlc`, strip the `peerIdShort` suffix: + +```ts +function hlcPrefix(h: string): string { + // wall(13).counter(8).peerIdShort(8) — strip last segment + return h.replace(/\.[A-Fa-f0-9]+$/, ''); +} +// ... reduce by min(hlcPrefix(seen)) ... +``` + +- [ ] **Step 4: Run tests + integration** + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/replication/hlc.ts src/main/features/peers/replication-engine.ts src/main/features/peers/peer-state-schema.ts src/main/features/peers/peer-store.ts src/main/features/peers/peers-service.ts tests/unit/peers/hlc.test.ts tests/unit/peers/op-log-gc.test.ts tests/unit/peers/engine-monotonicity.test.ts +git commit -m "fix(replication): persist last_seen_hlc, seed lastHlc from op_log, strip peerIdShort from GC frontier" +``` + +--- + +## Task 9 — Op-log dedup-first + indexes + column allowlist + +Closes audit findings: **03/Critical (C3)** dedup happens after merge; **03/Critical (C7)** schema-hash drift; **03/High (H2, H8, H9)**. + +**Files:** +- Modify: `src/main/features/peers/replication-engine.ts::applyRemoteOp` (dedup top-of-tx) +- Modify: `src/main/features/peers/schema.ts` (index on hlc) +- Modify: `src/shared/replication/sync-tables.ts` (single defs map) +- Modify: `src/main/features/peers/replication-engine.ts` (column allowlist) +- Add: drizzle migration `0029_op_log_hlc_index.sql` +- Test: `tests/unit/peers/dedup.test.ts`, `tests/unit/peers/column-allowlist.test.ts` + +- [ ] **Step 1: Write failing tests** + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +`schema.ts`: add `index('op_log_by_hlc').on(t.hlc)`. + +`sync-tables.ts`: collapse to one def map and derive arrays: + +```ts +export const SYNC_TABLE_DEFS = { + notes: { pk: 'id', columns: new Set<string>(['id', 'title', 'body', 'updated_at']) }, + ideas: { pk: 'id', columns: new Set<string>(['id', 'title', 'body', 'priority', 'updated_at']) }, + progress_tasks: { pk: 'slug', columns: new Set<string>(['slug', 'title', 'state', 'updated_at']) }, + // ... +} as const; +export type SyncTable = keyof typeof SYNC_TABLE_DEFS; +export const SYNC_TABLES = Object.keys(SYNC_TABLE_DEFS) as readonly SyncTable[]; +export const SYNC_TABLE_PK: Record<SyncTable, string> = + Object.fromEntries(Object.entries(SYNC_TABLE_DEFS).map(([k, v]) => [k, v.pk])) as never; +``` + +In `applyRemoteOp`, dedup at top of tx: + +```ts +const inserted = client + .prepare('INSERT INTO op_log (...) VALUES (...) ON CONFLICT (origin_peer_id, hlc) DO NOTHING') + .run(...); +if (inserted.changes === 0) return; // duplicate; skip merge work +// ... existing merge body ... +``` + +In `applyColumnsToUserTable`, validate columns against `SYNC_TABLE_DEFS[table].columns`: + +```ts +for (const col of Object.keys(columns)) { + if (!SYNC_TABLE_DEFS[table].columns.has(col)) { + throw new Error(`peers: column ${col} not in allowlist for ${table}`); + } +} +``` + +Add migration `drizzle/0029_op_log_hlc_index.sql`: + +```sql +CREATE INDEX IF NOT EXISTS op_log_by_hlc ON op_log(hlc); +``` + +- [ ] **Step 4: Run tests** + +- [ ] **Step 5: Commit** + +```bash +git add src/main/features/peers/replication-engine.ts src/main/features/peers/schema.ts src/shared/replication/sync-tables.ts drizzle/0029_op_log_hlc_index.sql drizzle/meta/_journal.json tests/unit/peers/dedup.test.ts tests/unit/peers/column-allowlist.test.ts +git commit -m "fix(replication): dedup op_log first, add hlc index, column allowlist per sync table" +``` + +--- + +## Task 10 — Awaitable peers bootstrap + dispose + +Closes audit findings: **04/Critical (C1, C2)** async-IIFE race + dispose race. + +**Files:** +- Modify: `src/main/bootstrap/service-registry.ts` (lines 461-492) +- Modify: `src/main/ipc/index.ts` (call site) +- Test: `tests/integration/peers/bootstrap-race.test.ts` + +- [ ] **Step 1: Write failing test** + +Race-style: dispose during init must `await` the in-flight bootstrap; calling a handler before init must queue or throw a typed `PEERS_NOT_READY` rather than the raw "not yet initialized" string. + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +Convert registry to expose a `Promise<PeersService>` rather than a "lazy throws" stub. Two mechanically-clean options — pick the awaitable path: + +```ts +// service-registry.ts +const peersServicePromise: Promise<PeersService> = createPeersService({ ... }); +let disposed = false; + +const peersServiceLazy: PeersService = new Proxy({} as PeersService, { + get(_t, prop) { + return (...args: unknown[]) => peersServicePromise.then((s) => (s as any)[prop](...args)); + }, +}); + +async function disposePeerTransport() { + disposed = true; + try { (await peersServicePromise).dispose(); } catch (err) { logger.warn({ err }, 'peers dispose'); } +} +``` + +(Document the all-methods-return-promise change; update handler thunks to `await` accordingly. Or alternative: refactor `index.ts` to await registry init before registering peers handlers.) + +- [ ] **Step 4: Run tests + e2e smoke** + +- [ ] **Step 5: Commit** + +```bash +git add src/main/bootstrap/service-registry.ts src/main/ipc/index.ts src/main/features/peers/peers-handlers.ts tests/integration/peers/bootstrap-race.test.ts +git commit -m "fix(peers): awaitable bootstrap + dispose; eliminate IIFE race" +``` + +--- + +## Task 11 — Validated handle helper + host hardening + dedupe contract types + +Closes audit findings: **04/High (H1, H2, H3, H4, H5)**. + +**Files:** +- Add: `src/main/features/peers/validated-handle.ts` +- Modify: `src/main/features/peers/peers-handlers.ts` (use helper) +- Modify: `src/shared/ipc/peers/contract.ts` (host regex; consolidate displayName; export `PairedPeer` type) +- Modify: `src/main/features/peers/peer-store.ts` (import `PairedPeer` from contract) +- Test: `tests/unit/peers/validated-handle.test.ts` + +- [ ] **Step 1: Write failing test** + +```ts +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { validatedHandle } from '@main/features/peers/validated-handle'; + +describe('validatedHandle', () => { + const channel = 'x.y.z' as const; + const map = { + [channel]: { + input: z.object({ a: z.number() }), + output: z.object({ b: z.string() }), + }, + } as const; + it('throws on bad input', async () => { + const fn = validatedHandle(map, channel, async () => ({ b: 'ok' })); + await expect(fn({ a: 'no' })).rejects.toThrow(); + }); + it('throws (in dev) on bad output', async () => { + const fn = validatedHandle(map, channel, async () => ({ b: 5 } as never)); + await expect(fn({ a: 1 })).rejects.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +```ts +// validated-handle.ts +import type { z } from 'zod'; +type SchemaMap = Record<string, { input: z.ZodTypeAny; output: z.ZodTypeAny }>; +export function validatedHandle<M extends SchemaMap, C extends keyof M & string>( + map: M, + channel: C, + fn: (input: z.infer<M[C]['input']>) => Promise<z.infer<M[C]['output']>>, +): (raw: unknown) => Promise<z.infer<M[C]['output']>> { + const { input, output } = map[channel]; + return async (raw) => { + const parsedIn = input.parse(raw); + const result = await fn(parsedIn); + if (process.env.NODE_ENV !== 'production') output.parse(result); + return result; + }; +} +``` + +In `contract.ts`: + +```ts +const HOST_RE = /^[A-Za-z0-9.\-:%\[\]]+$/; +const HostnameSchema = z.string().min(1).max(255).regex(HOST_RE); +// every place using z.string() for host -> HostnameSchema +// every displayName: z.string().nullable() (no .optional()) +export type PairedPeer = z.infer<typeof PairedPeerSchema>; +``` + +In `peer-store.ts`: + +```ts +import { type PairedPeer } from '@shared/ipc/peers'; +// drop local PairedPeer interface +``` + +In `peers-handlers.ts` rewrite to use `validatedHandle(peersInvoke, PEERS.PAIR.INIT, async (input) => service.pairInit(input))` etc. + +- [ ] **Step 4: Run + typecheck** + +- [ ] **Step 5: Commit** + +```bash +git add src/main/features/peers/validated-handle.ts src/main/features/peers/peers-handlers.ts src/shared/ipc/peers/contract.ts src/main/features/peers/peer-store.ts tests/unit/peers/validated-handle.test.ts +git commit -m "fix(peers): centralized validated-handle, HostnameSchema, single PairedPeer source-of-truth" +``` + +--- + +## Task 12 — Extract `postJsonPinned`, `safeFanOut`, `runGcTick` + +Closes audit findings: **04/Medium (M2, M3, M4)**. + +**Files:** +- Add: `src/main/features/peers/peer-http.ts` (extract postJson) +- Modify: `src/main/features/peers/peers-service.ts` (use helpers) +- Test: `tests/unit/peers/peer-http.test.ts` + +- [ ] **Step 1: Write failing test** + +`peer-http.test.ts` covers fingerprint-mismatch rejection + happy path against a local pinned server. + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +Move the 70-line `postJson` from `peers-service.ts:93-161` into `peer-http.ts::postJsonPinned(url, fingerprint, body)`. Use `pinnedCheckServerIdentity` from Task 4. + +In `peers-service.ts`: + +```ts +function safeFanOut<T>(handlers: Set<(t: T) => void>, value: T, label: string) { + for (const h of handlers) { + try { h(value); } catch (err) { serviceLogger.warn({ err }, `peers.${label} handler error`); } + } +} + +function runGcTick() { /* the body that was duplicated in initial-call + interval */ } +const initialGc = setTimeout(() => runGcTick(), 0); +initialGc.unref(); +const gcInterval = setInterval(runGcTick, GC_INTERVAL_MS); +gcInterval.unref(); +``` + +- [ ] **Step 4: Run + typecheck** + +- [ ] **Step 5: Commit** + +```bash +git add src/main/features/peers/peer-http.ts src/main/features/peers/peers-service.ts tests/unit/peers/peer-http.test.ts +git commit -m "refactor(peers): extract postJsonPinned, safeFanOut, runGcTick helpers" +``` + +--- + +## Task 13 — Renderer event/key wiring (EventBridge + useIpcEvent) + +Closes audit findings: **05/Critical (C1, C2)**. + +**Files:** +- Modify: `src/renderer/features/peers/api/usePeerEvents.ts` +- Modify: `src/renderer/shared/components/EventBridge.tsx` (lines 98-99, 138, 150) +- Test: `tests/unit/renderer/peers/usePeerEvents.test.tsx` + +- [ ] **Step 1: Write failing test** + +```ts +// asserts useIpcEvent is invoked (mock module) and the cast is removed. +``` + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +`usePeerEvents.ts`: + +```ts +import { useState } from 'react'; +import { useIpcEvent } from '@renderer/shared/hooks/useIpcEvent'; +import { PEERS_EVENTS } from '@shared/ipc/peers'; +export function useIncomingPin() { + const [pin, setPin] = useState<EventPayload<typeof PEERS_EVENTS.PIN.ISSUED> | null>(null); + useIpcEvent(PEERS_EVENTS.PIN.ISSUED, (payload) => setPin(payload)); + return { pin, dismiss: () => setPin(null) }; +} +``` + +Drop two `eslint-disable` lines and the unsafe cast. + +`EventBridge.tsx`: + +```ts +import { peerKeys } from '@features/peers'; +// ... +queryClient.invalidateQueries({ queryKey: peerKeys.paired() }); +queryClient.invalidateQueries({ queryKey: peerKeys.discovered() }); +``` + +Remove the local `PEERS_PAIRED`/`PEERS_DISCOVERED` constants. + +- [ ] **Step 4: Run tests + lint** + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/features/peers/api/usePeerEvents.ts src/renderer/shared/components/EventBridge.tsx tests/unit/renderer/peers/usePeerEvents.test.tsx +git commit -m "fix(peers/renderer): use useIpcEvent + peerKeys factory; drop unsafe casts and inline keys" +``` + +--- + +## Task 14 — Hoist `IncomingPinDialog` to RootLayout + +Closes audit findings: **05/High (H4)** dialog only mounts when on Settings page. + +**Files:** +- Modify: `src/renderer/app/layouts/RootLayout.tsx` (mount the dialog) +- Modify: `src/renderer/features/settings/components/SettingsPage.tsx` (remove the local mount) +- Test: `tests/unit/renderer/peers/incoming-pin-global.test.tsx` + +- [ ] **Step 1: Write failing test** + +Render RootLayout with a non-Settings child route, fire the IPC event, assert dialog opens. + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +In `RootLayout.tsx`, add: + +```tsx +import { IncomingPinDialog } from '@features/peers'; +// ... +<EventBridge /> +<IncomingPinDialog /> +``` + +In `SettingsPage.tsx` remove the local `<IncomingPinDialog />` (keep `<PeerListPanel />`). Drop the `TODO(p2p-phase4)` comment. + +- [ ] **Step 4: Run tests** + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/app/layouts/RootLayout.tsx src/renderer/features/settings/components/SettingsPage.tsx tests/unit/renderer/peers/incoming-pin-global.test.tsx +git commit -m "fix(peers/renderer): hoist IncomingPinDialog to RootLayout for global visibility" +``` + +--- + +## Task 15 — MVP refactor: presentation hooks for peers components + +Closes audit findings: **05/Critical (C3, C4)** god-components owning state + mutations + render. + +**Files:** +- Add: `src/renderer/features/peers/hooks/useOutgoingPair.ts` +- Add: `src/renderer/features/peers/hooks/usePeerListPanel.ts` +- Add: `src/renderer/features/peers/lib/format.ts` (rename `truncate.ts`; add `peerLabel`, `sanitizePin`, `PIN_LENGTH` import) +- Modify: `src/renderer/features/peers/components/OutgoingPairDialog.tsx` (render-only) +- Modify: `src/renderer/features/peers/components/PeerListPanel.tsx` (render-only; sub-components for self/paired/discovered bodies) +- Modify: `src/renderer/features/peers/components/IncomingPinDialog.tsx` (use `peerLabel`; replace `Heading as="h1"` for PIN value with a `Text size=…` per audit L3) +- Modify: `src/renderer/features/peers/index.ts` (re-export new hooks if external) +- Add: `src/shared/ipc/peers/constants.ts` exporting `PIN_LENGTH = 6` +- Test: `tests/unit/renderer/peers/useOutgoingPair.test.tsx`, `usePeerListPanel.test.tsx` + +- [ ] **Step 1: Write failing tests** + +Hook tests assert: state machine transitions correctly (`idle → awaitingPin → done`); `sendInvite` calls the `pairInit` mutation; `confirm` with bad PIN keeps `awaitingPin` stage; `close()` resets mutations. + +- [ ] **Step 2: Run — expect failure** + +- [ ] **Step 3: Implement** + +`useOutgoingPair.ts`: returns `{ stage, pin, setPin, targetLabel, isInitPending, isConfirmPending, initError, confirmError, sendInvite, confirm, close }`. Move `pairInit.mutate({...}, { onSuccess })` and PIN sanitization into the hook. + +`usePeerListPanel.ts`: returns `{ self, paired, discovered, revoke, inviteTarget, openInvite, closeInvite }`. + +Rename `truncate.ts` → `format.ts`, add `peerLabel` and `sanitizePin`. Update imports. + +Components keep only JSX. Replace internal `renderSelfBody`/`renderPairedBody`/`renderDiscoveredBody` with `<SelfBody/>`, `<PairedList/>`, `<DiscoveredList/>` typed components. + +- [ ] **Step 4: Run tests + lint** + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/features/peers/hooks/ src/renderer/features/peers/lib/format.ts src/renderer/features/peers/components/ src/renderer/features/peers/index.ts src/shared/ipc/peers/constants.ts tests/unit/renderer/peers/ +git rm src/renderer/features/peers/lib/truncate.ts +git commit -m "refactor(peers/renderer): MVP presentation hooks (useOutgoingPair, usePeerListPanel) + format helpers" +``` + +--- + +## Task 16 — Constants module + magic-number cleanup + deprecation removal + +Closes audit findings: **02/High (H2, H3)** scattered magics + deprecated `Phase1PeerConfig`; **01/Medium** magic numbers; **05/Medium (M5)** PIN length. + +**Files:** +- Add: `src/main/features/peers/peer-constants.ts` +- Modify: `peer-pairing.ts`, `pair-server.ts`, `ws-transport.ts`, `peer-mdns.ts`, `peers-service.ts` to import from constants +- Modify: `peer-config.ts` — remove `Phase1PeerConfig` alias and `loadPhase1PeerConfig` if unused +- Modify: `src/shared/ipc/peers/constants.ts` (added in Task 15) — export `PIN_LENGTH` + +- [ ] **Step 1: Verify deprecation has zero non-self callers** + +```bash +git grep -n "Phase1PeerConfig\|loadPhase1PeerConfig" src/ +``` + +- [ ] **Step 2: Implement** + +`peer-constants.ts`: + +```ts +export const WS_RECONNECT_BASE_MS = 500; +export const WS_RECONNECT_MAX_MS = 30_000; +export const WS_RECONNECT_JITTER = 0.25; +export const MAX_INBOUND_SOCKETS = 64; +export const MDNS_SERVICE_TYPE = 'adc-peer'; +export const MDNS_PROTOCOL = 'tcp'; +export const PEER_ID_SHORT_LEN = 8; +export const LOOPBACK_HOST = '127.0.0.1'; +export const PAIR_BODY_MAX_BYTES = 16 * 1024; +export const PAIR_REQUEST_TIMEOUT_MS = 5_000; +export const PAIR_HEADERS_TIMEOUT_MS = 10_000; +export const PAIR_KEEPALIVE_TIMEOUT_MS = 5_000; +export const SESSION_TTL_MS = 5 * 60 * 1000; +export const SESSION_MAX_ATTEMPTS = 3; +export const SESSION_SOFT_LIMIT = 100; +export const GC_INTERVAL_MS = 24 * 60 * 60 * 1000; +export const WS_CLOSE_CODES = { + SCHEMA_MISMATCH: 4001, + FINGERPRINT_MISMATCH: 4002, + MALFORMED_FRAME: 4003, + UNTRUSTED: 4004, +} as const; +``` + +Replace inlined numbers/strings across the listed files. Remove `Phase1PeerConfig` and `loadPhase1PeerConfig` if no callers. + +- [ ] **Step 3: Run + lint + typecheck** + +- [ ] **Step 4: Commit** + +```bash +git add src/main/features/peers/peer-constants.ts src/main/features/peers/ src/shared/ipc/peers/constants.ts +git commit -m "refactor(peers): centralize constants in peer-constants.ts; remove Phase1 deprecations" +``` + +--- + +## Task 17 — Misc structural cleanups + +Closes audit findings: **01/Low** `migration-tags.ts` location; **01/Medium** `peer-store` `revokedAt` reset on re-pair; **03/Low** consolidate `peer-state-schema.ts` into `schema.ts`; **04/Medium (M5, M6)** doc revoked-peer GC semantics; **04/Low (L3)** remove placeholder `'peer-a'` peerIdShort fallbacks; **04/Medium (M6)** assistant cross-device-query receives PeerStore via DI; **03/Medium (M8)** `schema-hash.ts` use node:crypto. + +**Files:** +- Move: `src/main/features/peers/migration-tags.ts` → `src/main/db/migration-tags.ts` +- Modify: `src/main/features/peers/peer-store.ts` (`upsert` clears `revokedAt`) +- Merge: `src/main/features/peers/peer-state-schema.ts` into `src/main/features/peers/schema.ts` (one schema per domain) +- Modify: `src/main/bootstrap/service-registry.ts` (drop `'peer-a'` / `'aaaaaaaa'` fallbacks; throw if identity not resolved) +- Modify: `src/main/features/assistant/cross-device-query.ts` (accept `peerReader: { listActive(): PairedPeer[] }` via DI; drop direct `createPeerStore`) +- Modify: `src/shared/replication/schema-hash.ts` (use `node:crypto.createHash`) +- Test: bump `tests/unit/peers/peer-store.test.ts` for revokedAt reset + +- [ ] **Step 1: Implement** (group commits per concern) + +- [ ] **Step 2: Verify** + +```bash +npx tsc --noEmit +npx eslint src/main/features/peers src/main/features/assistant/cross-device-query.ts src/shared/replication +npx vitest run tests/unit/peers tests/integration/assistant/cross-device-query.test.ts +``` + +- [ ] **Step 3: Commit** + +```bash +git commit -m "refactor(peers): move migration-tags to @main/db, merge peer-state into schema, drop peerId fallbacks, schema-hash uses node:crypto, cross-device-query DI" +``` + +--- + +## Task 18 — Final verification + summary + +**Files:** +- Add: `tmp/audit/SUMMARY.md` (will be authored by the orchestrator at end of run) + +- [ ] **Step 1: Full check** + +```bash +npx tsc --noEmit +npm run lint +npx vitest run +``` + +- [ ] **Step 2: Build** + +```bash +npm run build:local +``` + +- [ ] **Step 3: Open peers integration test** + +```bash +npx vitest run tests/integration/peers +``` + +- [ ] **Step 4: Author the summary markdown** (`tmp/audit/SUMMARY.md`) listing every audit finding with status `Closed | Deferred | Out-of-scope`, the commit SHA(s), and a residual-risk paragraph. The orchestrator does this after Task 17 lands. + +--- + +## Self-review + +**Spec coverage:** every Critical and High finding from `tmp/audit/01..05` is mapped to a numbered task above. Medium findings batched under Task 12, Task 16, Task 17. Low findings deferred (style only) but listed in `SUMMARY.md`. + +**Type consistency:** `PairedPeer` becomes single-source from `@shared/ipc/peers` (Task 11). `pinnedCheckServerIdentity` defined once and shared (Task 4 → Task 12). `OutboundDialer` types live in one file (Task 5). + +**Frequent commits:** 17 implementation tasks, each one commit. Test commits batched with implementation per TDD cycle. + +**No placeholders:** every step states the exact file, the test signature, and either the code or a precise diff direction. Implementer subagents read the audit `.md` files directly for additional context where steps reference findings (`audit 02/H1` etc.). diff --git a/docs/superpowers/specs/2026-04-07-task-ui-workflow-editor-design.md b/docs/superpowers/specs/2026-04-07-task-ui-workflow-editor-design.md deleted file mode 100644 index fd5a326f..00000000 --- a/docs/superpowers/specs/2026-04-07-task-ui-workflow-editor-design.md +++ /dev/null @@ -1,263 +0,0 @@ -# Task UI Polish + Workflow Editor — Design Spec - -> Approved 2026-04-07. Agent-team parallelizable. Two independent parts. - -## Part 1: Task Expanded Row Polish - -### 1a. Sticky Footer - -Relocate the existing action bar from the top of `ProgressTaskDetailRow` to a sticky footer pinned to the bottom of the expanded row scroll container. - -**Layout:** - -``` -┌─────────────────────────────────────────────────────┐ -│ [Research] [Plan] [Execute] ← tab buttons (top) │ -├─────────────────────────────────────────────────────┤ -│ │ -│ Tab content (scrollable, overflow-y-auto) │ -│ │ -├─────────────────────────────────────────────────────┤ -│ Docs: 2 | Plans: 1 | PR: N/A [Research ▾] [Plan ▾]│ -│ [Execute ▾] [Run Workflow ▸] │ -│ [Link Ticket] [Link PR] [Archive] │ -└─────────────────────────────────────────────────────┘ -``` - -**Specifics:** - -- `sticky bottom-0` inside the expanded row container -- `bg-muted` with `border-t` — matches assistant QuickActionChips aesthetic -- **Left side:** Info counts derived from task fields (`hasResearch`, `hasPlan`, `teamTaskCount`), PR status badge (from `prStatus`/`prUrl`) -- **Right side:** Per-tab ActionDropdowns (Research/Plan/Execute) always visible regardless of active tab, Run Workflow, Archive -- Button size: `xs` or `sm` with icons -- Link Ticket and Link PR buttons remain - -**Structural change:** `ProgressTaskDetailRow.tsx` restructures from `[action bar] [tabs] [content]` to `[tabs] [scrollable content] [sticky footer]`. ActionDropdown buttons currently inside each tab section move to the footer. - -**File:** `src/renderer/features/tasks/components/detail/ProgressTaskDetailRow.tsx` (721 lines) - -### 1b. Inline Table Row Actions - -Add two narrow icon-button columns to the far right of `ProgressTaskGrid`: - -| Column | Width | Icon | Action | -|--------|-------|------|--------| -| Run Workflow | 40px | `Play` | `runWorkflow(slug)` via `useProgressContext()` | -| Archive | 40px | `Archive` | `archiveTask(slug)` via `useProgressContext()` | - -- `variant="ghost" size="icon-xs"` -- Reference existing `ActionsCell.tsx` for similar logic patterns -- Disabled states: Run disabled if action is active, Archive disabled if action is active - -**File:** `src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx` (749 lines) - -### 1c. ContentBlock Preview Styling - -The `ContentBlock` component currently has two modes: - -- **Preview:** Truncated to 200 chars, line-clamped to 3 lines, plain text -- **Full:** Expanded with styled markdown (syntax highlighting, tables) - -**Problem:** Preview doesn't use styled markdown rendering — shows raw/unstyled text. - -**Fix:** - -1. Add support for a `<!-- summary -->...<!-- /summary -->` block convention -2. Preview parser extracts the summary block if present and renders it with full markdown styling (same components as the full view) -3. Summary block contains: a synopsis paragraph (max ~300 chars) + a key-facts table -4. If no summary block found, fall back to current truncation behavior (but now with markdown styling applied, just clamped) -5. "View full" button expands to the complete document - -**Summary block format:** - -```markdown -<!-- summary --> -## Summary -One-paragraph synopsis of findings/plan. - -| Key | Value | -|-----|-------| -| Approach | Extend existing service | -| Risk | Low | -| Estimate | 2 tasks | - -<!-- /summary --> -``` - -This ensures the preview always shows meaningful, well-formatted content instead of random opening lines. - ---- - -## Part 2: Workflow Editor in Tools View - -### 2a. Tools Page Restructure - -Replace the current 5 placeholder cards in `ToolsPage.tsx` with a `PageHeader` compound component tabbed layout: - -- **Claude Config tab** — renders the existing 5 tool cards (Skills, Commands, Agents, Plugins, Config) as-is, preserving them as a roadmap for future tool surfaces -- **ADC Workflow tab** — workflow editor (main deliverable) - -Uses `PageHeader.Tabs`, `PageHeader.TabList`, `PageHeader.Tab`, `PageHeader.TabContent` per project patterns. - -**File:** `src/renderer/features/tools/components/ToolsPage.tsx` (104 lines) - -### 2b. Workflow Editor Form - -Sidebar-list + detail-panel layout: - -``` -┌──────────────┬──────────────────────────────────────┐ -│ Workflows │ Workflow: Default Pipeline │ -│ │ Description: [textarea] │ -│ * Default │ │ -│ Fast Proto │ -- Brainstorming ─────────────────── │ -│ Research │ Strategy: [superpowers:brainstorming] │ -│ │ Prompt: [textarea] │ -│ [+ New] │ [Generate] │ -│ │ │ -│ │ -- Planning ──────────────────────── │ -│ │ Strategy: [claude-workflow:new-plan] │ -│ │ Prompt: [textarea] │ -│ │ [Generate] │ -│ │ │ -│ │ -- Implementation ────────────────── │ -│ │ Strategy: [claude-workflow:agent-team] │ -│ │ Prompt: [textarea] │ -│ │ [Generate] │ -│ │ │ -│ │ [Save] [Duplicate] [Delete] │ -└──────────────┴──────────────────────────────────────┘ -``` - -**Sidebar:** List of saved workflow templates. Active selection highlighted. `+ New` button at bottom. - -**Detail panel:** Form with: - -- Name + description fields at top -- 3 phase sections (Brainstorming, Planning, Implementation), each with: - - **Strategy dropdown** — populated from plugin artifact scan (see 2c) - - **Prompt textarea** — editable, supports `{slug}` interpolation - - **Generate button** — sends prompt to assistant via `useSendCommand()` + `openWidget()`, same pattern as `RoadmapPage.tsx` -- Save / Duplicate / Delete buttons at bottom - -**Generate flow:** - -1. User clicks Generate on a phase -2. Prompt textarea content + phase context sent to assistant -3. Assistant chat opens, user interacts until satisfied -4. Assistant calls `workflow-templates.writeArtifact` IPC to write the finalized file -5. New artifact appears in the strategy dropdown on next scan - -### 2c. WorkflowTemplateService Extension - -Extend the existing `WorkflowTemplateService` and schema to support the phase-based model. - -**Schema addition** — add `phases` array to `WorkflowTemplate`: - -```typescript -phases: [ - { - name: 'brainstorming', - strategy: string, // skill/command reference (e.g. 'superpowers:brainstorming') or 'skip' | 'custom' - prompt: string, // user-editable prompt template, supports {slug} - summarySpec: { - maxChars: number, // default 300 - tableFields: string[] // e.g. ['Approach', 'Risk', 'Estimate'] - } - }, - { name: 'planning', ... }, - { name: 'implementation', ... } -] -``` - -**Storage:** Electron `userData` via existing service. `isBuiltin` flag distinguishes ADC defaults from user-created. - -**New IPC channels** (added to `workflow-templates` domain): - -| Channel | Input | Output | Purpose | -|---------|-------|--------|---------| -| `workflow-templates.scanArtifacts` | `{ projectPath: string }` | `{ name: string, type: 'skill' \| 'command' \| 'agent', path: string }[]` | Scans `.claude/skills/`, `.claude/commands/`, `.claude/agents/` for active project | -| `workflow-templates.writeArtifact` | `{ projectPath: string, type: string, name: string, content: string }` | `{ path: string }` | Writes a generated artifact to the correct `.claude/` location — called by assistant | - -Existing channels (`workflow-templates.list`, `.get`, `.create`, `.update`, `.delete`) continue to work unchanged. - -### 2d. Task Store Integration - -When a workflow runs on a task, log the template reference and current phase in progress task frontmatter: - -```yaml ---- -title: Auth Refactor -status: executing -workflow: default-pipeline -workflowPhase: planning ---- -``` - -**Type additions to `ProgressTask`:** - -- `workflow?: string` — slug/id of the workflow template used -- `workflowPhase?: string` — current active phase name - -**Service changes:** - -- `ProgressService.runWorkflow(slug, templateId?)` sets these fields when starting -- Updated on each phase transition via existing `event:progress.workflowStep` -- Readable via existing `progress.getTask` / `event:progress.taskUpdated` channels — no new events needed - -### 2e. Summary Block Convention - -The workflow system appends to every phase prompt before sending: - -> "Include a `<!-- summary -->` section at the top of your output with: a synopsis paragraph (max {maxChars} characters) and a key-facts table with columns: {tableFields.join(', ')}. Wrap it in `<!-- summary -->` / `<!-- /summary -->` HTML comment markers." - -Driven by the `summarySpec` in each phase's config. This is what Part 1's ContentBlock preview parser consumes. - ---- - -## Backlog - -- **Custom workflow phases** — allow adding/removing/reordering phases beyond the fixed 3. Requires task table and progress pipeline updates. Tracked separately. - ---- - -## Agent Task Decomposition (Parallel) - -These tasks are independent and can run in parallel waves: - -**Wave 1 (no dependencies):** - -| Task | Part | Scope | -|------|------|-------| -| Sticky footer + ContentBlock preview | Part 1 | `ProgressTaskDetailRow.tsx`, `ContentBlock` subcomponent | -| Inline row action columns | Part 1 | `ProgressTaskGrid.tsx` | -| Tools page tab restructure | Part 2 | `ToolsPage.tsx` | -| WorkflowTemplate schema + scanArtifacts IPC | Part 2 | `workflow-templates` contract, service, handlers | - -**Wave 2 (depends on Wave 1):** - -| Task | Part | Depends On | -|------|------|------------| -| Workflow editor form UI | Part 2 | Tools page tabs + schema extension | -| Task store workflow fields | Part 2 | Schema extension | -| Summary block prompt injection | Part 2 | Schema extension + ContentBlock preview | - ---- - -## Key Files - -| File | Changes | -|------|---------| -| `src/renderer/features/tasks/components/detail/ProgressTaskDetailRow.tsx` | Restructure to sticky footer, move actions | -| `src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx` | Add 2 inline action columns | -| `src/renderer/features/tools/components/ToolsPage.tsx` | Replace cards with PageHeader tabs | -| `src/shared/ipc/workflow-templates/schemas.ts` | Add `phases`, `summarySpec` to template schema | -| `src/shared/ipc/workflow-templates/contract.ts` | Add `scanArtifacts`, `writeArtifact` channels | -| `src/main/services/workflow-templates/workflow-template-service.ts` | Implement scan + write + phase support | -| `src/main/ipc/handlers/workflow-template-handlers.ts` | Register new handlers | -| `src/shared/types/progress.ts` | Add `workflow`, `workflowPhase` fields | -| `src/main/services/progress/progress-service.ts` | Set workflow fields on run | -| New: `src/renderer/features/tools/components/WorkflowEditor.tsx` | Editor form component | -| New: `src/renderer/features/tools/components/WorkflowSidebar.tsx` | Template list sidebar | -| New: `src/renderer/features/tools/components/PhaseSection.tsx` | Reusable phase form section | diff --git a/docs/superpowers/specs/2026-04-08-hub-relay-design.md b/docs/superpowers/specs/2026-04-08-hub-relay-design.md deleted file mode 100644 index 93b1c251..00000000 --- a/docs/superpowers/specs/2026-04-08-hub-relay-design.md +++ /dev/null @@ -1,230 +0,0 @@ -# Hub Relay — Cross-Device Project Access - -> **Status**: Design approved -> **Created**: 2026-04-08 -> **Priority**: High - -## Problem - -ADC runs on multiple machines (Windows desktop, MacBook) but projects are tied to the machine they live on. A locked-down work codebase can only be cloned onto the work PC. Today there's no way to sit at the MacBook and work on a Windows-only project, or vice versa. - -## Solution - -Turn the existing Hub into a **stateful relay** that connects ADC instances across devices on the same LAN. Each machine registers its local projects with the Hub. Any connected ADC instance can claim a remote project and interact with it as if it were local — Claude Code sessions, agent runs, and terminal I/O are relayed through the Hub to the machine that owns the project. - -## Core Concepts - -### Symmetrical Peers - -Both machines are equal. The Windows desktop hosts the Hub Docker container, but that's just infrastructure — it doesn't make Windows special. The MacBook's projects appear on Windows just as naturally as Windows projects appear on MacBook. - -### Exclusive Claim Model - -Projects are one-device-at-a-time. When you claim a remote project, it appears in your local project list with a Hub badge. On the host machine, the project tab gets a lock overlay ("In use by MacBook") and interactions are disabled. Release is explicit (you close/deselect) or implicit (device goes offline for >60s). - -### Relay, Not Replication - -The Hub doesn't host projects, run agents, or sync files. Projects stay on their host machine. Claude Code sessions run locally on the machine that owns the project. The Hub relays I/O between the operator's UI and the host machine's Claude process. - ---- - -## Architecture - -### Data Flow - -``` -MacBook ADC Hub (Docker) Windows ADC ------------ ------------ ----------- - Device Registry -Register device + <-- POST /api/heartbeat --> Register device + -local projects Project Index local projects - -Discover remote <-- GET /api/projects --> -projects - -Claim Windows project --> POST /projects/:id/claim --> project.claimed event - Set claimed_by Lock project tab - -session.spawn --> WebSocket relay --> Spawn Claude Code - Buffer output session locally - -Render session output <-- WebSocket relay <-- Stream JSONL output -Send input --> WebSocket relay --> Pipe to Claude stdin - -Release project --> POST /projects/:id/release --> project.released event - Clear claimed_by Unlock project tab -``` - -### Hub Server (Fastify + SQLite) - -#### Existing Infrastructure (Kept As-Is) - -- Fastify with CORS, rate limiting, WebSocket plugin -- SQLite via better-sqlite3 with migration runner -- JWT + API key auth (dual auth on WebSocket handshake) -- Docker auto-provisioning via `docker-service.ts` -- Health check endpoint -- Device registration + heartbeat - -#### New Database Tables - -**`device_projects`** — Maps devices to their local projects. Upserted on each heartbeat. - -| Column | Type | Description | -|--------|------|-------------| -| `device_id` | TEXT FK | References `devices.id` (existing table via heartbeat) | -| `project_id` | TEXT | Project UUID | -| `name` | TEXT | Project display name | -| `path` | TEXT | Absolute path on the host machine | -| `last_seen` | TEXT | ISO 8601 timestamp, updated each heartbeat | - -Primary key: `(device_id, project_id)` - -**`project_claims`** — Active exclusive locks. - -| Column | Type | Description | -|--------|------|-------------| -| `project_id` | TEXT PK | The claimed project | -| `claimed_by_device_id` | TEXT FK | Device that holds the claim | -| `claimed_at` | TEXT | ISO 8601 when claimed | -| `expires_at` | TEXT | Auto-release deadline (heartbeat extends this) | - -**`session_relay`** — Active relay sessions. - -| Column | Type | Description | -|--------|------|-------------| -| `session_id` | TEXT PK | Unique session identifier | -| `project_id` | TEXT | Project being worked on | -| `source_device_id` | TEXT | Device operating the UI (the claimer) | -| `target_device_id` | TEXT | Device hosting the project (runs Claude) | -| `status` | TEXT | `active`, `ended`, `disconnected` | -| `created_at` | TEXT | ISO 8601 | - -**`session_buffer`** — Recent output for reconnect replay. Capped at 200 rows per session. - -| Column | Type | Description | -|--------|------|-------------| -| `session_id` | TEXT FK | References `session_relay.session_id` | -| `seq` | INTEGER | Monotonic sequence number | -| `message_json` | TEXT | The JSONL line or event payload | -| `created_at` | TEXT | ISO 8601 | - -Primary key: `(session_id, seq)`. Pruned 1 hour after session ends. - -#### New REST Routes - -| Method | Route | Purpose | -|--------|-------|---------| -| `POST` | `/api/projects/:id/claim` | Claim a remote project. Body: `{ deviceId }`. Returns 200 on success, 409 if already claimed (includes claimer info). | -| `POST` | `/api/projects/:id/release` | Release a claimed project. Body: `{ deviceId }`. | -| `POST` | `/api/projects/:id/force-reclaim` | Force reclaim from another device. Only works if caller's `device_id` matches the project's host device. | -| `GET` | `/api/devices/:id/projects` | List a specific device's registered projects. | -| `GET` | `/api/sessions/:id/replay` | Get buffered messages for reconnect. Query param: `?after_seq=N` for partial replay. | - -#### New WebSocket Message Types - -**Device events** (Hub broadcasts to all connected clients): - -| Type | Payload | When | -|------|---------|------| -| `device.online` | `{ deviceId, hostname, os }` | Device registers / comes back online | -| `device.offline` | `{ deviceId }` | Device misses 3 heartbeats (~30s) | - -**Project events** (Hub broadcasts to all connected clients): - -| Type | Payload | When | -|------|---------|------| -| `project.claimed` | `{ projectId, claimedByDeviceId, deviceName }` | Project claimed | -| `project.released` | `{ projectId }` | Project released or auto-expired | - -**Session relay messages** (Hub routes between specific devices): - -| Type | Direction | Payload | -|------|-----------|---------| -| `session.spawn` | Claimer → Hub → Host | `{ sessionId, projectId, config: { agentRole, prompt, workDir, ... } }` | -| `session.input` | Claimer → Hub → Host | `{ sessionId, input: string }` | -| `session.output` | Host → Hub → Claimer | `{ sessionId, seq, payload: { ... } }` (JSONL line) | -| `session.kill` | Claimer → Hub → Host | `{ sessionId }` | -| `session.ended` | Host → Hub → Claimer | `{ sessionId, exitCode }` | -| `session.resume` | Claimer → Hub | `{ sessionId, lastSeq }` — Hub replays buffered messages after `lastSeq` | - -### Electron Client Changes - -#### Device Service (`src/main/services/device/`) - -- Extend heartbeat payload to include local project list (id, name, path) -- Listen for `device.online` / `device.offline` Hub WebSocket events -- Emit IPC events so the renderer can update project availability in real-time - -#### Project Service (`src/main/services/project/`) - -- Add `remote` boolean + `hostDeviceId` + `hostDeviceName` fields to the project model -- On Hub connect: query `GET /api/projects` and merge remote projects into the project list -- Handle `project.claimed` / `project.released` events to update lock state -- New methods: `claimProject(id)`, `releaseProject(id)`, `forceReclaimProject(id)` - -#### New Relay Service (`src/main/services/relay/`) - -Central service that owns the cross-device session lifecycle: - -- **For outgoing sessions** (I'm on MacBook, working on a Windows project): wraps session commands in relay envelopes, sends through Hub WebSocket, receives output and feeds it to the renderer as if it were local -- **For incoming relay requests** (Windows receives a spawn request from MacBook via Hub): unwraps the envelope, spawns Claude Code locally using the existing agent orchestrator, pipes output back through Hub WebSocket -- **Reconnect logic**: on WebSocket reconnect, sends `session.resume` with `lastSeq`, processes replayed messages to restore UI state - -The relay service is the **only** component that knows whether a session is local or remote. Everything above it (agent orchestrator, renderer) works with the same session interface. - -#### Agent Orchestrator (`src/main/services/agent-orchestrator/`) - -Thin abstraction: "start session" checks if the project is local or remote. -- Local: spawns Claude Code directly (existing behavior, no change) -- Remote: delegates to relay service - -The orchestrator's existing features (watchdog, cost tracking, session listing) work the same — session output arrives through the same interface regardless of source. - -#### Renderer Changes - -Minimal: -- **Project list**: show Hub badge + host device name on remote projects. Lock overlay + "In use by {device}" on projects claimed by another device. "Release" action in project context menu for your claimed remote projects. -- **Session UI**: no changes. Output format is identical whether local or relayed. The renderer doesn't know or care where the session runs. -- **Hub setup page**: already exists. May need minor updates to show connected devices after setup completes. - ---- - -## Heartbeat & Liveness - -- ADC sends heartbeat every 10 seconds (existing behavior) -- Heartbeat now includes: device ID, hostname, OS, online status, local project list -- Hub marks a device offline after 3 missed heartbeats (30s) -- Offline device's projects become greyed out / unavailable in other clients -- When device comes back: heartbeat resumes, projects reappear, any stale claims auto-expire -- Active claims are extended on each heartbeat (rolling `expires_at`). If claimer goes offline without releasing, claim expires after 60 seconds. - -## Reconnect Behavior - -1. MacBook loses WebSocket connection (network blip, sleep, etc.) -2. Agent on Windows keeps running — it doesn't know about the relay -3. Hub buffers output in `session_buffer` (last 200 messages per session) -4. MacBook reconnects, sends `session.resume` with last received `seq` -5. Hub replays all buffered messages after that `seq` -6. MacBook UI catches up seamlessly - -If MacBook is offline for >60s, the claim auto-expires and the project unlocks on Windows. The agent session may still be running on Windows — it's a local session on that machine. The MacBook can re-claim and resume viewing if the session is still active. - -## Security - -- All relay messages go through the existing authenticated WebSocket (JWT or API key) -- Hub validates that the device sending `session.spawn` actually holds the claim for that project -- Hub validates that session output is only forwarded to the device that holds the claim -- Force reclaim is restricted to the project's host device -- LAN-only by design — no internet exposure. Users who want remote access use their own VPN/Tailscale. - ---- - -## Out of Scope - -- **No file browsing/editing through the Hub** — relay is for Claude Code sessions and terminal I/O only -- **No file sync/replication** — projects stay on their host machine -- **No multi-user** — single-user auth, your machines only -- **No internet relay / cloud hosting** — LAN-only, VPN is user's responsibility -- **No concurrent project access** — one device at a time per project -- **No Hub-hosted compute** — Hub is relay only, doesn't run Claude Code diff --git a/docs/superpowers/specs/2026-04-08-worktree-setup-design.md b/docs/superpowers/specs/2026-04-08-worktree-setup-design.md deleted file mode 100644 index 85bc8da0..00000000 --- a/docs/superpowers/specs/2026-04-08-worktree-setup-design.md +++ /dev/null @@ -1,195 +0,0 @@ -# Universal Worktree Setup — Design Spec - -> Approved 2026-04-08. Every agent gets full repo access, zero cross-contamination. - -## Problem - -Agents spawned in git worktrees are missing critical files: -- `.claude/settings.json` — gitignored, so not checked out. Agents lose plugin config and hook definitions. -- `node_modules/` — gitignored, so not checked out. All builds, lint, typecheck fail. -- `.claude/settings.local.json` — gitignored. Only the ADC WorktreeProvisioner generates this for team-leads. - -Two separate worktree creation paths exist (Claude Code native and ADC WorktreeProvisioner) producing inconsistent environments. No `.worktreeinclude` file exists to bridge the gap for native worktrees. - -## Solution - -A single shared setup script (`scripts/worktree-setup.sh`) called by both paths: -1. **Claude Code native** — via `WorktreeCreate` hook in `settings.json` -2. **ADC WorktreeProvisioner** — calls the same script after `git worktree add` - -The script copies gitignored config files and installs dependencies. Both paths produce identical, fully-functional worktree environments. - -## What Changes - -### 1. Create `.worktreeinclude` - -Tells Claude Code which gitignored files to auto-copy into native worktrees: - -``` -.claude/settings.json -.claude/settings.local.json -.env -.env.local -``` - -Note: `.worktreeinclude` is NOT processed when a `WorktreeCreate` hook exists. But we create it anyway as documentation of intent and as a fallback for environments where hooks aren't configured. - -### 2. Create `scripts/worktree-setup.sh` - -The single entry point for worktree filesystem setup. Idempotent — safe to run multiple times. - -```bash -#!/usr/bin/env bash -# scripts/worktree-setup.sh — Universal worktree environment setup -# Called by: WorktreeCreate hook (Claude Code native) and WorktreeProvisioner (ADC IPC) -# -# Arguments: -# $1 — worktree path (absolute) -# $2 — main repo path (absolute, defaults to git rev-parse --show-toplevel of parent) -# -# What it does: -# 1. Copies gitignored .claude/ config files from main repo -# 2. Runs npm ci for dependency installation -# 3. Verifies the worktree is functional - -set -euo pipefail - -WORKTREE_PATH="${1:?Usage: worktree-setup.sh <worktree-path> [main-repo-path]}" -MAIN_REPO="${2:-$(cd "$(dirname "$0")/.." && pwd)}" - -# ── Step 1: Copy gitignored .claude/ files ────────────────── -CLAUDE_SRC="$MAIN_REPO/.claude" -CLAUDE_DST="$WORKTREE_PATH/.claude" - -mkdir -p "$CLAUDE_DST" - -# settings.json — plugin config, hooks, enabled skills -if [ -f "$CLAUDE_SRC/settings.json" ]; then - cp "$CLAUDE_SRC/settings.json" "$CLAUDE_DST/settings.json" -fi - -# settings.local.json — only if it exists in main (don't overwrite provisioner-generated ones) -if [ -f "$CLAUDE_SRC/settings.local.json" ] && [ ! -f "$CLAUDE_DST/settings.local.json" ]; then - cp "$CLAUDE_SRC/settings.local.json" "$CLAUDE_DST/settings.local.json" -fi - -# .env files -for envfile in .env .env.local; do - if [ -f "$MAIN_REPO/$envfile" ]; then - cp "$MAIN_REPO/$envfile" "$WORKTREE_PATH/$envfile" - fi -done - -# ── Step 2: Install dependencies ──────────────────────────── -cd "$WORKTREE_PATH" -if [ -f "package-lock.json" ]; then - npm ci --ignore-scripts --no-audit --no-fund 2>/dev/null || npm install --ignore-scripts --no-audit --no-fund -fi - -# ── Step 3: Verify ────────────────────────────────────────── -echo "Worktree setup complete: $WORKTREE_PATH" -``` - -Flags: `--ignore-scripts` (skip postinstall to avoid Electron rebuild), `--no-audit --no-fund` (speed). - -### 3. Add `WorktreeCreate` hook to `.claude/settings.json` - -```json -{ - "hooks": { - "WorktreeCreate": [ - { - "command": "bash scripts/worktree-setup.sh \"$WORKTREE_PATH\" \"$PROJECT_DIR\"", - "timeout": 120000 - } - ] - } -} -``` - -Environment variables available in WorktreeCreate hooks: -- `$WORKTREE_PATH` — the newly created worktree directory -- `$PROJECT_DIR` — the main repo root - -### 4. Update ADC WorktreeProvisioner - -In `src/main/services/worktree-provisioner/worktree-provisioner.ts`: - -After the `git worktree add` call, instead of manually copying `.claude/` dirs and files, call the shared script: - -```typescript -execSync(`bash scripts/worktree-setup.sh "${worktreePath}" "${projectPath}"`, { - cwd: projectPath, - timeout: 120_000, - stdio: 'pipe', -}); -``` - -Keep the provisioner's additional responsibilities that the script doesn't handle: -- Generating role-specific CLAUDE.md (merging agent definition + project rules) -- Generating team-lead enforcement hooks in `settings.local.json` (overwrites the one copied by the script) -- IPC session lifecycle management - -Remove the manual `CLAUDE_DIRS_TO_COPY` / `CLAUDE_FILES_TO_COPY` logic — the script handles it. - -### 5. Add `WorktreeRemove` hook (optional cleanup) - -```json -{ - "hooks": { - "WorktreeRemove": [ - { - "command": "echo 'Worktree removed: $WORKTREE_PATH'", - "timeout": 5000 - } - ] - } -} -``` - -Lightweight — just logging for now. Could run cleanup later if needed. - -### 6. Update team-leader agent definition - -Add clear worktree instructions to `.claude/agents/team-leader.md` so when the app hands a task to a team-lead, it knows exactly how worktrees work: - -```markdown -## Worktree Setup - -Every agent you spawn MUST work in an isolated worktree. The worktree is fully functional: -- Full codebase checkout on its own branch -- node_modules installed (npm ci runs automatically) -- .claude/ settings, skills, agents, refs all available -- CLAUDE.md with project rules available - -Worktrees are created by the WorktreeProvisioner (via workspace.provisionTeammate IPC). -Each teammate gets: isolated branch, isolated file changes, full build capability. - -Multiple teams can run simultaneously — each on different worktrees/branches. -Changes don't affect other teams until explicitly merged. -``` - -### 7. Update docs - -- `docs/patterns/PATTERNS.md` — add Worktree Setup section -- `CLAUDE.md` — add to Finding Things: `- Worktree setup: scripts/worktree-setup.sh` - -## File Summary - -| File | Action | -|------|--------| -| `.worktreeinclude` | Create — lists gitignored files to copy | -| `scripts/worktree-setup.sh` | Create — shared setup script | -| `.claude/settings.json` | Modify — add WorktreeCreate hook | -| `src/main/services/worktree-provisioner/worktree-provisioner.ts` | Modify — call shared script instead of manual copy | -| `.claude/agents/team-leader.md` | Modify — add worktree instructions | -| `docs/patterns/PATTERNS.md` | Modify — add worktree pattern | -| `CLAUDE.md` | Modify — add worktree ref to Finding Things | - -## Verification - -After implementation: -1. Create a native Claude Code worktree (`EnterWorktree`) → verify it has `.claude/settings.json`, `node_modules/`, and can run `npm run typecheck` -2. Create a WorktreeProvisioner worktree (via IPC) → verify same -3. Run two agents in parallel worktrees → verify no cross-contamination -4. Verify `npm run build` passes in a fresh worktree diff --git a/docs/superpowers/specs/2026-04-17-test-suite-gap-closure-design.md b/docs/superpowers/specs/2026-04-17-test-suite-gap-closure-design.md new file mode 100644 index 00000000..bc1d3114 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-test-suite-gap-closure-design.md @@ -0,0 +1,180 @@ +# Test Suite Full IPC Contract Refactor + +**Date:** 2026-04-17 +**Branch:** develop +**Scope:** Close all 11 gaps found in gap analysis — runtime crashes, schema mismatches, dead UI, broken exports, duplicate hooks + +## Problem + +The test-suite feature has 11 gaps across backend, IPC contract, and renderer layers: + +- 2 runtime crashes (runner.run() missing projectId at both call sites) +- 3 data/type mismatches (QaScriptSchema missing 6 fields, two competing query key factories, duplicate mutation hooks with different cache invalidation) +- 3 broken UI elements (Run button, Run Selected button, exportFile passes empty steps) +- 1 intentional stub (exportGithub — no action needed) +- 2 compounding issues (duplicate hooks, allRuns unscoped — fixed by other gaps) + +Root causes: +1. Migration 0016 renamed tables but never added columns added later to the Drizzle schema. Fixed by migrations 0022-0024 (already created). +2. QaScriptSchema was written for the original minimal IPC shape and never updated when the DB store grew. +3. Two separate developers (or sessions) created competing query key files with incompatible signatures. +4. Run buttons were scaffolded as UI but never wired to mutations. + +## Design + +### 1. IPC Contract — Single Source of Truth + +**File: `src/shared/ipc/test-suite/schemas.ts`** + +Expand `QaScriptSchema` to include all fields the DB store returns: + +```ts +export const QaScriptSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + steps: z.array(TestSuiteStepSchema), + filePath: z.string(), + projectId: z.string(), + targetUrl: z.string(), + stepCount: z.number().int(), + lastStatus: z.string().nullable(), + lastRunAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +``` + +**File: `src/shared/ipc/test-suite/contract.ts`** + +Add `projectId` to `LIST.SCRIPTS` input: + +```ts +[TEST_SUITE.LIST.SCRIPTS]: { + input: z.object({ projectId: z.string() }), + output: z.array(QaScriptSchema), +}, +``` + +### 2. Handler & Service Layer + +**File: `src/main/features/test-suite/recorder-handlers.ts`** + +LIST.SCRIPTS handler filters by project: + +```ts +router.handle(TEST_SUITE.LIST.SCRIPTS, ({ projectId }) => + testSuiteService.listScriptsByProject(projectId) as never, +); +``` + +Update the local `TestSuiteService` interface to include `listScriptsByProject`. + +**File: `src/main/features/test-suite/index.ts`** + +Three changes: + +1. Add `listScriptsByProject` facade method → delegates to `scriptStore.listByProject(projectId)` +2. Fix `runScript()` → pass `projectId: script.projectId` to `runner.run()` +3. Fix `exportFile()` → pass `steps: script.steps` instead of `steps: []` + +Add `listScriptsByProject` to the `TestSuiteService` interface. + +**File: `src/main/features/qa/qa-trigger.ts`** + +Add `projectId: script.projectId` to the `runner.run()` call at line 131. + +### 3. Renderer — Single Query Key Factory & Hook Consolidation + +**Delete:** `src/renderer/features/test-suite/api/queryKeys.ts` + +**Canonical key factory:** `src/renderer/features/test-suite/api/testSuiteKeys.ts` + +Add missing keys: + +```ts +export const testSuiteKeys = { + all: ['test-suite'] as const, + config: (projectId: string) => [...testSuiteKeys.all, 'config', projectId] as const, + configs: (projectId: string) => [...testSuiteKeys.all, 'configs', projectId] as const, + scripts: (projectId: string) => [...testSuiteKeys.all, 'scripts', projectId] as const, + script: (id: string) => [...testSuiteKeys.all, 'script', id] as const, + runs: (scriptId: string) => [...testSuiteKeys.all, 'runs', scriptId] as const, + allRuns: (projectId: string) => [...testSuiteKeys.all, 'runs', 'all', projectId] as const, + run: (runId: string) => [...testSuiteKeys.all, 'run', runId] as const, + screenshots: (runId: string) => [...testSuiteKeys.all, 'screenshots', runId] as const, + analytics: { /* unchanged */ }, +}; +``` + +**Delete:** `src/renderer/features/test-suite/api/useScriptMutations.ts` +- `useSaveScript()` and `useDeleteScript()` are duplicates of the projectId-based versions in their own files +- Extract `useExportRun()` to new file `src/renderer/features/test-suite/api/useExportRun.ts` + +**Delete:** `src/renderer/features/test-suite/api/useScripts.ts` +- Duplicate of `useTestSuiteScripts.ts` +- One importer: `ScriptSelector.tsx` — switch to `useTestSuiteScripts(projectId)` with projectId from `useLooseParams()` + +**Update hooks importing from `queryKeys`:** +- `useRuns.ts` → import from `testSuiteKeys`, use `testSuiteKeys.runs(scriptId)` + +**Fix `useTestSuiteScripts.ts`:** +- Pass `projectId` to IPC call: `ipc(TEST_SUITE.LIST.SCRIPTS, { projectId: projectId! })` + +**Update `api/index.ts` barrel** — remove deleted exports, add `useExportRun`. + +### 4. LibraryPanel — Wire Dead Buttons + +**File: `src/renderer/features/test-suite/components/LibraryPanel.tsx`** + +Import and instantiate `useRunScript()` from `useRuns.ts`. + +Wire Run button per script: + +```tsx +onClick={() => runScript.mutate({ scriptId: script.id })} +``` + +Wire Run Selected bulk button: + +```tsx +onClick={() => { + for (const id of selected) runScript.mutate({ scriptId: id }); +}} +``` + +## Not In Scope + +- No new database migrations (0022-0024 already created) +- No new IPC channels +- No new components +- No changes to: recording flow, browser view, screenshots, baselines, schedules, shared steps, analytics, diff engine, config store, watcher, scheduler +- No Zod output validation enforcement on handlers (leave `as never` casts) +- `exportGithub` stays as a stub — no GitHub integration + +## Files Modified + +| File | Action | +|------|--------| +| `src/shared/ipc/test-suite/schemas.ts` | Expand QaScriptSchema | +| `src/shared/ipc/test-suite/contract.ts` | Add projectId to LIST.SCRIPTS | +| `src/main/features/test-suite/recorder-handlers.ts` | Filter LIST.SCRIPTS by project, update local interface | +| `src/main/features/test-suite/index.ts` | Add listScriptsByProject, fix runner.run() projectId, fix exportFile steps | +| `src/main/features/qa/qa-trigger.ts` | Add projectId to runner.run() | +| `src/renderer/features/test-suite/api/queryKeys.ts` | DELETE | +| `src/renderer/features/test-suite/api/useScriptMutations.ts` | DELETE | +| `src/renderer/features/test-suite/api/useScripts.ts` | DELETE | +| `src/renderer/features/test-suite/api/useExportRun.ts` | CREATE (extracted from useScriptMutations) | +| `src/renderer/features/test-suite/api/testSuiteKeys.ts` | Add allRuns, configs keys | +| `src/renderer/features/test-suite/api/useRuns.ts` | Switch to testSuiteKeys, fix imports | +| `src/renderer/features/test-suite/api/useTestSuiteScripts.ts` | Pass projectId to IPC | +| `src/renderer/features/test-suite/api/index.ts` | Update barrel exports | +| `src/renderer/features/test-suite/components/LibraryPanel.tsx` | Wire Run + Run Selected buttons | +| `src/renderer/features/test-suite/components/ScriptSelector.tsx` | Switch from useScripts to useTestSuiteScripts(projectId) | + +## Verification + +1. `npx tsc --noEmit` — zero errors +2. `npx eslint <changed files>` — zero warnings +3. Kill Electron + restart: `taskkill //F //IM electron.exe 2>/dev/null; npm run dev` +4. Manual flow: record a test → save → library shows it with correct step count → Run button triggers execution diff --git a/docs/superpowers/specs/2026-04-21-layout-preset-simplification-design.md b/docs/superpowers/specs/2026-04-21-layout-preset-simplification-design.md new file mode 100644 index 00000000..5e18743c --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-layout-preset-simplification-design.md @@ -0,0 +1,77 @@ +# Layout Preset Simplification + +**Date:** 2026-04-21 +**Branch:** `feature/agent-project-display-cleanup` +**Status:** Approved + +## Problem + +The Display settings tab exposes too many individual layout knobs (16 sidebar variants, 8 toolbar styles, 4 content layouts, 3 icon shapes). The icon shape selector was never meant to be user-facing — icon buttons should adapt to their parent context. The toolbar's floating mode still renders square hover states on child icon buttons, contradicting the rounded container. + +## Solution + +Replace all individual layout selectors with a single **Layout Preset** dropdown containing two bundled options. Remove the icon shape selector entirely. + +### Layout Presets + +| Preset | Sidebar | Content | Toolbar | +|--------|---------|---------|---------| +| **Default** | `sidebar-07` (icon collapse, flat groups, tooltips) | `flush` | `default` (h-10, solid bg, border) | +| **Floating** | `sidebar-04` (floating variant, collapsible groups) | `bordered` (rounded-lg border, inner spacing) | `floating` (h-9, rounded-lg, shadow-sm, 90% bg) | + +### Icon Button Shape + +Remove as a user setting. Hardcode `rounded` as the default. The floating toolbar's `rounded-lg` class should cascade rounded hover states to child icon buttons — this is a CSS fix in `TopBar.tsx`, not a setting. + +## Files Changed + +### Remove code + +1. **`src/shared/types/layout.ts`** — Remove all sidebar variants except `sidebar-04` and `sidebar-07`. Remove all toolbar styles except `default` and `floating`. Remove all content layouts except `flush` and `bordered`. Add `LayoutPreset` type (`'default' | 'floating'`) and `LAYOUT_PRESETS` constant mapping each preset to its `{ sidebar, toolbar, content }` values. + +2. **`src/shared/types/settings.ts`** — Remove `sidebarLayout`, `toolbarStyle`, `contentLayout`, `iconButtonShape` fields from `AppSettings`. Add `layoutPreset?: LayoutPreset`. + +3. **`src/shared/ipc/settings/schemas.ts`** — Update Zod schemas: remove `iconButtonShape`, `sidebarLayout`, `toolbarStyle`, `contentLayout` fields. Add `layoutPreset`. + +4. **`src/renderer/shared/stores/theme-store.ts`** — Remove `IconButtonShape` type, `iconButtonShape` state, `setIconButtonShape` action, `BUTTON_SHAPE_RADIUS`, `ICON_BUTTON_SHAPE_RADIUS` maps, and `applyIconButtonShape` function. Hardcode `--btn-radius: 0.375rem` and `--btn-icon-radius: 0.125rem` in `globals.css` (static, not dynamic). + +5. **`src/renderer/shared/stores/layout-store.ts`** — Replace `sidebarLayout`, `toolbarStyle`, `contentLayout` with single `layoutPreset: LayoutPreset` field. Add `setLayoutPreset(id)` that sets the preset and derives the three underlying values. Keep `sidebarLayout`, `toolbarStyle`, `contentLayout` as **derived getters** (computed from preset) so `TopBar.tsx`, `ContentAreaContainer.tsx`, and `AppSidebar.tsx` continue to work without changes to their read paths. + +6. **`src/renderer/features/settings/components/LayoutSection.tsx`** — Gut the 2x2 grid. New layout: single bordered container with two items side by side — Layout Preset dropdown (left) and Color Theme dropdown + customize button (right). Keep the SVG preview below. Remove all icon shape UI. Remove `LAYOUT_PREVIEWS` for unused sidebar variants (keep `sidebar-04` and `sidebar-07` entries only). + +7. **`src/renderer/features/settings/api/useSettings.ts`** — Update hydration to read `layoutPreset` instead of individual fields. Update mutation to persist `layoutPreset`. + +8. **`src/main/features/settings/settings-service.ts`** — Update `getLayout()` and `saveLayout()` for preset-based persistence. + +9. **`src/main/features/settings/settings-defaults.ts`** — Update default to `layoutPreset: 'default'`, remove `iconButtonShape`. + +10. **`src/renderer/app/layouts/TopBar.tsx`** — Remove unused toolbar style classes (keep `default` and `floating` only). Fix floating toolbar's icon button hover: add `[&_button]:rounded-md` or similar scoped class so icon buttons inside the floating toolbar get rounded hover states. + +11. **`src/renderer/app/layouts/ContentAreaContainer.tsx`** — Remove unused content layout styles (keep `flush` and `bordered` only). + +12. **`src/renderer/app/layouts/sidebar-layouts/layout-configs.ts`** — Remove unused layout configs (keep `sidebar-04` and `sidebar-07` only). + +### No changes needed + +- **`AppSidebar.tsx`** — Reads `sidebarLayout` from layout store, which remains as a derived value. +- **`globals.css`** — Make `--btn-radius` and `--btn-icon-radius` static values instead of dynamic. + +## Migration + +Existing users with persisted `sidebarLayout`/`toolbarStyle`/`contentLayout` values: on hydration, map their combination to the nearest preset. If `toolbarStyle === 'floating'` or `sidebarLayout === 'sidebar-04'`, map to `floating`. Otherwise map to `default`. + +`layoutGap` and `uiScale` are unaffected — they remain independent settings. + +## Settings UI Layout + +``` +┌──────────────────────────────────────────────┐ +│ Layout (preset) │ Color Theme + [edit] │ +├──────────────────────┴───────────────────────┤ +│ Unified SVG Preview │ +│ (sidebar | toolbar | content tints) │ +│ + color legend below │ +└──────────────────────────────────────────────┘ +``` + +Two dropdowns side by side in a bordered container, SVG preview spanning full width below them. diff --git a/docs/superpowers/specs/2026-04-23-hub-auto-discovery-design.md b/docs/superpowers/specs/2026-04-23-hub-auto-discovery-design.md new file mode 100644 index 00000000..6264b133 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-hub-auto-discovery-design.md @@ -0,0 +1,438 @@ +# Hub Auto-Discovery — Design Spec + +**Status:** Draft — pending approval +**Date:** 2026-04-23 +**Author:** brainstormed with Claude +**Scope:** Full enterprise bundle (Option X) + +## Motivation + +The current hub-pairing flow is a manual chain: user finds the hub's LAN IP, types `http://<ip>:3200`, generates a bootstrap secret on the hub container, pastes it into the client, mints a key, pastes the key into the Connect form. Each step has surfaced a production bug during the 0.1.0 → 0.1.6 releases (CSP blocking the fetch, missing X-Bootstrap-Secret header, missing firewall rule, etc.). Users on a second device are forced to know the network topology of the first. + +The replacement must: + +- Work automatically: the client browses mDNS, finds hubs, pairs with one click. +- Survive a compromised LAN: no plaintext keys on the wire, no rogue hub can impersonate a paired one. +- Support multiple hubs per client (home + office + cloud), Spotify-Connect-style switching from Settings → Hub. +- Ship with audit logging, revocation, and an admin surface so a hub owner retains control of paired clients. +- Preserve existing installs through a non-breaking migration. + +## Design decisions locked in brainstorming + +| Question | Decision | +|---|---| +| Trust model for discovered hubs | **A** — no approval required; LAN is the trust boundary. | +| Manual URL entry after auto-discovery lands | **B** — hidden escape hatch, revealed via expander. | +| Hub count per user | **B** — 2 to 5 hubs maintained at once. | +| Picker location | Settings → Hub tab (override of my recommended toolbar). | +| First-launch flow | **A** — show picker after skip-sign-in; "Continue without hub" skips to local mode. | +| Pairing endpoint approach | **1** — dedicated `/api/pair` with two-step init/confirm; no reuse of `/api/auth/generate-key`. | +| Scope | **X** — full enterprise bundle (TLS, Ed25519 identity, audit, revocation, network change, per-hub data isolation). | + +## Architecture + +``` +┌──────────────────────────────────────┐ ┌───────────────────────────────┐ +│ Hub (Fastify) │ │ Client (Electron main) │ +│ │ │ │ +│ ┌────────────────────────────────┐ │ │ ┌─────────────────────────┐ │ +│ │ mdns/advertiser.ts │ │ mDNS │ │ hub-discovery.ts │ │ +│ │ _adc-hub._tcp.local │──┼──────────────┼──│ Bonjour browse │ │ +│ │ TXT: {id,v,app,ch,name,api,fp}│ │ │ │ Map<hubId, record> │ │ +│ └────────────────────────────────┘ │ │ └─────────────────────────┘ │ +│ │ │ │ +│ ┌────────────────────────────────┐ │ HTTPS │ ┌─────────────────────────┐ │ +│ │ routes/pair.ts │ │◄─────────────┼──│ hub-connection.ts │ │ +│ │ POST /api/pair/init │ │ pinned fp │ │ HubRecord[] │ │ +│ │ POST /api/pair/confirm │ │ │ │ activeHubId │ │ +│ └────────────────────────────────┘ │ │ └─────────────────────────┘ │ +│ │ │ │ +│ ┌────────────────────────────────┐ │ WSS │ ┌─────────────────────────┐ │ +│ │ routes/admin/* │ │◄─────────────┼──│ hub-ws-client.ts │ │ +│ │ + /admin React UI │ │ close 4003 │ │ revocation modal │ │ +│ └────────────────────────────────┘ │ │ └─────────────────────────┘ │ +│ │ │ │ +│ ┌────────────────────────────────┐ │ │ │ IPC │ +│ │ SQLite │ │ │ ▼ │ +│ │ api_keys + client_id + pubkey│ │ │ ┌─────────────────────────┐ │ +│ │ pairing_events (audit) │ │ │ │ Renderer │ │ +│ └────────────────────────────────┘ │ │ │ HubPickerPanel │ │ +└──────────────────────────────────────┘ │ │ HubSetupPage (first │ │ + │ │ launch frame) │ │ + │ └─────────────────────────┘ │ + └───────────────────────────────┘ +``` + +### New and modified modules + +**Hub (`hub/`):** + +- `hub/src/mdns/advertiser.ts` (new) — owns the Bonjour advertisement lifecycle. +- `hub/src/lib/hub-id.ts` (new) — reads or generates the stable `hubId` UUID, persisted at `data/hub-id`. +- `hub/src/lib/tls.ts` (new) — manages the self-signed cert + key at `data/tls.{cert,key}.pem`; regenerates if missing or expired within 30 days. +- `hub/src/routes/pair.ts` (new) — two-step pair endpoints. +- `hub/src/middleware/pair-rate-limit.ts` (new) — Fastify rate-limit for `/api/pair/*`. +- `hub/src/middleware/audit.ts` (new) — writes `pairing_events` rows. +- `hub/src/routes/admin/*` (new) — `clients`, `audit`, `settings`, and `rotate-admin-key` endpoints plus the React admin UI served at `/admin`. +- `hub/src/middleware/api-key.ts` (modified) — honors the new `revoked_at` column; closes WebSockets with code `4003` on revoke. +- `hub/src/app.ts` (modified) — HTTPS-only Fastify listen; wires advertiser + admin route registration. +- `hub/migrations/` (new file) — adds `pairing_events` table and `api_keys.revoked_at` + `.revoked_reason` + `.client_id` + `.display_name` + `.pubkey_fp` columns. + +**Client main process (`src/main/`):** + +- `src/main/features/hub/hub-discovery.ts` (new) — Bonjour browse, discovered-hub Map, IPC event emission, network-change handling. +- `src/main/features/hub/hub-pair.ts` (new) — two-step pair orchestration, fingerprint pinning, Ed25519 signing. +- `src/main/features/hub/client-identity.ts` (new) — per-hub Ed25519 keypair generation and `safeStorage` persistence. +- `src/main/features/hub/hub-connection.ts` (modified) — multi-hub storage, switch-active lifecycle, `beforeActiveHubChange` hook broadcast. +- `src/main/features/hub/hub-handlers.ts` (modified) — new IPC handlers `DISCOVERED.LIST`, `PAIR.REQUEST`, `SWITCH.ACTIVE`, `REMOVE.RECORD`, `MANUAL.PAIR`. +- `src/main/features/hub/config-store.ts` (modified) — schema migration from flat legacy config to `{hubs: HubRecord[], activeHubId}`. +- `src/main/lib/db.ts` (modified) — `getActiveDb()` resolves to `hubs/${activeHubId}/adc.db`. +- `src/main/index.ts` (modified) — starts `hub-discovery` at `app.whenReady`; gates on `ENABLE_HUB_DISCOVERY`. + +**Client renderer (`src/renderer/`):** + +- `src/renderer/features/hub/components/HubPickerPanel/` (new) — the Spotify-style picker (paired list + discovered list + manual-entry expander). +- `src/renderer/features/hub/components/HubSetupPage/` (modified) — wraps the new picker in the first-run frame. +- `src/renderer/features/settings/components/HubSettings/` (modified) — replaces current form layout with the picker. +- `src/renderer/features/hub/api/useHubDiscovery.ts` (new) — React Query hook subscribing to `event:hub.discovery.changed`. +- `src/renderer/features/hub/api/useHubPair.ts`, `useHubSwitchActive.ts`, `useHubRemoveRecord.ts`, `useHubManualPair.ts` (new). + +**IPC contract (`src/shared/ipc/hub/`):** + +- `channels.ts` (modified) — adds `HUB.DISCOVERED.LIST`, `HUB.PAIR.REQUEST`, `HUB.SWITCH.ACTIVE`, `HUB.REMOVE.RECORD`, `HUB.MANUAL.PAIR`; event channels `event:hub.discovery.changed`, `event:hub.active.changed`, `event:hub.revoked`. +- `contract.ts` (modified) — Zod schemas for all new channels. + +## mDNS service shape + +**Service type:** `_adc-hub._tcp.local` +**Port:** read from the live Fastify config at advertise time. +**Instance name:** the `hubId` UUID. UUID avoids hostname-collision edge cases. + +**TXT record fields** (all values stringified; total payload budget 400 bytes): + +| Key | Meaning | Example | +|---|---|---| +| `id` | Stable `hubId` UUID | `7e8a5b82-4f15-4a1d-9b2c-0ae7f33ba6c1` | +| `v` | Pair-protocol version, starts at `1` | `1` | +| `app` | App version for display | `0.1.7` | +| `ch` | Channel | `release` \| `local` \| `dev` | +| `name` | Human display name (defaults to hostname, renameable on hub) | `DESKTOP-34T6B1U` | +| `api` | Path prefix (future-proofs reverse proxies) | `/api` | +| `fp` | SHA-256 fingerprint of the TLS leaf cert (64-char hex) | `9f4e…` | + +**Lifecycle:** + +- Advertised once the Fastify HTTPS listen callback fires. +- Withdrawn on SIGTERM / SIGINT via `Bonjour.unpublish`, with a goodbye packet fallback. +- Not re-advertised if the server's own health endpoint hard-fails (prevents stale entries on crashed hubs). + +**Client browsing:** + +- Starts at `app.whenReady` and runs for the app's lifetime. +- Filters by `ch` matching the client's channel — a Dev build never surfaces a Release hub. +- In-memory Map keyed by `hubId`, entries over 60 s without refresh marked stale, dropped at 5 min. +- `event:hub.discovery.changed` emitted on any delta, debounced 250 ms. + +## Cryptography and transport + +### TLS + +The hub listens on HTTPS only. Plaintext HTTP on the LAN port is removed. + +**Cert generation:** on first run or when the cert file is missing, unreadable, or within 30 days of expiry, `hub/src/lib/tls.ts` generates a self-signed X.509 certificate with a fresh Ed25519 key. Validity is 365 days from issue. Stored at `${dataDir}/tls.cert.pem` and `${dataDir}/tls.key.pem`, mode `0600`. `CN = hubId`, `subjectAltName` = `DNS:localhost` + every IPv4 on non-loopback interfaces at generation time. The expiry check runs once per startup and every 24 h thereafter; when rotation fires, advertisement restarts so clients receive the new fingerprint within one discovery cycle. + +**Fingerprint distribution:** SHA-256 of the leaf cert (binary DER form, hex-encoded) is placed in the mDNS TXT `fp` field. When the cert is regenerated, the advertisement is restarted so clients see the new fingerprint immediately. + +**Client trust store:** each `HubRecord` carries `pinnedFingerprint`. Every connection the client opens to a paired hub uses a per-connection `https.Agent` with `rejectUnauthorized: true` and a `checkServerIdentity` callback that compares the peer cert's SHA-256 to `pinnedFingerprint`. Mismatch → connection rejected and the UI shows a "Hub identity changed — possible spoofing. Re-pair to accept." banner. No automatic re-pin. + +### Client Ed25519 identity + +Each paired hub gets its own client identity — revoking on hub A must not force re-pair on hub B. + +**Generation:** on first pair attempt against a given hub, the client calls `crypto.generateKeyPairSync('ed25519')`. Private key is wrapped via `safeStorage.encryptString` (macOS Keychain / Windows DPAPI / Linux libsecret) and stored at `${userData}/hubs/${hubId}/client-identity.enc`; public key stored plaintext alongside as `client-identity.pub`. + +**Derived `clientId`:** SHA-256 of the DER-encoded public key, first 16 bytes hex-encoded. Stable as long as the identity file is preserved. + +### Pairing flow + +Two-step exchange to prove key possession on every pair attempt, first or repeat. + +``` +POST /api/pair/init +req { clientId, clientPubKey, displayName } +res { nonce, expiresAt } // 32-byte base64 nonce, 30 s TTL + +POST /api/pair/confirm +req { clientId, nonce, signature } // Ed25519(nonce) with the client private key +res { hubId, displayName, key } +``` + +- On `/api/pair/init`, the hub persists `(clientId → pubkey)` if this is the first time it has seen this `clientId`. If the `clientId` is already known and the submitted `clientPubKey` does not match the stored one, the hub responds `409 IdentityConflict` and logs a `pair.reject` audit event. Users hit this when a client wiped its identity file; the resolution is a manual "Reset identity" button in the admin UI that clears the stored pubkey for that `clientId`. +- On `/api/pair/confirm`, the hub verifies the signature against the stored pubkey, drops the nonce from its cache, mints a fresh 64-char hex key, inserts it into `api_keys` with `client_id`, `display_name`, `pubkey_fp`, and returns it. +- Existing key rows for the same `clientId` are marked `revoked_at = now()` so only one key per clientId is ever active. + +**Nonce store:** in-memory Map, keyed by `(clientId, nonce)` → `expiresAt`. Nonces are single-use; a `confirm` call that references a spent or expired nonce responds `401 ExpiredNonce`. + +### Rate limiting + +- `/api/pair/init`: 20 requests per hour per source IP. Includes failures. +- `/api/pair/confirm`: 5 requests per hour per `clientId`. Failures count toward the budget to raise the cost of brute-forcing signatures. +- Both limits configurable via `HUB_PAIR_RATE_LIMIT_INIT` and `HUB_PAIR_RATE_LIMIT_CONFIRM` env vars. + +## Audit log, revocation, and admin UI + +### Audit table + +New SQLite table `pairing_events` on the hub: + +| Column | Type | Notes | +|---|---|---| +| `id` | TEXT PK | ULID | +| `ts` | INTEGER | Epoch ms | +| `event_type` | TEXT | `pair.init`, `pair.confirm`, `pair.reject`, `key.revoke`, `key.use` | +| `client_id` | TEXT | Nullable for pre-identity rejections | +| `display_name` | TEXT | | +| `source_ip` | TEXT | | +| `user_agent` | TEXT | | +| `pubkey_fp` | TEXT | SHA-256 of client pubkey, nullable | +| `outcome` | TEXT | `success`, `rate_limited`, `identity_conflict`, `bad_signature`, `expired_nonce`, `revoked`, `unknown_key` | +| `reason` | TEXT | Free-form detail | + +- Written by the audit middleware on every `/api/pair/*` request and on key usage / revocation paths. +- Retention defaults to 90 days; a daily cron in `hub/src/lib/audit-retention.ts` deletes older rows. Configurable via `HUB_AUDIT_RETENTION_DAYS`. + +### Revocation + +- `api_keys` gains `revoked_at INTEGER NULL` and `revoked_reason TEXT`. +- The api-key middleware rejects any key with non-null `revoked_at` (401) and instructs the WebSocket manager to close every open connection for that `clientId` with close code `4003` and body `{ reason }`. +- Client receives the close, renders a modal: "Access to `${displayName}` was revoked: `${reason}`. Re-pair or switch hubs." No auto-retry. + +### Admin endpoints + +All require the admin key in header `X-Admin-Key` (separate from the pair-issued `X-API-Key`). + +| Endpoint | Purpose | +|---|---| +| `GET /api/admin/clients` | List clients with `last_seen_at`, `paired_at`, `revoked_at`. | +| `POST /api/admin/clients/:clientId/revoke` | Body `{ reason }`. Revokes + closes WS. | +| `POST /api/admin/clients/:clientId/rename` | Body `{ displayName }`. Hub-local label. | +| `POST /api/admin/clients/:clientId/reset-identity` | Clears the stored pubkey; next pair behaves like a fresh install. | +| `GET /api/admin/audit` | Query `?from=&to=&clientId=&eventType=`. Paginated. | +| `POST /api/admin/rotate-admin-key` | Requires current key. Returns new key. | + +### Admin UI + +Tiny React SPA served at `/admin` under basic-auth (env `HUB_ADMIN_USER` + `HUB_ADMIN_PASSWORD_HASH`, where the hash is an argon2id digest produced by `node scripts/hash-admin-password.mjs <password>`). If either env is unset, the route returns 403. Three tabs: + +- **Clients** — table of paired clients, last-seen timestamp, actions (Rename, Revoke, Reset identity). +- **Audit** — filterable event feed backed by `GET /api/admin/audit`. +- **Settings** — hub display name, TLS fingerprint (read-only), Regenerate cert button with warning that every client must re-pair afterward. + +### Admin key management + +Generated once on first hub run (64-char hex), printed to hub logs **once** and written to `${dataDir}/admin-key.txt` mode `0600`. Rotation via `POST /api/admin/rotate-admin-key` using the current key. Lost? The hub owner can read the file off disk. No recovery path is offered through the network for safety reasons. + +## Client lifecycle + +### Main-process discovery service + +- Starts at `app.whenReady` and runs for the app's lifetime. No UI-driven lifecycle — the renderer consumes a materialized view. +- Map keyed by `hubId` with entries `{ txt, addresses, port, lastSeenAt, pinnedFingerprint? }`. +- Entries over 60 s stale are greyed out in the picker; dropped from the Map after 5 min. Keeps the UI stable during brief mDNS silences. +- Channel filter: records whose TXT `ch` does not match `resolveChannel()` are discarded at intake. +- `event:hub.discovery.changed` emitted on set or metadata delta, debounced 250 ms. + +### Network change handling + +Two signal sources merged: + +1. `os.networkInterfaces()` polled every 5 s. A hash of the sorted list is compared frame-to-frame; any change triggers the handler. +2. Electron `powerMonitor` events (`resume`, `on-ac`, `on-battery`, `lock-screen`, `unlock-screen`) each force an immediate recheck. + +Handler actions: + +1. Clear the discovery Map. +2. Unsubscribe and resubscribe the Bonjour browse. +3. Trigger the active hub's reconnect path — re-resolve via mDNS and re-validate the pinned fingerprint before opening any socket. If the active hub is absent or its fingerprint changed, mark it offline and render the disconnected state. No attempts to reach the last-known URL directly. + +### IPC additions to `HUB` + +| Channel | Input | Output | Notes | +|---|---|---|---| +| `HUB.DISCOVERED.LIST` | `{}` | `{ paired: HubRecord[], discovered: DiscoveredHub[], activeHubId }` | Reads main-process state; no network. | +| `HUB.PAIR.REQUEST` | `{ hubId, displayName? }` | `{ ok: true, hubId } \| { ok: false, error }` | Drives the two-step init/confirm, writes/updates `HubRecord`, sets active. | +| `HUB.SWITCH.ACTIVE` | `{ hubId }` | `{ ok: true } \| { ok: false, error }` | Orchestrates active-hub swap. | +| `HUB.REMOVE.RECORD` | `{ hubId }` | `{ ok: true }` | Drops record locally; best-effort remote revoke using the current key. | +| `HUB.MANUAL.PAIR` | `{ url, displayName? }` | same shape as `PAIR.REQUEST` | Escape hatch for blocked mDNS. Synthesizes a discovered record by hitting `<url>/api/health` to pull back a mini TXT-like descriptor; then runs the normal pair flow including fingerprint pin. | +| event `event:hub.discovery.changed` | — | `{ paired, discovered, activeHubId }` | Debounced 250 ms. | +| event `event:hub.active.changed` | — | `{ activeHubId }` | Fired after the full swap completes. | +| event `event:hub.revoked` | — | `{ hubId, reason }` | Consumed by renderer to open the revocation modal. | + +### Settings → Hub picker component + +`HubPickerPanel` structure: + +``` +┌─ ADC Hub ─────────────────────────────────────────────┐ +│ Currently connected: "DESKTOP-34T6B1U" [●] (v0.1.7) │ +│ │ +│ Paired │ +│ ● DESKTOP-34T6B1U connected [Rename] [Remove] │ +│ ○ Office-Mini offline [Rename] [Remove] │ +│ │ +│ Discovered on your network │ +│ ○ Kitchen-NUC (v0.1.7) [Pair & Switch] │ +│ │ +│ ▸ Add manually │ +└───────────────────────────────────────────────────────┘ +``` + +- State indicators: `connected` (solid dot), `paired-offline` (hollow grey dot), `discovered` (dashed dot). +- Hover tooltip shows last-seen timestamp and fingerprint prefix for the user to eyeball-verify. +- Rows are `role="radio"` inside a `role="radiogroup"`; arrow keys move the selection; Enter / Space triggers the primary action (Switch for paired, Pair & Switch for discovered). +- "Add manually" expands inline; explanatory text says "Use this only if mDNS is blocked on your network." +- All mutations route through React Query hooks (`useHubPair`, `useHubSwitchActive`, `useHubRemoveRecord`, `useHubManualPair`) that call IPC and invalidate `hubKeys.all`. + +### First-launch `HubSetupPage` + +Reuses `HubPickerPanel` inside a wrapper frame: + +``` +┌────────────────────────────────────────────┐ +│ Welcome to ADC │ +│ Choose a hub to connect, or skip for now. │ +│ │ +│ [HubPickerPanel] │ +│ │ +│ [Skip — use local only] │ +└────────────────────────────────────────────┘ +``` + +Skipping creates a synthetic `HubRecord` with `hubId: "local"` so the rest of the system can treat local-only mode uniformly. + +## Per-hub data isolation + +### Directory layout + +``` +${userData}/ +├── hubs/ +│ ├── local/ +│ │ └── adc.db +│ ├── 7e8a5b82-…/ +│ │ ├── adc.db +│ │ └── client-identity.{enc,pub} +│ └── c11d-…/ +│ ├── adc.db +│ └── client-identity.{enc,pub} +└── hub-config.json # {hubs: HubRecord[], activeHubId} +``` + +### HubRecord schema + +```ts +interface HubRecord { + hubId: string; // "local" for the synthetic local-only record + displayName: string; + lastKnownUrl: string | null; // https://<ip>:3200 as last observed + encryptedApiKey: string | null; // null for "local" + pinnedFingerprint: string | null; // null for "local" + dbPath: string; // absolute path to adc.db + clientIdentityRef: string | null; // relative path under hubs/<hubId>/ + addedAt: string; // ISO + lastConnectedAt: string | null; // ISO +} +``` + +### Active-hub switching + +`switchActive(hubId: string)`: + +1. Emit `beforeActiveHubChange` through the main-process event bus. Every service holding a SQLite handle subscribes and closes its `Database` instance. The WebSocket client closes its socket. +2. Update `hub-config.json` — `activeHubId = hubId`, `lastConnectedAt = now()` for the new record. +3. Repoint the `getActiveDb()` resolver to `hubs/${hubId}/adc.db`. +4. Run Drizzle migrations against the new DB (idempotent, fast). +5. Re-open services in their factory registration order. +6. Invalidate every React Query cache via `queryClient.clear()` on the renderer (triggered by `event:hub.active.changed`). +7. If the new record has credentials, establish the new TLS connection + WebSocket. Otherwise the app is in local-only mode. + +### Cross-hub references are structurally impossible + +Each DB is a separate file. Project UUIDs on hub A have no relationship to those on hub B. No schema-level cross-linkage is introduced. + +## Migration + +### Hub-side + +At first post-upgrade start: + +1. If `data/hub-id` is missing, generate and write a fresh UUID. +2. If `data/tls.{cert,key}.pem` are missing or expire within 30 days, generate a new cert pair. +3. If `data/admin-key.txt` is missing, generate one, log a one-time banner `[Hub] Admin key initialized. Read from data/admin-key.txt.`, persist mode `0600`. +4. Migration `0010_pair_identity.sql` adds the new columns to `api_keys`, creates `pairing_events`. +5. Existing keys survive: they have `NULL` `client_id` / `pubkey_fp` / `display_name`. The api-key middleware continues to accept them until they are rotated by a successful pair-confirm flow. + +### Client-side + +Runs at the first launch of a build with `ENABLE_HUB_DISCOVERY=true`, detected by the absence of the `hubs[]` array in `hub-config.json`: + +1. Create `hubs/legacy/` and move the existing top-level `adc.db` into it. +2. Synthesize a `HubRecord` with `hubId: "legacy"`, the old `hubUrl`, `encryptedApiKey`, `pinnedFingerprint: null`, `dbPath: hubs/legacy/adc.db`. +3. Set `activeHubId: "legacy"`. +4. The app launches and attempts to connect. The upgraded hub now listens HTTPS-only, so the plaintext HTTP connection attempt fails with a TLS handshake error. The client catches this specific error (`ERR_SSL_WRONG_VERSION_NUMBER` or similar) on any `legacy` record and renders a blocking banner in Settings → Hub: "This hub upgraded to encrypted transport. Re-pair to reconnect." The app continues to function in local-only read-mode against the legacy DB while the banner is present; no sync attempts are retried. +5. The banner's action button triggers `HUB.PAIR.REQUEST` against the legacy hub's URL. The pair goes through the normal two-step init/confirm over HTTPS, producing a fresh `hubId` (the hub's real post-upgrade `hubId` from `data/hub-id`) and a pinned fingerprint. The new `HubRecord` is inserted, the legacy DB file is moved into `hubs/${newHubId}/adc.db`, the legacy record is removed, and `activeHubId` is flipped to the new record. +6. Once no `legacy` records remain, plaintext HTTP is unreachable — the client only opens HTTPS. The legacy fallback lives only for the duration of step 4-5 on the upgrading device. + +## Testing strategy + +### Unit (Vitest, approximately 40 cases) + +- mDNS record encode/decode round-trip for all TXT fields. +- Ed25519 sign and verify; wrong-key rejection; tampered-nonce rejection. +- Nonce store TTL eviction and single-use semantics. +- Rate-limit buckets: window rollover, per-IP vs per-clientId scoping. +- Fingerprint compare including case-insensitivity and whitespace. +- `switchActive` orchestration using an in-memory fake of the service registry. +- Legacy config migrator: legacy input → expected `hubs[]` shape. +- TLS cert expiry check (mocked clock). + +### Integration (Vitest with hub spun up on ephemeral port, approximately 20 cases) + +- Full `/api/pair/init` → `/api/pair/confirm` happy path. +- Bad signature on confirm. +- Replayed nonce after TTL → 401. +- Two clients pairing back-to-back; both can reach the hub independently. +- Revocation: admin POST → WebSocket receives close 4003. +- Admin key rotation. +- Legacy-config migrator with a live hub: client starts on plaintext HTTP, re-pairs, ends up on HTTPS with the same DB contents. +- `409 IdentityConflict` reproduction. + +### End-to-end (Playwright + electron-playwright-helpers, 6 scenarios) + +1. Fresh install → first-launch picker shows a test hub → pair → verify `hub-config.json` shape and DB path. +2. Two clients on the same LAN pair with one hub; revoke one; verify the other is unaffected and receives no stray events. +3. Switch active hub → picker and DB-backed UI both reflect the new set; previously-selected project from hub A is not visible under hub B. +4. Network change simulated by toggling a virtual interface; discovery Map empties and repopulates; active hub reconnects with a fresh pin verification. +5. Rogue mDNS announce with the same `hubId` but a different fingerprint → rejected with the spoofing banner. +6. Admin UI revokes a client → client renders the revocation modal and is disconnected from the hub. + +## Rollout + +- **Feature flag:** `ENABLE_HUB_DISCOVERY` env var. Default `true`; exists solely as an emergency rollback lever that can be set via the installer/settings if the feature destabilizes a user's install. When `false`, `hub-discovery` never starts, the renderer falls back to the legacy `HubSetupPage`, and the main process still resolves `activeHubId = "legacy"` if present. +- **Single release** — ship the full enterprise bundle in one version (0.2.0 suggested, given the scope). No half-landings of sub-pieces. +- **Monitoring signal:** hub audit log surfaces pair success rate. Post-release watch is `pair.failure_rate` (any outcome other than `success`) averaged over 24 h; above 5% triggers investigation, above 10% triggers rollback by pinning the next build to `ENABLE_HUB_DISCOVERY=false` and releasing 0.2.1 immediately. +- **Documentation update:** `README.md` install section needs a rewrite — the pair-with-secret dance is no longer the supported path. Mention the picker and the "Add manually" fallback. + +## Open questions intentionally deferred + +- A future spec will cover **cross-hub data migration** — exporting a project from one hub and importing on another. Currently each hub is an island. +- **Cloud-hosted hub discovery** — the `HUB.MANUAL.PAIR` path covers it functionally, but there's room for a better onboarding UX (OAuth, SSO-behind-proxy) if ADC ever grows a hosted tier. +- **iOS / mobile client** — mDNS advertising shape is compatible with iOS Bonjour APIs, but nothing in this spec is blocked on mobile existing. + +## Success criteria + +- New user on a second device runs the app, sees the hub in the picker within 10 seconds of launch, taps once, is paired and syncing. +- A user who never wants a hub can skip at first launch and never sees hub UI again (until they open Settings → Hub). +- No plaintext API keys or bearer tokens cross the wire in the default configuration. +- Hub owner can, from a single UI surface, see who is paired and revoke any client within 5 seconds. +- Existing installs survive the upgrade with no user action beyond an optional re-pair prompt. diff --git a/docs/test-suite-enterprise-release.html b/docs/test-suite-enterprise-release.html new file mode 100644 index 00000000..2b6b3d9d --- /dev/null +++ b/docs/test-suite-enterprise-release.html @@ -0,0 +1,682 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Test Suite Enterprise Release - ADC + + + +
+
+

Test Suite Enterprise Release

+

Agent Desktop Command — Full-stack E2E testing for enterprise website testing

+

+ April 18, 2026 — 5 Phases, 20 Tasks, 40 Commits +

+
+
+ +
+ +
+
+
5
+
Phases Completed
+
+
+
20
+
Tasks Implemented
+
+
+
40+
+
Files Modified
+
+
+
0
+
Typecheck Errors
+
+
+ + +
+

Release Phases

+ + +
+
+ 0 +

Lock Down Pass / Fail

+ Foundation — 8 tasks +
+
+
    +
  • + +
    +
    Smart Waits in Script Writer
    +
    Added waitForLoadState('networkidle') after navigation steps with configurable timeouts. No more race conditions.
    +
    +
  • +
  • + +
    +
    Playwright Locator Upgrade
    +
    Priority chain: getByTestId > getByLabel > getByPlaceholder > getByRole > getByText > CSS fallback. Context-aware locator selection from recorded step metadata.
    +
    +
  • +
  • + +
    +
    Pre-flight Checks in Runner
    +
    Validates spec file exists, Playwright is installed, and browsers are available before spawning. Clear error messages instead of cryptic crashes.
    +
    +
  • +
  • + +
    +
    JSON Reporter for Step-Level Results
    +
    Switched from --reporter=line to --reporter=json. Parses stats.expected / stats.unexpected for accurate pass/fail counts.
    +
    +
  • +
  • + +
    +
    Screenshot on Failure
    +
    Added --screenshot=only-on-failure flag. Failed runs capture screenshots automatically.
    +
    +
  • +
  • + +
    +
    Data-Driven Value Substitution
    +
    Wired substituteDataInSteps() in the DATA-RUN handler. CSV/JSON {{key}} placeholders now substitute correctly per row.
    +
    +
  • +
  • + +
    +
    Configurable Timeouts
    +
    Added navigationTimeout (30s default) and actionTimeout (10s default) to TestSuiteConfig. Flows through to generated Playwright config.
    +
    +
  • +
  • + +
    +
    Run Output Log Dialog + Task/Workflow Integration
    +
    Full-screen log viewer with syntax coloring, copy-to-clipboard, Create Task button (pre-fills failure details), and Start Workflow dropdown (Research/Plan/Team).
    +
    +
  • +
+
+
+ + +
+
+ 1 +

Stability

+ Code health — 4 tasks +
+
+
    +
  • + +
    +
    WebContentsView Migration
    +
    Migrated from deprecated BrowserView to WebContentsView. Updated setBrowserViewcontentView.addChildView.
    +
    +
  • +
  • + +
    +
    Zustand Store Consolidation
    +
    Merged two stores into single test-suite-store.ts. Ephemeral fields (isRunning, outputLines) excluded from persistence.
    +
    +
  • +
  • + +
    +
    Dead Code Removal
    +
    Deleted old exporter.ts (deprecated page.click() API), unused RunOutputPanel.tsx, and orphan store.ts.
    +
    +
  • +
  • + +
    +
    Single Retry for Flaky Tests
    +
    Added --retries=1 to runner spawn args (later made configurable in Phase 3).
    +
    +
  • +
+
+
+ + +
+
+ 2 +

Scale

+ Organization & execution — 4 tasks +
+
+
    +
  • + +
    +
    Script Tags
    +
    Tags field on scripts (JSON array in SQLite TEXT column). Migration 0025_test_suite_tags.sql. Schema, store, contract all wired.
    +
    +
  • +
  • + +
    +
    Tag Filtering UI
    +
    Toggle badge bar in LibraryPanel with intersection filtering (must match ALL selected tags). Tags input in SaveRecordingDialog. Tag badges on script rows.
    +
    +
  • +
  • + +
    +
    Cross-Browser Testing
    +
    Multi-select for Chromium, Firefox, WebKit. Generated Playwright config uses projects array with correct device names (Desktop Chrome, Desktop Firefox, Desktop Safari).
    +
    +
  • +
  • + +
    +
    Parallel Workers
    +
    Workers config (1-16) passed via --workers=N flag. UI number input in ConfigEditDialog.
    +
    +
  • +
  • + +
    +
    Environment Profiles
    +
    Named URL profiles (staging, production). Runtime switching via BASE_URL env var. Environment selector in ResultsPanel toolbar.
    +
    +
  • +
+
+
+ + +
+
+ 3 +

Enterprise

+ Production-grade — 4 tasks +
+
+
    +
  • + +
    +
    Configurable Retries
    +
    Retries (0-5) in TestSuiteConfig. Replaces hardcoded --retries=1. UI number input with description text.
    +
    +
  • +
  • + +
    +
    HTML Report Generation
    +
    Dual --reporter=json,html output. Reports saved to .playwright-reports/<runId>/. "View Report" button opens in browser via shell.openPath.
    +
    +
  • +
  • + +
    +
    Auth State Persistence
    +
    Playwright storageState support. "Capture Auth State" button saves cookies/localStorage. Generated config includes storageState for authenticated test runs.
    +
    +
  • +
  • + +
    +
    Batch Test Execution
    +
    BATCH.RUN IPC channel runs multiple scripts sequentially. "Run Selected" uses batch. "Run Tagged" button runs all scripts matching selected tags.
    +
    +
  • +
+
+
+ + +
+
+ 4 +

Differentiation

+ Smart features — 4 tasks +
+
+
    +
  • + +
    +
    Test Health Score
    +
    Letter grade (A-F) computed from pass rate (40pts), stability (30pts), and speed (30pts). Score bars with visual breakdown on the Analytics tab.
    +
    +
  • +
  • + +
    +
    Flaky Test Indicators
    +
    RunSparkline detects pass-fail alternation patterns and marks flaky runs with yellow/amber color. Native tooltip shows "Flaky" on hover.
    +
    +
  • +
  • + +
    +
    Auto-Assertion Suggestions
    +
    After recording, generates assertion suggestions from step context (URL verification, input value checks, element visibility). Checkbox UI in SaveRecordingDialog.
    +
    +
  • +
  • + +
    +
    Run Completion Toasts
    +
    Success/error toast notifications when a test run completes. Uses the Zustand toast store with auto-dismiss (5s success, 8s error).
    +
    +
  • +
+
+
+
+ + +
+

Architecture Overview

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LayerKey FilesResponsibility
IPC Schemaschemas.ts, contract.ts, channels.tsZod validation, channel constants, type-safe IPC contracts
Configconfig-store.tsPer-project TestSuiteConfig with 15+ fields in settings_kv
Script Storescript-store.ts, schema.tsCRUD for test scripts with tags, steps (JSON), metadata in SQLite
Runnerrunner.tsSpawns Playwright CLI, streams output, parses JSON results
Script Writerscript-writer.tsGenerates .spec.ts with smart waits, Playwright locators
Config Writerplaywright-config-writer.tsGenerates playwright.config.ts with projects, workers, storageState
Service Facadeindex.tsComposes stores + runner, handles auth state, batch runs
IPC Handlersrecorder-handlers.tsRegisters 60+ IPC handlers for all test-suite operations
React Hooksapi/*.tsReact Query hooks for scripts, runs, config, analytics
UI Componentscomponents/*.tsx (20 files)Full test suite UI with 7 tabs, dialogs, panels
+
+ + +
+

TestSuiteConfig Schema

+ + + + + + + + + + + + + + + + + +
FieldTypeDefaultPhase
targetUrlurl-Original
viewportWidth/Heightint1280x720Original
screenshotModeenum'smart'Original
testDirectorystring'test-suite/'Original
navigationTimeoutint (ms)30000Phase 0
actionTimeoutint (ms)10000Phase 0
browsersenum[]['chromium']Phase 2
workersint (1-16)1Phase 2
environmentsobject[][]Phase 2
retriesint (0-5)1Phase 3
storageStatePathstring?undefinedPhase 3
+
+ + +
+

Commit History

+
+f344fa55 docs: update CLAUDE.md test-suite section for Phase 4 features +158499b1 merge: Phase 4 — Differentiation (health score, flaky indicators, assertion suggestions, toast notifications) +32737e28 feat(test-suite): add toast notifications for run completion +775f36e4 feat(test-suite): add auto-generated assertion suggestions in save dialog +186776ee feat(test-suite): add test health score with letter grade to analytics +01df29e9 feat(test-suite): add flaky test indicators to run history sparkline +1f9ef18a docs: update CLAUDE.md test-suite section for Phase 3 features +0deed2a2 merge: Phase 3 — Enterprise (configurable retries, HTML reports, auth state, batch execution) +98e710d6 feat(test-suite): add batch test execution with run-by-tag support +40636a44 feat(test-suite): add auth state persistence via Playwright storageState +e786ca25 feat(test-suite): add HTML report generation with View Report button +a26c0323 feat(test-suite): add configurable retries to config and runner +c73a1c11 docs: update CLAUDE.md test-suite section for Phase 2 features +747e0238 merge: Phase 2 — Scale (tags, cross-browser, parallel workers, environment profiles) +a5c97887 feat(test-suite): add environment profiles with URL switching +ef120ce8 fix(test-suite): use correct Playwright device names for browser projects +8809f237 feat(test-suite): add cross-browser and parallel execution config +fa53e1af feat(test-suite): add tag filtering UI to library and save dialog +369d6179 feat(test-suite): add tags field to scripts schema and store +3047c6e4 merge: Phase 1 — Stability (WebContentsView, store consolidation, dead code, retry) +6dce7f73 docs: add test-suite feature documentation to CLAUDE.md +37b80485 refactor(test-suite): delete unused RunOutputPanel component +3e19b090 refactor(test-suite): remove dead code and add single retry for flaky tests +44dd311d refactor(test-suite): consolidate Zustand stores into test-suite-store +25a25fb2 refactor(test-suite): migrate BrowserView to WebContentsView +8075c777 merge: Phase 0 — Lock Down Pass/Fail (6 commits) +98a56454 feat(test-suite): add run summary stats, full log dialog, and task/workflow actions +4b148fd6 feat(test-suite): wire data-driven value substitution in DATA-RUN handler +1bf055e4 feat(test-suite): add pre-flight checks, JSON reporter, and failure screenshots to runner +a178b339 feat(test-suite): add smart waits and Playwright locator upgrade to script-writer +c5e40c2a refactor(test-suite): remove old exporter, consolidate on script-writer +3cff41fc feat(test-suite): add configurable navigation and action timeouts +
+
+
+ +
+
+

Agent Desktop Command — Test Suite Enterprise Release

+

Built with Electron 39, React 19, TypeScript, SQLite, Playwright, shadcn/ui

+

Generated April 18, 2026

+
+
+ + diff --git a/docs/ui/user-interface-flow.md b/docs/ui/user-interface-flow.md index 83bb1435..54907ebe 100644 --- a/docs/ui/user-interface-flow.md +++ b/docs/ui/user-interface-flow.md @@ -80,7 +80,7 @@ Electron main process starts | Path | Component | Auth Required | File | |------|-----------|---------------|------| -| `/hub-setup` | `HubSetupPage` | No (redirects to /login if hub already configured) | `src/renderer/features/hub-setup/components/HubSetupPage.tsx` | +| `/hub-setup` | `HubSetupPage` | No (redirects to /login if hub already configured) | `src/renderer/features/hub/components/HubSetupPage.tsx` | | `/login` | `LoginPage` | No (redirects to /hub-setup if hub not configured) | `src/renderer/features/auth/components/LoginPage.tsx` | | `/register` | `RegisterPage` | No (redirects to /hub-setup if hub not configured) | `src/renderer/features/auth/components/RegisterPage.tsx` | | All other routes | Wrapped by `AuthGuard` | Yes | `src/renderer/features/auth/components/AuthGuard.tsx` | @@ -431,7 +431,7 @@ Once a project is active, these routes become available: | `/projects/$projectId/agents` | `AgentDashboard` | `src/renderer/features/agents/` | | `/projects/$projectId/github` | `GitHubPage` | `src/renderer/features/github/` | | `/projects/$projectId/roadmap` | `RoadmapPage` | `src/renderer/features/roadmap/` | -| `/projects/$projectId/ideation` | `IdeationPage` | `src/renderer/features/ideation/` | +| `/projects/$projectId/ideation` | `IdeationPage` | `src/renderer/features/ideas/` | | `/projects/$projectId/changelog` | `ChangelogPage` | `src/renderer/features/changelog/` | | `/projects/$projectId/insights` | `InsightsPage` | `src/renderer/features/insights/` | | `/projects/$projectId/workflow` | `WorkflowPipelinePage` | `src/renderer/features/workflow-pipeline/` | @@ -654,7 +654,7 @@ Hub broadcasts WebSocket event ## 12. Ideation **Route**: `/projects/$projectId/ideation` -**Feature**: `src/renderer/features/ideation/` +**Feature**: `src/renderer/features/ideas/` ### User Actions @@ -999,7 +999,7 @@ Delete: - `src/renderer/features/settings/components/WorkspacesTab.tsx` - `src/renderer/features/settings/components/WorkspaceCard.tsx` - `src/renderer/features/settings/components/WorkspaceEditor.tsx` -- `src/renderer/features/workspaces/api/useWorkspaces.ts` +- `src/renderer/features/workspace/api/useWorkspaces.ts` - `src/main/ipc/handlers/workspace-handlers.ts` ### 23.4 Device Management diff --git a/drizzle/0015_drop_milestones.sql b/drizzle/0015_drop_milestones.sql new file mode 100644 index 00000000..54a33792 --- /dev/null +++ b/drizzle/0015_drop_milestones.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS `idx_milestones_project_id`; +--> statement-breakpoint +DROP TABLE IF EXISTS `milestones`; diff --git a/drizzle/0016_test_suite.sql b/drizzle/0016_test_suite.sql new file mode 100644 index 00000000..7bd4b5ef --- /dev/null +++ b/drizzle/0016_test_suite.sql @@ -0,0 +1,23 @@ +-- drizzle/0016_test_suite.sql +ALTER TABLE qa_scripts RENAME TO test_suite_scripts; +--> statement-breakpoint +ALTER TABLE qa_runs RENAME TO test_suite_runs; +--> statement-breakpoint +CREATE TABLE test_suite_screenshots ( + id TEXT PRIMARY KEY NOT NULL, + run_id TEXT NOT NULL, + script_id TEXT NOT NULL, + step_index INTEGER NOT NULL, + step_label TEXT NOT NULL, + trigger TEXT NOT NULL, + file_path TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + captured_at TEXT NOT NULL, + FOREIGN KEY (run_id) REFERENCES test_suite_runs(id) ON DELETE CASCADE, + FOREIGN KEY (script_id) REFERENCES test_suite_scripts(id) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE INDEX idx_test_suite_screenshots_run ON test_suite_screenshots(run_id); +--> statement-breakpoint +CREATE INDEX idx_test_suite_screenshots_script ON test_suite_screenshots(script_id); diff --git a/drizzle/0017_add_baselines_diffs.sql b/drizzle/0017_add_baselines_diffs.sql new file mode 100644 index 00000000..bec3c0f8 --- /dev/null +++ b/drizzle/0017_add_baselines_diffs.sql @@ -0,0 +1,25 @@ +-- drizzle/0017_add_baselines_diffs.sql +CREATE TABLE IF NOT EXISTS test_suite_baselines ( + id TEXT PRIMARY KEY, + script_id TEXT NOT NULL, + step_index INTEGER NOT NULL, + step_label TEXT NOT NULL, + file_path TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS test_suite_diffs ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + baseline_id TEXT NOT NULL, + screenshot_id TEXT NOT NULL, + diff_file_path TEXT NOT NULL, + mismatch_percentage INTEGER NOT NULL, + mismatch_pixels INTEGER NOT NULL, + threshold INTEGER NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL +); diff --git a/drizzle/0018_add_shared_steps.sql b/drizzle/0018_add_shared_steps.sql new file mode 100644 index 00000000..cbf72d28 --- /dev/null +++ b/drizzle/0018_add_shared_steps.sql @@ -0,0 +1,16 @@ +-- drizzle/0018_add_shared_steps.sql +CREATE TABLE IF NOT EXISTS test_suite_shared_steps ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + domain TEXT NOT NULL, + description TEXT, + steps TEXT NOT NULL, + usage_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_test_suite_shared_steps_project ON test_suite_shared_steps(project_id); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_test_suite_shared_steps_domain ON test_suite_shared_steps(project_id, domain); diff --git a/drizzle/0019_add_schedules.sql b/drizzle/0019_add_schedules.sql new file mode 100644 index 00000000..4ac159c7 --- /dev/null +++ b/drizzle/0019_add_schedules.sql @@ -0,0 +1,16 @@ +-- drizzle/0019_add_schedules.sql +CREATE TABLE IF NOT EXISTS test_suite_schedules ( + id TEXT PRIMARY KEY, + script_id TEXT NOT NULL, + project_id TEXT NOT NULL, + interval_ms INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + last_run_at TEXT, + next_run_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_test_suite_schedules_project ON test_suite_schedules(project_id); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_test_suite_schedules_script ON test_suite_schedules(script_id); diff --git a/drizzle/0020_add_runners.sql b/drizzle/0020_add_runners.sql new file mode 100644 index 00000000..f578d9c2 --- /dev/null +++ b/drizzle/0020_add_runners.sql @@ -0,0 +1,37 @@ +-- drizzle/0020_add_runners.sql +CREATE TABLE IF NOT EXISTS runner_profiles ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + command TEXT NOT NULL, + cwd_relative TEXT NOT NULL DEFAULT '.', + env_json TEXT NOT NULL DEFAULT '{}', + health_check_url TEXT, + health_check_timeout_ms INTEGER NOT NULL DEFAULT 30000, + auto_restart INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_runner_profiles_project ON runner_profiles(project_id); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS runner_instances ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + scope_kind TEXT NOT NULL, + scope_project_id TEXT NOT NULL, + scope_worktree_path TEXT, + status TEXT NOT NULL, + pid INTEGER, + resolved_cwd TEXT NOT NULL, + resolved_command TEXT NOT NULL, + exit_code INTEGER, + started_at TEXT, + ready_at TEXT, + stopped_at TEXT, + last_error TEXT +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_runner_instances_profile ON runner_instances(profile_id); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_runner_instances_scope ON runner_instances(scope_project_id, scope_kind); diff --git a/drizzle/0021_add_project_id_to_progress_tasks.sql b/drizzle/0021_add_project_id_to_progress_tasks.sql new file mode 100644 index 00000000..e525bc93 --- /dev/null +++ b/drizzle/0021_add_project_id_to_progress_tasks.sql @@ -0,0 +1,6 @@ +-- drizzle/0021_add_project_id_to_progress_tasks.sql +-- Scope progress tasks to a specific project so each project's Tasks tab +-- renders only its own list instead of a shared global one. +ALTER TABLE progress_tasks ADD COLUMN project_id TEXT; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_progress_tasks_project ON progress_tasks(project_id); diff --git a/drizzle/0022_test_suite_scripts_columns.sql b/drizzle/0022_test_suite_scripts_columns.sql new file mode 100644 index 00000000..8c8a976e --- /dev/null +++ b/drizzle/0022_test_suite_scripts_columns.sql @@ -0,0 +1,11 @@ +-- Migration: Add missing columns to test_suite_scripts +-- The 0016 migration renamed qa_scripts → test_suite_scripts but never added +-- the columns expected by the current Drizzle schema. + +ALTER TABLE test_suite_scripts ADD COLUMN target_url TEXT NOT NULL DEFAULT ''; +--> statement-breakpoint +ALTER TABLE test_suite_scripts ADD COLUMN step_count INTEGER NOT NULL DEFAULT 0; +--> statement-breakpoint +ALTER TABLE test_suite_scripts ADD COLUMN last_status TEXT; +--> statement-breakpoint +ALTER TABLE test_suite_scripts ADD COLUMN last_run_at TEXT; diff --git a/drizzle/0023_test_suite_description.sql b/drizzle/0023_test_suite_description.sql new file mode 100644 index 00000000..865862c0 --- /dev/null +++ b/drizzle/0023_test_suite_description.sql @@ -0,0 +1,3 @@ +-- Migration: Add description column to test_suite_scripts + +ALTER TABLE test_suite_scripts ADD COLUMN description TEXT; diff --git a/drizzle/0024_test_suite_runs_columns.sql b/drizzle/0024_test_suite_runs_columns.sql new file mode 100644 index 00000000..20940feb --- /dev/null +++ b/drizzle/0024_test_suite_runs_columns.sql @@ -0,0 +1,11 @@ +-- Migration: Add missing columns to test_suite_runs +-- The original qa_runs table (0009) was renamed to test_suite_runs (0016) +-- but never got the columns the Drizzle schema expects. + +ALTER TABLE test_suite_runs ADD COLUMN duration_ms INTEGER NOT NULL DEFAULT 0; +--> statement-breakpoint +ALTER TABLE test_suite_runs ADD COLUMN steps_passed INTEGER NOT NULL DEFAULT 0; +--> statement-breakpoint +ALTER TABLE test_suite_runs ADD COLUMN steps_failed INTEGER NOT NULL DEFAULT 0; +--> statement-breakpoint +ALTER TABLE test_suite_runs ADD COLUMN output TEXT; diff --git a/drizzle/0025_test_suite_tags.sql b/drizzle/0025_test_suite_tags.sql new file mode 100644 index 00000000..8a1db3ef --- /dev/null +++ b/drizzle/0025_test_suite_tags.sql @@ -0,0 +1 @@ +ALTER TABLE test_suite_scripts ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'; diff --git a/drizzle/0026_op_log_and_row_meta.sql b/drizzle/0026_op_log_and_row_meta.sql new file mode 100644 index 00000000..15c9351f --- /dev/null +++ b/drizzle/0026_op_log_and_row_meta.sql @@ -0,0 +1,23 @@ +CREATE TABLE op_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hlc TEXT NOT NULL, + origin_peer_id TEXT NOT NULL, + table_name TEXT NOT NULL, + pk TEXT NOT NULL, + op_type TEXT NOT NULL, + payload TEXT NOT NULL, + applied_at INTEGER NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX op_log_dedup ON op_log(origin_peer_id, hlc); +--> statement-breakpoint +CREATE TABLE row_meta ( + table_name TEXT NOT NULL, + pk TEXT NOT NULL, + column_name TEXT NOT NULL, + hlc TEXT NOT NULL, + origin_peer_id TEXT NOT NULL, + PRIMARY KEY (table_name, pk, column_name) +); +--> statement-breakpoint +CREATE INDEX row_meta_by_row ON row_meta(table_name, pk); diff --git a/drizzle/0027_workflow_runs_summary.sql b/drizzle/0027_workflow_runs_summary.sql new file mode 100644 index 00000000..446f7a67 --- /dev/null +++ b/drizzle/0027_workflow_runs_summary.sql @@ -0,0 +1,15 @@ +CREATE TABLE workflow_runs_summary ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + task_id TEXT, + workflow_id TEXT, + status TEXT NOT NULL, + started_at INTEGER, + finished_at INTEGER, + summary TEXT, + ran_on_peer_id TEXT NOT NULL +); +--> statement-breakpoint +CREATE INDEX workflow_runs_summary_by_project ON workflow_runs_summary(project_id); +--> statement-breakpoint +CREATE INDEX workflow_runs_summary_by_task ON workflow_runs_summary(task_id); diff --git a/drizzle/0028_peer_state.sql b/drizzle/0028_peer_state.sql new file mode 100644 index 00000000..d24375f0 --- /dev/null +++ b/drizzle/0028_peer_state.sql @@ -0,0 +1,10 @@ +CREATE TABLE peer_state ( + peer_id TEXT PRIMARY KEY, + display_name TEXT, + pubkey TEXT NOT NULL, + cert_fingerprint TEXT NOT NULL, + last_seen_hlc TEXT, + paired_at INTEGER NOT NULL, + last_connected_at INTEGER, + revoked_at INTEGER +); diff --git a/drizzle/0029_projects.sql b/drizzle/0029_projects.sql new file mode 100644 index 00000000..67274692 --- /dev/null +++ b/drizzle/0029_projects.sql @@ -0,0 +1,26 @@ +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + workspace_id TEXT, + name TEXT NOT NULL, + description TEXT, + root_path TEXT NOT NULL, + git_url TEXT, + repo_structure TEXT NOT NULL DEFAULT 'single', + default_branch TEXT NOT NULL DEFAULT 'main', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +--> statement-breakpoint +CREATE INDEX idx_projects_workspace ON projects(workspace_id); +--> statement-breakpoint +CREATE TABLE sub_projects ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + relative_path TEXT NOT NULL, + git_url TEXT, + default_branch TEXT NOT NULL DEFAULT 'main', + created_at TEXT NOT NULL +); +--> statement-breakpoint +CREATE INDEX idx_sub_projects_project ON sub_projects(project_id); diff --git a/drizzle/0030_op_log_hlc_index.sql b/drizzle/0030_op_log_hlc_index.sql new file mode 100644 index 00000000..172130c4 --- /dev/null +++ b/drizzle/0030_op_log_hlc_index.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS op_log_by_hlc ON op_log(hlc); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3548961e..d82b3c45 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -106,6 +106,118 @@ "when": 1775843500000, "tag": "0014_add_workspaces_table", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1775843600000, + "tag": "0015_drop_milestones", + "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1775843700000, + "tag": "0016_test_suite", + "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1775843800000, + "tag": "0017_add_baselines_diffs", + "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1775843900000, + "tag": "0018_add_shared_steps", + "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1775844000000, + "tag": "0019_add_schedules", + "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1776000000000, + "tag": "0020_add_runners", + "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1776100000000, + "tag": "0021_add_project_id_to_progress_tasks", + "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1776200000000, + "tag": "0022_test_suite_scripts_columns", + "breakpoints": true + }, + { + "idx": 23, + "version": "6", + "when": 1776300000000, + "tag": "0023_test_suite_description", + "breakpoints": true + }, + { + "idx": 24, + "version": "6", + "when": 1776400000000, + "tag": "0024_test_suite_runs_columns", + "breakpoints": true + }, + { + "idx": 25, + "version": "6", + "when": 1776500000000, + "tag": "0025_test_suite_tags", + "breakpoints": true + }, + { + "idx": 26, + "version": "6", + "when": 1776600000000, + "tag": "0026_op_log_and_row_meta", + "breakpoints": true + }, + { + "idx": 27, + "version": "6", + "when": 1777067549736, + "tag": "0027_workflow_runs_summary", + "breakpoints": true + }, + { + "idx": 28, + "version": "6", + "when": 1777069738667, + "tag": "0028_peer_state", + "breakpoints": true + }, + { + "idx": 29, + "version": "6", + "when": 1777200000000, + "tag": "0029_projects", + "breakpoints": true + }, + { + "idx": 30, + "version": "6", + "when": 1777300000000, + "tag": "0030_op_log_hlc_index", + "breakpoints": true } ] } diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 9534d7e2..2df5259c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -2,9 +2,14 @@ import { resolve } from 'path'; import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; import react from '@vitejs/plugin-react'; +const BAKED_CHANNEL = process.env.ADC_CHANNEL ?? ''; + export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], + define: { + __ADC_CHANNEL__: JSON.stringify(BAKED_CHANNEL), + }, build: { lib: { entry: resolve(__dirname, 'src/main/index.ts'), @@ -33,10 +38,18 @@ export default defineConfig({ }, preload: { plugins: [externalizeDepsPlugin()], + define: { + __ADC_CHANNEL__: JSON.stringify(BAKED_CHANNEL), + }, build: { rollupOptions: { input: { index: resolve(__dirname, 'src/preload/index.ts'), + 'test-suite-recorder': resolve(__dirname, 'src/preload/test-suite-recorder.ts'), + }, + output: { + format: 'cjs', + entryFileNames: '[name].cjs', }, }, }, diff --git a/eslint.config.js b/eslint.config.js index fb5ba0ae..2ca2d155 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -367,6 +367,24 @@ export default tseslint.config( ...globals.browser, }, }, + rules: { + // Forbid hardcoded route strings — must use ROUTES / ROUTE_PATTERNS from @shared/constants + 'no-restricted-syntax': [ + 'error', + { + selector: + "Property[key.name='to'] > Literal[value=/^\\/(?!.*\\$).+/]", + message: + "Hardcoded route string in navigate({ to }). Import ROUTES or ROUTE_PATTERNS from '@shared/constants'.", + }, + { + selector: + "JSXAttribute[name.name='to'] > Literal[value=/^\\/(?!.*\\$).+/]", + message: + "Hardcoded route string in . Import ROUTES or ROUTE_PATTERNS from '@shared/constants'.", + }, + ], + }, }, // ── Test file overrides ──────────────────────────────────────── diff --git a/hub/.dockerignore b/hub/.dockerignore deleted file mode 100644 index 4f3998a0..00000000 --- a/hub/.dockerignore +++ /dev/null @@ -1,10 +0,0 @@ -node_modules -dist -*.md -.gitignore -.env -.env.* -*.log -tests/ -coverage/ -.git diff --git a/hub/.env.example b/hub/.env.example deleted file mode 100644 index d2fe2c0a..00000000 --- a/hub/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# Claude-UI Hub Environment Variables -# Copy this file to .env and customize as needed - -# Server Configuration -# -------------------- -# Port to listen on (default: 3200) -PORT=3200 - -# Host to bind to (default: 0.0.0.0 - all interfaces) -HOST=0.0.0.0 - -# Security Configuration -# ---------------------- -# Bootstrap secret for generating the first API key -# If set, POST /api/auth/generate-key requires X-Bootstrap-Secret header -# If not set, the endpoint is open (for development convenience) -# Generate a secure secret: openssl rand -hex 32 -HUB_BOOTSTRAP_SECRET= - -# CORS Configuration -# ------------------ -# Additional allowed origins (comma-separated) -# localhost:*, 127.0.0.1:*, and file:// are always allowed -# Example: https://my-dashboard.example.com,https://another-app.example.com -HUB_ALLOWED_ORIGINS= diff --git a/hub/.gitignore b/hub/.gitignore deleted file mode 100644 index e129183f..00000000 --- a/hub/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -dist/ -data/ diff --git a/hub/Dockerfile b/hub/Dockerfile deleted file mode 100644 index 40983da1..00000000 --- a/hub/Dockerfile +++ /dev/null @@ -1,39 +0,0 @@ -# ADC Hub — Docker image -# Build: docker build -t parkerm2/adc-hub:latest . -# Run: docker run -d -p 3200:3200 --name adc-hub parkerm2/adc-hub:latest - -# Stage 1: Build TypeScript -FROM node:22-alpine AS builder -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY tsconfig.json ./ -COPY src/ ./src/ -RUN npm run build - -# Stage 2: Production runtime -FROM node:22-alpine -WORKDIR /app - -# Create non-root user for security -RUN addgroup -S appgroup && adduser -S appuser -G appgroup - -COPY package*.json ./ -RUN npm ci --omit=dev -COPY --from=builder /app/dist/ ./dist/ -# DB migrations + schema resolve via __dirname from dist/db/ -COPY src/db/migrations/ ./dist/db/migrations/ -COPY src/db/schema.sql ./dist/db/schema.sql -RUN mkdir -p /app/data && chown -R appuser:appgroup /app/data - -ENV PORT=3200 -ENV HOST=0.0.0.0 -EXPOSE 3200 - -# Switch to non-root user -USER appuser - -HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ - CMD wget -qO- http://localhost:3200/api/health || exit 1 - -CMD ["node", "dist/index.js"] diff --git a/hub/README.md b/hub/README.md deleted file mode 100644 index 1a5f8055..00000000 --- a/hub/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Claude-UI Hub - -Self-hosted sync server for Claude-UI. Runs on your local network to sync -projects, tasks, planner, and settings across multiple devices. - -## Quick Start - -### Option 1: Docker (Recommended) - -1. Generate TLS certificates: - ```bash - # Linux/Mac - ./scripts/generate-certs.sh - # Windows - powershell ./scripts/generate-certs.ps1 - ``` - -2. Start the hub: - ```bash - docker compose up -d - ``` - -3. Generate your API key: - ```bash - curl -X POST http://localhost:3200/api/auth/generate-key - ``` - -4. Connect from Claude-UI: - - Open Settings > Hub Connection - - Enter: https://YOUR_IP:3443 - - Paste the API key - -### Option 2: Run Directly - -1. Install dependencies: - ```bash - cd hub && npm install - ``` - -2. Start dev server: - ```bash - npm run dev - ``` - -Server runs on port 3200 by default. - -## Architecture - -- **Fastify** REST API + WebSocket -- **SQLite** (better-sqlite3) for data storage -- **nginx** reverse proxy with TLS -- **Docker Compose** for deployment - -## API - -All endpoints require `X-API-Key` header (except initial key generation). - -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET/POST | /api/projects | List/create projects | -| GET/POST | /api/tasks | List/create tasks | -| GET/PUT | /api/settings | Get/update settings | -| GET/POST | /api/planner/events | Planner time blocks | -| GET/POST | /api/captures | Quick captures | -| GET/POST | /api/agent-runs | Agent run history | -| WS | /ws | Real-time sync | diff --git a/hub/package-lock.json b/hub/package-lock.json deleted file mode 100644 index 8770de3f..00000000 --- a/hub/package-lock.json +++ /dev/null @@ -1,2132 +0,0 @@ -{ - "name": "claude-ui-hub", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "claude-ui-hub", - "version": "0.1.0", - "dependencies": { - "@fastify/cors": "^10.0.0", - "@fastify/rate-limit": "^10.0.0", - "@fastify/websocket": "^11.0.0", - "@node-rs/argon2": "^2.0.2", - "better-sqlite3": "^11.0.0", - "fastify": "^5.0.0", - "jose": "^6.1.3", - "nanoid": "^5.0.0" - }, - "devDependencies": { - "@types/better-sqlite3": "^7.6.0", - "@types/node": "^22.0.0", - "@types/ws": "^8.18.1", - "tsx": "^4.0.0", - "typescript": "^5.9.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@fastify/ajv-compiler": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", - "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0" - } - }, - "node_modules/@fastify/cors": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", - "integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fastify-plugin": "^5.0.0", - "mnemonist": "0.40.0" - } - }, - "node_modules/@fastify/error": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", - "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", - "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fast-json-stringify": "^6.0.0" - } - }, - "node_modules/@fastify/forwarded": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", - "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/merge-json-schemas": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", - "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/forwarded": "^3.0.0", - "ipaddr.js": "^2.1.0" - } - }, - "node_modules/@fastify/rate-limit": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", - "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@lukeed/ms": "^2.0.2", - "fastify-plugin": "^5.0.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/@fastify/websocket": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", - "integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "duplexify": "^4.1.3", - "fastify-plugin": "^5.0.0", - "ws": "^8.16.0" - } - }, - "node_modules/@lukeed/ms": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", - "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@node-rs/argon2": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz", - "integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==", - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@node-rs/argon2-android-arm-eabi": "2.0.2", - "@node-rs/argon2-android-arm64": "2.0.2", - "@node-rs/argon2-darwin-arm64": "2.0.2", - "@node-rs/argon2-darwin-x64": "2.0.2", - "@node-rs/argon2-freebsd-x64": "2.0.2", - "@node-rs/argon2-linux-arm-gnueabihf": "2.0.2", - "@node-rs/argon2-linux-arm64-gnu": "2.0.2", - "@node-rs/argon2-linux-arm64-musl": "2.0.2", - "@node-rs/argon2-linux-x64-gnu": "2.0.2", - "@node-rs/argon2-linux-x64-musl": "2.0.2", - "@node-rs/argon2-wasm32-wasi": "2.0.2", - "@node-rs/argon2-win32-arm64-msvc": "2.0.2", - "@node-rs/argon2-win32-ia32-msvc": "2.0.2", - "@node-rs/argon2-win32-x64-msvc": "2.0.2" - } - }, - "node_modules/@node-rs/argon2-android-arm-eabi": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz", - "integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-android-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz", - "integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-darwin-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz", - "integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-darwin-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz", - "integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-freebsd-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz", - "integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz", - "integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm64-gnu": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz", - "integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm64-musl": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz", - "integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-x64-gnu": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz", - "integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-x64-musl": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz", - "integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-wasm32-wasi": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz", - "integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.5" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@node-rs/argon2-win32-arm64-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz", - "integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-win32-ia32-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz", - "integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-win32-x64-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz", - "integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/node": { - "version": "22.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", - "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/abstract-logging": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", - "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", - "license": "MIT" - }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/avvio": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz", - "integrity": "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==", - "license": "MIT", - "dependencies": { - "@fastify/error": "^4.0.0", - "fastq": "^1.17.1" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/duplexify": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", - "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.2" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stringify": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", - "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0", - "json-schema-ref-resolver": "^3.0.0", - "rfdc": "^1.2.0" - } - }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "license": "MIT", - "dependencies": { - "fast-decode-uri-component": "^1.0.1" - } - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastify": { - "version": "5.7.4", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", - "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/ajv-compiler": "^4.0.5", - "@fastify/error": "^4.0.0", - "@fastify/fast-json-stringify-compiler": "^5.0.0", - "@fastify/proxy-addr": "^5.0.0", - "abstract-logging": "^2.0.1", - "avvio": "^9.0.0", - "fast-json-stringify": "^6.0.0", - "find-my-way": "^9.0.0", - "light-my-request": "^6.0.0", - "pino": "^10.1.0", - "process-warning": "^5.0.0", - "rfdc": "^1.3.1", - "secure-json-parse": "^4.0.0", - "semver": "^7.6.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, - "node_modules/find-my-way": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", - "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/light-my-request": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", - "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "dependencies": { - "cookie": "^1.0.1", - "process-warning": "^4.0.0", - "set-cookie-parser": "^2.6.0" - } - }, - "node_modules/light-my-request/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/mnemonist": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz", - "integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==", - "license": "MIT", - "dependencies": { - "obliterator": "^2.0.4" - } - }, - "node_modules/nanoid": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", - "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/obliterator": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", - "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", - "license": "MIT" - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/pino": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", - "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^4.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-regex2": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", - "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/stream-shift": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", - "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/thread-stream": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", - "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/toad-cache": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", - "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/hub/package.json b/hub/package.json deleted file mode 100644 index 35e8b42d..00000000 --- a/hub/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "claude-ui-hub", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc", - "start": "node dist/index.js", - "lint": "eslint src/", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@fastify/cors": "^10.0.0", - "@fastify/rate-limit": "^10.0.0", - "@fastify/websocket": "^11.0.0", - "@node-rs/argon2": "^2.0.2", - "better-sqlite3": "^11.0.0", - "fastify": "^5.0.0", - "jose": "^6.1.3", - "nanoid": "^5.0.0" - }, - "devDependencies": { - "@types/better-sqlite3": "^7.6.0", - "@types/node": "^22.0.0", - "@types/ws": "^8.18.1", - "tsx": "^4.0.0", - "typescript": "^5.9.0" - } -} diff --git a/hub/src/app.ts b/hub/src/app.ts deleted file mode 100644 index b598864c..00000000 --- a/hub/src/app.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { join } from 'node:path'; - -import cors from '@fastify/cors'; -import rateLimit from '@fastify/rate-limit'; -import websocket from '@fastify/websocket'; -import Fastify from 'fastify'; - -import { createDatabase } from './db/database.js'; -import { verifyAccessToken } from './lib/jwt.js'; -import { createApiKeyMiddleware, hashKey } from './middleware/api-key.js'; -import { createJwtAuthMiddleware } from './middleware/jwt-auth.js'; -import { agentRoutes } from './routes/agents.js'; -import { authRoutes } from './routes/auth.js'; -import { captureRoutes } from './routes/captures.js'; -import { deviceRoutes } from './routes/devices.js'; -import { plannerRoutes } from './routes/planner.js'; -import { projectRoutes } from './routes/projects.js'; -import { workspaceRoutes } from './routes/workspaces.js'; -import { settingsRoutes } from './routes/settings.js'; -import { taskRoutes } from './routes/tasks.js'; -import { webhookRoutes } from './routes/webhooks/index.js'; -import { addAuthenticatedClient } from './ws/broadcaster.js'; - -import type Database from 'better-sqlite3'; - -/** - * Validate CORS origin. - * Allows: localhost:*, 127.0.0.1:*, file://, and any origins in HUB_ALLOWED_ORIGINS env var. - */ -function isAllowedOrigin(origin: string | undefined): boolean { - // If no origin (same-origin request or non-browser), allow - if (!origin) { - return true; - } - - // Allow localhost and 127.0.0.1 (any port) - if ( - origin.startsWith('http://localhost:') || - origin.startsWith('https://localhost:') || - origin.startsWith('http://127.0.0.1:') || - origin.startsWith('https://127.0.0.1:') || - origin.startsWith('file://') - ) { - return true; - } - - // Check custom allowed origins from env - const allowedOriginsEnv = process.env.HUB_ALLOWED_ORIGINS; - if (allowedOriginsEnv) { - const allowedOrigins = allowedOriginsEnv.split(',').map((o) => o.trim()); - if (allowedOrigins.includes(origin)) { - return true; - } - } - - return false; -} - -/** WebSocket close codes for authentication errors. */ -const WS_CLOSE_INVALID_KEY = 4001; -const WS_CLOSE_AUTH_TIMEOUT = 4002; - -/** Timeout for receiving auth message (5 seconds). */ -const WS_AUTH_TIMEOUT_MS = 5000; - -interface WsApiKeyAuthMessage { - type: 'auth'; - apiKey: string; -} - -interface WsBearerAuthMessage { - type: 'auth'; - bearerToken: string; -} - -type WsAuthMessage = WsApiKeyAuthMessage | WsBearerAuthMessage; - -function isValidAuthMessage(data: unknown): data is WsAuthMessage { - if (typeof data !== 'object' || data === null) { - return false; - } - const obj = data as Record; - if (obj.type !== 'auth') { - return false; - } - return typeof obj.apiKey === 'string' || typeof obj.bearerToken === 'string'; -} - -/** - * Handle WebSocket authentication using first-message protocol. - * Client must send one of: - * { type: "auth", apiKey: "..." } — legacy API key auth - * { type: "auth", bearerToken: "..." } — JWT bearer token auth - * within 5 seconds. - * - Valid credentials: add to broadcast pool - * - Invalid credentials: close with code 4001 - * - Timeout: close with code 4002 - */ -function handleWebSocketAuth( - socket: import('ws').WebSocket, - db: Database.Database, -): void { - let authenticated = false; - - // Set up auth timeout - const timeoutId = setTimeout(() => { - if (!authenticated) { - console.log('[WS] Auth timeout — closing connection'); - socket.close(WS_CLOSE_AUTH_TIMEOUT, 'Auth timeout'); - } - }, WS_AUTH_TIMEOUT_MS); - - function authSuccess(): void { - authenticated = true; - clearTimeout(timeoutId); - console.log('[WS] Client authenticated'); - addAuthenticatedClient(socket); - } - - function authFailure(reason: string): void { - console.log(`[WS] ${reason}`); - clearTimeout(timeoutId); - socket.close(WS_CLOSE_INVALID_KEY, reason); - } - - // Handle first message for auth - const handleMessage = (rawData: import('ws').RawData): void => { - // Only process first message for auth - if (authenticated) { - return; - } - - try { - const data = JSON.parse(String(rawData)) as unknown; - - if (!isValidAuthMessage(data)) { - authFailure('Invalid auth message'); - return; - } - - // JWT bearer token auth - if ('bearerToken' in data && typeof data.bearerToken === 'string') { - void (async () => { - try { - const payload = await verifyAccessToken(data.bearerToken); - if (payload) { - authSuccess(); - } else { - authFailure('Invalid bearer token'); - } - } catch { - authFailure('Bearer token verification failed'); - } - })(); - return; - } - - // Legacy API key auth - if ('apiKey' in data && typeof data.apiKey === 'string') { - const keyHash = hashKey(data.apiKey); - const row = db - .prepare('SELECT id FROM api_keys WHERE key_hash = ?') - .get(keyHash) as { id: string } | undefined; - - if (!row) { - authFailure('Invalid API key'); - return; - } - - authSuccess(); - return; - } - - authFailure('Invalid auth message'); - } catch { - authFailure('Failed to parse auth message'); - } - }; - - socket.on('message', handleMessage); - - // Clean up timeout on close - socket.on('close', () => { - clearTimeout(timeoutId); - }); -} - -export async function buildApp(dbPath?: string): Promise> { - const app = Fastify({ logger: true }); - - // Database - const resolvedDbPath = dbPath ?? join(process.cwd(), 'data', 'claude-ui.db'); - const db = createDatabase(resolvedDbPath); - - // Decorate Fastify instance with db - app.decorate('db', db); - - // Graceful shutdown — close db on server close - app.addHook('onClose', () => { - db.close(); - }); - - // CORS — explicit origins only - await app.register(cors, { - origin: (origin, callback) => { - if (isAllowedOrigin(origin)) { - callback(null, true); - } else { - callback(new Error('Not allowed by CORS'), false); - } - }, - }); - - // Rate limiting — global limit: 100 requests/minute per IP - await app.register(rateLimit, { - max: 100, - timeWindow: '1 minute', - // Skip rate limiting for WebSocket upgrade requests - keyGenerator: (request) => request.ip, - }); - - // WebSocket - await app.register(websocket); - - // API key auth middleware (for legacy/hybrid routes) - app.addHook('onRequest', createApiKeyMiddleware(db)); - - // JWT auth middleware (for new user-based auth) - app.addHook('onRequest', createJwtAuthMiddleware()); - - // WebSocket route with first-message auth protocol - app.register(async (wsApp) => { - wsApp.get('/ws', { websocket: true }, (socket) => { - handleWebSocketAuth(socket, db); - }); - }); - - // REST routes - await app.register(projectRoutes); - await app.register(taskRoutes); - await app.register(settingsRoutes); - await app.register(plannerRoutes); - await app.register(captureRoutes); - await app.register(agentRoutes); - await app.register(webhookRoutes); - await app.register(deviceRoutes); - await app.register(workspaceRoutes); - - // Auth routes with stricter rate limiting (10 requests/minute per IP) - await app.register( - async (authApp) => { - await authApp.register(rateLimit, { - max: 10, - timeWindow: '1 minute', - keyGenerator: (request) => request.ip, - }); - await authApp.register(authRoutes); - }, - ); - - // Health check - app.get('/api/health', async () => { - return { status: 'ok', timestamp: new Date().toISOString() }; - }); - - return app; -} diff --git a/hub/src/db/database.ts b/hub/src/db/database.ts deleted file mode 100644 index 03d8d4df..00000000 --- a/hub/src/db/database.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { mkdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import Database from 'better-sqlite3'; - -import { runMigrations } from './migration-runner.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export function createDatabase(dbPath: string): Database.Database { - // Ensure the directory exists - mkdirSync(dirname(dbPath), { recursive: true }); - - const db = new Database(dbPath); - - // Enable WAL mode for better concurrent read performance - db.pragma('journal_mode = WAL'); - // Enable foreign keys - db.pragma('foreign_keys = ON'); - - // Run versioned schema migrations - const migrationsDir = join(__dirname, 'migrations'); - runMigrations(db, migrationsDir); - - return db; -} diff --git a/hub/src/db/migration-runner.ts b/hub/src/db/migration-runner.ts deleted file mode 100644 index 24b949bf..00000000 --- a/hub/src/db/migration-runner.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import type Database from 'better-sqlite3'; - -interface SchemaVersionRow { - version: number | null; -} - -export function runMigrations(db: Database.Database, migrationsDir: string): void { - // Bootstrap: create schema_version table outside of migrations - db.exec(` - CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, - name TEXT NOT NULL, - applied_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - `); - - // Get current schema version - const row = db.prepare('SELECT MAX(version) as version FROM schema_version').get() as - | SchemaVersionRow - | undefined; - const current = row?.version ?? 0; - - // Read migration files sorted by numeric prefix - const files = readdirSync(migrationsDir) - .filter((f) => f.endsWith('.sql')) - .sort(); - - // Apply pending migrations in order - for (const file of files) { - const prefix = file.split('_')[0]; - if (prefix === undefined) continue; - - const version = Number.parseInt(prefix, 10); - if (Number.isNaN(version) || version <= current) continue; - - const sql = readFileSync(join(migrationsDir, file), 'utf-8'); - const name = file.replace('.sql', ''); - - // Each migration runs in a transaction for atomicity - const applyMigration = db.transaction(() => { - db.exec(sql); - db.prepare('INSERT INTO schema_version (version, name) VALUES (?, ?)').run(version, name); - }); - - applyMigration(); - console.log(`[Migration] Applied: ${name}`); - } -} diff --git a/hub/src/db/migrations/001_initial_schema.sql b/hub/src/db/migrations/001_initial_schema.sql deleted file mode 100644 index 66e86b56..00000000 --- a/hub/src/db/migrations/001_initial_schema.sql +++ /dev/null @@ -1,90 +0,0 @@ --- Claude-UI Hub Database Schema - -CREATE TABLE IF NOT EXISTS projects ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - path TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS tasks ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'backlog', - priority INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS subtasks ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - sort_order INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS planner_events ( - id TEXT PRIMARY KEY, - date TEXT NOT NULL, - start_time TEXT NOT NULL, - end_time TEXT NOT NULL, - title TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'personal', - task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS captures ( - id TEXT PRIMARY KEY, - text TEXT NOT NULL, - project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS agent_runs ( - id TEXT PRIMARY KEY, - task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, - project_id TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'running', - started_at TEXT NOT NULL DEFAULT (datetime('now')), - completed_at TEXT, - tokens_used INTEGER DEFAULT 0, - cost_usd REAL DEFAULT 0, - log TEXT DEFAULT '' -); - -CREATE TABLE IF NOT EXISTS api_keys ( - id TEXT PRIMARY KEY, - key_hash TEXT NOT NULL, - name TEXT NOT NULL DEFAULT 'default', - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS webhook_commands ( - id TEXT PRIMARY KEY, - source TEXT NOT NULL CHECK(source IN ('slack', 'github', 'manual')), - source_id TEXT, - source_channel TEXT, - source_url TEXT, - command_text TEXT NOT NULL, - project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, - status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'processed', 'failed')), - result_text TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - processed_at TEXT -); - -CREATE INDEX IF NOT EXISTS idx_webhook_commands_source ON webhook_commands(source); -CREATE INDEX IF NOT EXISTS idx_webhook_commands_status ON webhook_commands(status); diff --git a/hub/src/db/migrations/002_user_auth_devices.sql b/hub/src/db/migrations/002_user_auth_devices.sql deleted file mode 100644 index 2774cb40..00000000 --- a/hub/src/db/migrations/002_user_auth_devices.sql +++ /dev/null @@ -1,121 +0,0 @@ --- User Authentication & Multi-Device Support --- Migration 002: Users, Sessions, Devices, Workspaces, Sub-projects - --- ═══════════════════════════════════════════════════════════════════ --- USERS --- ═══════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - display_name TEXT NOT NULL, - avatar_url TEXT, - settings TEXT, -- JSON blob for user preferences - created_at TEXT NOT NULL DEFAULT (datetime('now')), - last_login_at TEXT -); - -CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); - --- ═══════════════════════════════════════════════════════════════════ --- SESSIONS (JWT refresh tokens) --- ═══════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - device_id TEXT REFERENCES devices(id) ON DELETE SET NULL, - refresh_token_hash TEXT NOT NULL, - expires_at TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - last_used_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); -CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); - --- ═══════════════════════════════════════════════════════════════════ --- DEVICES --- ═══════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS devices ( - id TEXT PRIMARY KEY, - machine_id TEXT, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - device_type TEXT NOT NULL CHECK(device_type IN ('desktop', 'mobile', 'web')), - device_name TEXT NOT NULL, - capabilities TEXT NOT NULL DEFAULT '{}', -- JSON: { canExecute: boolean, repos: string[] } - is_online INTEGER NOT NULL DEFAULT 0, - last_seen TEXT, - app_version TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(machine_id) -- machine_id unique if not null -); - -CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id); -CREATE INDEX IF NOT EXISTS idx_devices_machine_id ON devices(machine_id); - --- ═══════════════════════════════════════════════════════════════════ --- WORKSPACES --- ═══════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS workspaces ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - host_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL, - settings TEXT, -- JSON blob for workspace settings - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX IF NOT EXISTS idx_workspaces_user_id ON workspaces(user_id); -CREATE INDEX IF NOT EXISTS idx_workspaces_host_device_id ON workspaces(host_device_id); - --- ═══════════════════════════════════════════════════════════════════ --- UPDATE PROJECTS TABLE --- Add workspace association, git info, repo structure --- ═══════════════════════════════════════════════════════════════════ - -ALTER TABLE projects ADD COLUMN workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL; -ALTER TABLE projects ADD COLUMN git_url TEXT; -ALTER TABLE projects ADD COLUMN repo_structure TEXT NOT NULL DEFAULT 'single' CHECK(repo_structure IN ('single', 'monorepo', 'multi-repo')); -ALTER TABLE projects ADD COLUMN default_branch TEXT DEFAULT 'main'; -ALTER TABLE projects ADD COLUMN description TEXT; - -CREATE INDEX IF NOT EXISTS idx_projects_workspace_id ON projects(workspace_id); - --- ═══════════════════════════════════════════════════════════════════ --- SUB-PROJECTS (for multi-repo support) --- ═══════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS sub_projects ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - name TEXT NOT NULL, - relative_path TEXT NOT NULL, - git_url TEXT, - default_branch TEXT DEFAULT 'main', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(project_id, relative_path) -); - -CREATE INDEX IF NOT EXISTS idx_sub_projects_project_id ON sub_projects(project_id); - --- ═══════════════════════════════════════════════════════════════════ --- UPDATE TASKS TABLE --- Add workspace and sub-project associations --- ═══════════════════════════════════════════════════════════════════ - -ALTER TABLE tasks ADD COLUMN workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL; -ALTER TABLE tasks ADD COLUMN sub_project_id TEXT REFERENCES sub_projects(id) ON DELETE SET NULL; -ALTER TABLE tasks ADD COLUMN assigned_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL; -ALTER TABLE tasks ADD COLUMN created_by_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL; -ALTER TABLE tasks ADD COLUMN execution_session_id TEXT; -ALTER TABLE tasks ADD COLUMN progress TEXT; -- JSON blob for TaskProgress -ALTER TABLE tasks ADD COLUMN metadata TEXT; -- JSON blob for extra data - -CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id); -CREATE INDEX IF NOT EXISTS idx_tasks_sub_project_id ON tasks(sub_project_id); diff --git a/hub/src/db/migrations/005_tasks_enhanced.sql b/hub/src/db/migrations/005_tasks_enhanced.sql deleted file mode 100644 index bdb5f524..00000000 --- a/hub/src/db/migrations/005_tasks_enhanced.sql +++ /dev/null @@ -1,38 +0,0 @@ --- Enhanced Task Fields for AG-Grid Display --- Migration 005: Add agent_name, activity_history, cost tracking, PR info, completed_at, and task_sessions - --- ═══════════════════════════════════════════════════════════════════ --- UPDATE TASKS TABLE --- Add AG-Grid cell renderer fields --- ═══════════════════════════════════════════════════════════════════ - -ALTER TABLE tasks ADD COLUMN agent_name TEXT; -ALTER TABLE tasks ADD COLUMN activity_history TEXT DEFAULT '[]'; -ALTER TABLE tasks ADD COLUMN cost_tokens INTEGER DEFAULT 0; -ALTER TABLE tasks ADD COLUMN cost_usd REAL DEFAULT 0.0; -ALTER TABLE tasks ADD COLUMN pr_number INTEGER; -ALTER TABLE tasks ADD COLUMN pr_state TEXT; -ALTER TABLE tasks ADD COLUMN pr_ci_status TEXT; -ALTER TABLE tasks ADD COLUMN pr_url TEXT; -ALTER TABLE tasks ADD COLUMN completed_at TEXT; - --- ═══════════════════════════════════════════════════════════════════ --- TASK SESSIONS --- Track spawned execution processes per task per device --- ═══════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS task_sessions ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - device_id TEXT NOT NULL REFERENCES devices(id), - pid INTEGER, - status TEXT NOT NULL DEFAULT 'running', - started_at TEXT NOT NULL DEFAULT (datetime('now')), - ended_at TEXT, - token_usage INTEGER DEFAULT 0, - cost_usd REAL DEFAULT 0.0, - error_message TEXT -); - -CREATE INDEX IF NOT EXISTS idx_task_sessions_task_id ON task_sessions(task_id); -CREATE INDEX IF NOT EXISTS idx_task_sessions_device_id ON task_sessions(device_id); diff --git a/hub/src/db/schema.sql b/hub/src/db/schema.sql deleted file mode 100644 index abe42a8e..00000000 --- a/hub/src/db/schema.sql +++ /dev/null @@ -1,94 +0,0 @@ --- NOTE: This file is for reference only. --- Actual schema is managed by migrations in hub/src/db/migrations/ --- To add schema changes, create a new numbered migration file. --- --- Claude-UI Hub Database Schema - -CREATE TABLE IF NOT EXISTS projects ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - path TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS tasks ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'backlog', - priority INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS subtasks ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - sort_order INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS planner_events ( - id TEXT PRIMARY KEY, - date TEXT NOT NULL, - start_time TEXT NOT NULL, - end_time TEXT NOT NULL, - title TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'personal', - task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS captures ( - id TEXT PRIMARY KEY, - text TEXT NOT NULL, - project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS agent_runs ( - id TEXT PRIMARY KEY, - task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, - project_id TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'running', - started_at TEXT NOT NULL DEFAULT (datetime('now')), - completed_at TEXT, - tokens_used INTEGER DEFAULT 0, - cost_usd REAL DEFAULT 0, - log TEXT DEFAULT '' -); - -CREATE TABLE IF NOT EXISTS api_keys ( - id TEXT PRIMARY KEY, - key_hash TEXT NOT NULL, - name TEXT NOT NULL DEFAULT 'default', - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS webhook_commands ( - id TEXT PRIMARY KEY, - source TEXT NOT NULL CHECK(source IN ('slack', 'github', 'manual')), - source_id TEXT, - source_channel TEXT, - source_url TEXT, - command_text TEXT NOT NULL, - project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, - status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'processed', 'failed')), - result_text TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - processed_at TEXT -); - -CREATE INDEX IF NOT EXISTS idx_webhook_commands_source ON webhook_commands(source); -CREATE INDEX IF NOT EXISTS idx_webhook_commands_status ON webhook_commands(status); diff --git a/hub/src/index.ts b/hub/src/index.ts deleted file mode 100644 index e6c2aeab..00000000 --- a/hub/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { buildApp } from './app.js'; - -const PORT = Number(process.env['PORT']) || 3200; -const HOST = process.env['HOST'] ?? '0.0.0.0'; - -async function start(): Promise { - const app = await buildApp(); - - try { - await app.listen({ port: PORT, host: HOST }); - app.log.info(`Hub server listening on ${HOST}:${PORT}`); - } catch (error) { - app.log.error(error); - process.exit(1); - } -} - -void start(); diff --git a/hub/src/lib/fastify.d.ts b/hub/src/lib/fastify.d.ts deleted file mode 100644 index eac1f843..00000000 --- a/hub/src/lib/fastify.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type Database from 'better-sqlite3'; - -declare module 'fastify' { - interface FastifyInstance { - db: Database.Database; - } -} diff --git a/hub/src/lib/jwt.ts b/hub/src/lib/jwt.ts deleted file mode 100644 index 65a0fc38..00000000 --- a/hub/src/lib/jwt.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { createHash, randomBytes } from 'node:crypto'; - -import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; - -// ─── Configuration ──────────────────────────────────────────── - -const JWT_SECRET = process.env.JWT_SECRET ?? 'dev-secret-change-in-production'; -const ACCESS_TOKEN_EXPIRES = '15m'; -const REFRESH_TOKEN_EXPIRES = '7d'; - -// Convert secret to Uint8Array for jose -const secretKey = new TextEncoder().encode(JWT_SECRET); - -// ─── Types ──────────────────────────────────────────────────── - -export interface AccessTokenPayload extends JWTPayload { - sub: string; // user ID - deviceId?: string; - type: 'access'; -} - -export interface RefreshTokenPayload extends JWTPayload { - sub: string; // user ID - sessionId: string; - type: 'refresh'; -} - -export interface TokenPair { - accessToken: string; - refreshToken: string; - expiresAt: string; -} - -// ─── Token Generation ───────────────────────────────────────── - -export async function generateAccessToken( - userId: string, - deviceId?: string, -): Promise<{ token: string; expiresAt: string }> { - const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes - - const token = await new SignJWT({ - sub: userId, - deviceId, - type: 'access', - } satisfies AccessTokenPayload) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime(ACCESS_TOKEN_EXPIRES) - .sign(secretKey); - - return { token, expiresAt: expiresAt.toISOString() }; -} - -export async function generateRefreshToken( - userId: string, - sessionId: string, -): Promise { - const token = await new SignJWT({ - sub: userId, - sessionId, - type: 'refresh', - } satisfies RefreshTokenPayload) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime(REFRESH_TOKEN_EXPIRES) - .sign(secretKey); - - return token; -} - -export async function generateTokenPair( - userId: string, - sessionId: string, - deviceId?: string, -): Promise { - const { token: accessToken, expiresAt } = await generateAccessToken(userId, deviceId); - const refreshToken = await generateRefreshToken(userId, sessionId); - - return { - accessToken, - refreshToken, - expiresAt, - }; -} - -// ─── Token Verification ─────────────────────────────────────── - -export async function verifyAccessToken(token: string): Promise { - try { - const { payload } = await jwtVerify(token, secretKey); - - if (payload.type !== 'access') { - return null; - } - - return payload as AccessTokenPayload; - } catch { - return null; - } -} - -export async function verifyRefreshToken(token: string): Promise { - try { - const { payload } = await jwtVerify(token, secretKey); - - if (payload.type !== 'refresh') { - return null; - } - - return payload as RefreshTokenPayload; - } catch { - return null; - } -} - -// ─── Hash Utilities ─────────────────────────────────────────── - -/** - * Hash a refresh token for storage. - * Uses SHA-256 since we just need to verify equality, not reverse. - */ -export function hashToken(token: string): string { - return createHash('sha256').update(token).digest('hex'); -} - -/** - * Generate a random session ID. - */ -export function generateSessionId(): string { - return randomBytes(16).toString('hex'); -} - -/** - * Calculate session expiry date (7 days from now). - */ -export function getSessionExpiry(): string { - return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); -} diff --git a/hub/src/lib/password.ts b/hub/src/lib/password.ts deleted file mode 100644 index badb48f8..00000000 --- a/hub/src/lib/password.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { hash, verify } from '@node-rs/argon2'; - -// Argon2 configuration (OWASP recommended) -const ARGON2_OPTIONS = { - memoryCost: 65536, // 64 MB - timeCost: 3, - parallelism: 4, -}; - -/** - * Hash a password using Argon2id. - */ -export async function hashPassword(password: string): Promise { - return hash(password, ARGON2_OPTIONS); -} - -/** - * Verify a password against a hash. - */ -export async function verifyPassword(password: string, passwordHash: string): Promise { - try { - return await verify(passwordHash, password); - } catch { - return false; - } -} - -/** - * Validate password strength. - * Requirements: - * - At least 8 characters - * - At least one lowercase letter - * - At least one uppercase letter - * - At least one number - */ -export function validatePasswordStrength(password: string): { valid: boolean; reason?: string } { - if (password.length < 8) { - return { valid: false, reason: 'Password must be at least 8 characters long' }; - } - - if (!/[a-z]/.test(password)) { - return { valid: false, reason: 'Password must contain at least one lowercase letter' }; - } - - if (!/[A-Z]/.test(password)) { - return { valid: false, reason: 'Password must contain at least one uppercase letter' }; - } - - if (!/[0-9]/.test(password)) { - return { valid: false, reason: 'Password must contain at least one number' }; - } - - return { valid: true }; -} - -/** - * Validate email format. - */ -export function validateEmail(email: string): boolean { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); -} diff --git a/hub/src/lib/types.ts b/hub/src/lib/types.ts deleted file mode 100644 index f9741930..00000000 --- a/hub/src/lib/types.ts +++ /dev/null @@ -1,191 +0,0 @@ -// Domain types for the Hub API - -export interface User { - id: string; - email: string; - password_hash: string; - display_name: string; - avatar_url: string | null; - settings: string | null; - created_at: string; - last_login_at: string | null; -} - -export interface Session { - id: string; - user_id: string; - device_id: string | null; - refresh_token_hash: string; - expires_at: string; - created_at: string; - last_used_at: string; -} - -export interface Device { - id: string; - machine_id: string | null; - user_id: string; - device_type: 'desktop' | 'mobile' | 'web'; - device_name: string; - capabilities: string; // JSON - is_online: number; // SQLite boolean - last_seen: string | null; - app_version: string | null; - created_at: string; -} - -export interface Workspace { - id: string; - user_id: string; - name: string; - description: string | null; - host_device_id: string | null; - settings: string | null; // JSON - created_at: string; - updated_at: string; -} - -export interface Project { - id: string; - name: string; - path: string; - workspace_id: string | null; - git_url: string | null; - repo_structure: 'single' | 'monorepo' | 'multi-repo'; - default_branch: string | null; - description: string | null; - created_at: string; - updated_at: string; -} - -export interface SubProject { - id: string; - project_id: string; - name: string; - relative_path: string; - git_url: string | null; - default_branch: string | null; - created_at: string; -} - -export interface Task { - id: string; - project_id: string; - workspace_id: string | null; - sub_project_id: string | null; - title: string; - description: string; - status: string; - priority: number; - assigned_device_id: string | null; - created_by_device_id: string | null; - execution_session_id: string | null; - progress: string | null; // JSON - metadata: string | null; // JSON - agent_name: string | null; - activity_history: string; // JSON array for sparkline - cost_tokens: number; - cost_usd: number; - pr_number: number | null; - pr_state: string | null; - pr_ci_status: string | null; - pr_url: string | null; - completed_at: string | null; - created_at: string; - updated_at: string; -} - -export interface TaskSession { - id: string; - task_id: string; - device_id: string; - pid: number | null; - status: string; - started_at: string; - ended_at: string | null; - token_usage: number; - cost_usd: number; - error_message: string | null; -} - -export interface TaskWithSubtasks extends Task { - subtasks: Subtask[]; -} - -export interface Subtask { - id: string; - task_id: string; - title: string; - description: string; - status: string; - sort_order: number; -} - -export interface Setting { - key: string; - value: string; - updated_at: string; -} - -export interface PlannerEvent { - id: string; - date: string; - start_time: string; - end_time: string; - title: string; - category: string; - task_id: string | null; - created_at: string; - updated_at: string; -} - -export interface Capture { - id: string; - text: string; - project_id: string | null; - created_at: string; -} - -export interface AgentRun { - id: string; - task_id: string | null; - project_id: string; - status: string; - started_at: string; - completed_at: string | null; - tokens_used: number; - cost_usd: number; - log: string; -} - -export interface ApiKey { - id: string; - key_hash: string; - name: string; - created_at: string; -} - -// Webhook command (stored in webhook_commands table) -export interface WebhookCommand { - id: string; - source: 'slack' | 'github' | 'manual'; - source_id: string | null; - source_channel: string | null; - source_url: string | null; - command_text: string; - project_id: string | null; - status: 'pending' | 'processed' | 'failed'; - result_text: string | null; - created_at: string; - processed_at: string | null; -} - -// WebSocket broadcast message -export interface WsBroadcastMessage { - type: 'mutation'; - entity: string; - action: 'created' | 'updated' | 'deleted' | 'progress' | 'completed' | 'execute' | 'cancel'; - id: string; - data: unknown; - timestamp: string; -} diff --git a/hub/src/lib/webhook-validator.ts b/hub/src/lib/webhook-validator.ts deleted file mode 100644 index f8a3a915..00000000 --- a/hub/src/lib/webhook-validator.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; - -/** - * Validate a Slack v0 request signature. - * - * Slack signs requests as: v0=HMAC-SHA256("v0:{timestamp}:{body}", signingSecret) - * See https://api.slack.com/authentication/verifying-requests-from-slack - */ -export function validateSlackSignature( - signingSecret: string, - timestamp: string, - body: string, - signature: string, -): boolean { - const baseString = `v0:${timestamp}:${body}`; - const computed = `v0=${createHmac('sha256', signingSecret).update(baseString).digest('hex')}`; - - if (computed.length !== signature.length) { - return false; - } - - return timingSafeEqual(Buffer.from(computed), Buffer.from(signature)); -} - -/** - * Validate a GitHub HMAC-SHA256 webhook signature. - * - * GitHub sends the signature in the X-Hub-Signature-256 header as: sha256= - * See https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries - */ -export function validateGitHubSignature( - secret: string, - body: string, - signature: string, -): boolean { - const computed = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; - - if (computed.length !== signature.length) { - return false; - } - - return timingSafeEqual(Buffer.from(computed), Buffer.from(signature)); -} diff --git a/hub/src/middleware/api-key.ts b/hub/src/middleware/api-key.ts deleted file mode 100644 index 1665d70b..00000000 --- a/hub/src/middleware/api-key.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createHash } from 'node:crypto'; - -import type { FastifyReply, FastifyRequest } from 'fastify'; -import type Database from 'better-sqlite3'; - -function hashKey(key: string): string { - return createHash('sha256').update(key).digest('hex'); -} - -// Routes that use JWT auth (not API key auth) -const JWT_AUTH_ROUTES = [ - '/api/auth/register', - '/api/auth/login', - '/api/auth/logout', - '/api/auth/refresh', - '/api/auth/me', - '/api/health', -]; - -function isJwtAuthRoute(url: string): boolean { - return JWT_AUTH_ROUTES.some((route) => url.startsWith(route)); -} - -export function createApiKeyMiddleware(db: Database.Database) { - return async function apiKeyMiddleware( - request: FastifyRequest, - reply: FastifyReply, - ): Promise { - // Skip for routes that use JWT auth instead of API keys - if (isJwtAuthRoute(request.url)) { - return; - } - - // Skip auth for the generate-key endpoint when no keys exist - if (request.url === '/api/auth/generate-key' && request.method === 'POST') { - const row = db.prepare('SELECT COUNT(*) as count FROM api_keys').get() as - | { count: number } - | undefined; - if (!row || row.count === 0) { - return; - } - } - - // Skip auth for WebSocket upgrade requests (they use query param auth) - if (request.url.startsWith('/ws')) { - return; - } - - // If request has Authorization header (Bearer token), skip API key check - // and let the JWT middleware handle authentication - if (request.headers.authorization?.startsWith('Bearer ')) { - return; - } - - const apiKey = request.headers['x-api-key']; - - if (!apiKey || typeof apiKey !== 'string') { - await reply.status(401).send({ error: 'Missing X-API-Key header' }); - return; - } - - const keyHash = hashKey(apiKey); - const row = db - .prepare('SELECT id FROM api_keys WHERE key_hash = ?') - .get(keyHash) as { id: string } | undefined; - - if (!row) { - await reply.status(401).send({ error: 'Invalid API key' }); - return; - } - }; -} - -export { hashKey }; diff --git a/hub/src/middleware/jwt-auth.ts b/hub/src/middleware/jwt-auth.ts deleted file mode 100644 index 61428b79..00000000 --- a/hub/src/middleware/jwt-auth.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { FastifyReply, FastifyRequest } from 'fastify'; - -import { verifyAccessToken } from '../lib/jwt.js'; - -// ─── Types ──────────────────────────────────────────────────── - -declare module 'fastify' { - interface FastifyRequest { - user?: { - id: string; - deviceId?: string; - }; - } -} - -// ─── Public Routes ──────────────────────────────────────────── - -// Routes that don't require authentication -const PUBLIC_ROUTES = [ - '/api/auth/register', - '/api/auth/login', - '/api/auth/refresh', - '/api/health', - '/api/auth/generate-key', // Legacy API key generation - '/api/auth/keys', // Legacy API key listing -]; - -// Routes that accept either API key OR JWT auth -const HYBRID_AUTH_ROUTES = [ - '/api/projects', - '/api/tasks', - '/api/settings', - '/api/captures', - '/api/agents', - '/api/planner', - '/api/webhooks', -]; - -function isPublicRoute(url: string): boolean { - return PUBLIC_ROUTES.some((route) => url.startsWith(route)); -} - -function isHybridRoute(url: string): boolean { - return HYBRID_AUTH_ROUTES.some((route) => url.startsWith(route)); -} - -// ─── Middleware ─────────────────────────────────────────────── - -/** - * JWT authentication middleware. - * - * This middleware: - * 1. Skips public routes - * 2. Allows hybrid routes to use either API key or JWT - * 3. Requires JWT for protected routes - * 4. Attaches user info to request - */ -export function createJwtAuthMiddleware() { - return async function jwtAuthMiddleware( - request: FastifyRequest, - reply: FastifyReply, - ): Promise { - // Skip WebSocket upgrade requests (handled separately) - if (request.headers.upgrade === 'websocket') { - return; - } - - // Skip public routes - if (isPublicRoute(request.url)) { - return; - } - - // For hybrid routes, check if API key middleware already authenticated - // (the existing api-key middleware runs before this) - if (isHybridRoute(request.url)) { - // If no Authorization header, let API key middleware handle it - if (!request.headers.authorization) { - return; - } - } - - // Extract Bearer token - const authHeader = request.headers.authorization; - if (!authHeader?.startsWith('Bearer ')) { - // For hybrid routes without JWT, let API key handle it - if (isHybridRoute(request.url)) { - return; - } - - await reply.status(401).send({ - error: { - code: 'UNAUTHORIZED', - message: 'Missing or invalid Authorization header', - }, - }); - return; - } - - const token = authHeader.slice(7); // Remove 'Bearer ' - - // Verify JWT - const payload = await verifyAccessToken(token); - if (!payload) { - await reply.status(401).send({ - error: { - code: 'UNAUTHORIZED', - message: 'Invalid or expired access token', - }, - }); - return; - } - - // Attach user info to request - request.user = { - id: payload.sub, - deviceId: payload.deviceId, - }; - }; -} - -/** - * Require authentication decorator. - * Use this on routes that must be authenticated. - */ -export async function requireAuth( - request: FastifyRequest, - reply: FastifyReply, -): Promise { - if (!request.user) { - await reply.status(401).send({ - error: { - code: 'UNAUTHORIZED', - message: 'Authentication required', - }, - }); - } -} diff --git a/hub/src/routes/agents.ts b/hub/src/routes/agents.ts deleted file mode 100644 index 07ef5e98..00000000 --- a/hub/src/routes/agents.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { FastifyInstance } from 'fastify'; -import { nanoid } from 'nanoid'; - -import type { AgentRun } from '../lib/types.js'; -import { broadcast } from '../ws/broadcaster.js'; - -export async function agentRoutes(app: FastifyInstance): Promise { - const db = app.db; - - // GET /api/agent-runs?project_id=X — List runs - app.get<{ Querystring: { project_id?: string } }>('/api/agent-runs', async (request) => { - const { project_id } = request.query; - - if (project_id) { - return db - .prepare('SELECT * FROM agent_runs WHERE project_id = ? ORDER BY started_at DESC') - .all(project_id) as AgentRun[]; - } - - return db - .prepare('SELECT * FROM agent_runs ORDER BY started_at DESC') - .all() as AgentRun[]; - }); - - // POST /api/agent-runs — Log new run - app.post<{ - Body: { - project_id: string; - task_id?: string; - status?: string; - tokens_used?: number; - cost_usd?: number; - log?: string; - }; - }>('/api/agent-runs', async (request, reply) => { - const { project_id, task_id, status, tokens_used, cost_usd, log } = request.body; - - if (!project_id) { - return reply.status(400).send({ error: 'project_id is required' }); - } - - const id = nanoid(); - const now = new Date().toISOString(); - - db.prepare( - 'INSERT INTO agent_runs (id, task_id, project_id, status, started_at, tokens_used, cost_usd, log) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - ).run( - id, - task_id ?? null, - project_id, - status ?? 'running', - now, - tokens_used ?? 0, - cost_usd ?? 0, - log ?? '', - ); - - const run = db.prepare('SELECT * FROM agent_runs WHERE id = ?').get(id) as AgentRun; - broadcast('agent_runs', 'created', id, run); - return reply.status(201).send(run); - }); - - // PUT /api/agent-runs/:id — Update run (status, tokens, cost) - app.put<{ - Params: { id: string }; - Body: Partial>; - }>('/api/agent-runs/:id', async (request, reply) => { - const existing = db.prepare('SELECT * FROM agent_runs WHERE id = ?').get(request.params.id) as - | AgentRun - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Agent run not found' }); - } - - const status = request.body.status ?? existing.status; - const completed_at = request.body.completed_at ?? existing.completed_at; - const tokens_used = request.body.tokens_used ?? existing.tokens_used; - const cost_usd = request.body.cost_usd ?? existing.cost_usd; - const log = request.body.log ?? existing.log; - - db.prepare( - 'UPDATE agent_runs SET status = ?, completed_at = ?, tokens_used = ?, cost_usd = ?, log = ? WHERE id = ?', - ).run(status, completed_at, tokens_used, cost_usd, log, request.params.id); - - const run = db - .prepare('SELECT * FROM agent_runs WHERE id = ?') - .get(request.params.id) as AgentRun; - broadcast('agent_runs', 'updated', request.params.id, run); - return run; - }); -} diff --git a/hub/src/routes/auth.ts b/hub/src/routes/auth.ts deleted file mode 100644 index ac1a1f91..00000000 --- a/hub/src/routes/auth.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { randomBytes, timingSafeEqual } from 'node:crypto'; - -import type { FastifyInstance, FastifyReply } from 'fastify'; -import { nanoid } from 'nanoid'; - -import type { ApiKey } from '../lib/types.js'; -import { hashKey } from '../middleware/api-key.js'; -import { requireAuth } from '../middleware/jwt-auth.js'; -import { AuthError, createAuthService } from '../services/auth-service.js'; - -// ─── Types ──────────────────────────────────────────────────── - -interface DeviceRow { - id: string; - machine_id: string | null; - user_id: string; - device_type: string; - device_name: string; - capabilities: string; - is_online: number; - last_seen: string | null; - app_version: string | null; - created_at: string; -} - -// ─── Helpers ────────────────────────────────────────────────── - -function formatDevice(row: DeviceRow) { - return { - id: row.id, - machineId: row.machine_id ?? undefined, - userId: row.user_id, - deviceType: row.device_type as 'desktop' | 'mobile' | 'web', - deviceName: row.device_name, - capabilities: JSON.parse(row.capabilities) as unknown, - isOnline: row.is_online === 1, - lastSeen: row.last_seen ?? undefined, - appVersion: row.app_version ?? undefined, - createdAt: row.created_at, - }; -} - -/** Validates the bootstrap secret header. Returns true if valid (or secret not configured). */ -function validateBootstrapSecret(headerValue: string | string[] | undefined): boolean { - const expectedSecret = process.env.HUB_BOOTSTRAP_SECRET; - - if (!expectedSecret) { - return true; - } - - if (typeof headerValue !== 'string') { - return false; - } - - if (headerValue.length !== expectedSecret.length) { - return false; - } - - return timingSafeEqual(Buffer.from(headerValue), Buffer.from(expectedSecret)); -} - -/** - * Send an AuthError as a structured HTTP response. - */ -async function sendAuthError(reply: FastifyReply, error: AuthError): Promise { - await reply.status(error.statusCode).send({ - error: { code: error.code, message: error.message }, - }); -} - -// ─── Routes ─────────────────────────────────────────────────── - -export async function authRoutes(app: FastifyInstance): Promise { - const db = app.db; - const authService = createAuthService(db); - - // ───────────────────────────────────────────────────────────── - // POST /api/auth/register — Create user account - // ───────────────────────────────────────────────────────────── - app.post<{ - Body: { - email: string; - password: string; - displayName: string; - }; - }>('/api/auth/register', async (request, reply) => { - try { - const { email, password, displayName } = request.body; - const result = await authService.register(email, password, displayName); - - return reply.status(201).send({ - user: result.user, - accessToken: result.accessToken, - refreshToken: result.refreshToken, - expiresAt: result.expiresAt, - }); - } catch (error: unknown) { - if (error instanceof AuthError) { - await sendAuthError(reply, error); - return; - } - throw error; - } - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/auth/login — Login and get tokens - // ───────────────────────────────────────────────────────────── - app.post<{ - Body: { - email: string; - password: string; - deviceInfo?: { - machineId?: string; - deviceName: string; - deviceType: 'desktop' | 'mobile' | 'web'; - capabilities?: { canExecute: boolean; repos: string[] }; - appVersion?: string; - }; - }; - }>('/api/auth/login', async (request, reply) => { - try { - const { email, password, deviceInfo } = request.body; - - // Authenticate via auth service - const result = await authService.login(email, password, deviceInfo?.machineId); - - // Handle device registration/update (route-level concern) - let device: DeviceRow | undefined; - if (deviceInfo) { - const now = new Date().toISOString(); - - if (deviceInfo.machineId) { - device = db - .prepare('SELECT * FROM devices WHERE machine_id = ? AND user_id = ?') - .get(deviceInfo.machineId, result.user.id) as DeviceRow | undefined; - - if (device) { - db.prepare( - 'UPDATE devices SET device_name = ?, capabilities = ?, is_online = 1, last_seen = ?, app_version = ? WHERE id = ?', - ).run( - deviceInfo.deviceName, - JSON.stringify(deviceInfo.capabilities ?? { canExecute: false, repos: [] }), - now, - deviceInfo.appVersion ?? null, - device.id, - ); - device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device.id) as DeviceRow; - } - } - - if (!device) { - const deviceId = nanoid(); - db.prepare( - 'INSERT INTO devices (id, machine_id, user_id, device_type, device_name, capabilities, is_online, last_seen, app_version, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)', - ).run( - deviceId, - deviceInfo.machineId ?? null, - result.user.id, - deviceInfo.deviceType, - deviceInfo.deviceName, - JSON.stringify(deviceInfo.capabilities ?? { canExecute: false, repos: [] }), - now, - deviceInfo.appVersion ?? null, - now, - ); - device = db.prepare('SELECT * FROM devices WHERE id = ?').get(deviceId) as DeviceRow; - } - } - - const response: Record = { - user: result.user, - accessToken: result.accessToken, - refreshToken: result.refreshToken, - expiresAt: result.expiresAt, - }; - - if (device) { - response.device = formatDevice(device); - } - - return response; - } catch (error: unknown) { - if (error instanceof AuthError) { - await sendAuthError(reply, error); - return; - } - throw error; - } - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/auth/logout — Invalidate session (requires auth) - // ───────────────────────────────────────────────────────────── - app.post<{ - Body: { refreshToken?: string }; - }>('/api/auth/logout', { preHandler: [requireAuth] }, async (request, reply) => { - try { - const { refreshToken } = request.body; - - if (refreshToken) { - await authService.logoutByRefreshToken(refreshToken); - } else if (request.user) { - authService.logoutAllSessions(request.user.id); - } - - return { success: true }; - } catch (error: unknown) { - if (error instanceof AuthError) { - await sendAuthError(reply, error); - return; - } - throw error; - } - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/auth/refresh — Refresh access token - // ───────────────────────────────────────────────────────────── - app.post<{ - Body: { refreshToken: string }; - }>('/api/auth/refresh', async (request, reply) => { - try { - const { refreshToken } = request.body; - const tokens = await authService.refresh(refreshToken); - - return { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: tokens.expiresAt, - }; - } catch (error: unknown) { - if (error instanceof AuthError) { - await sendAuthError(reply, error); - return; - } - throw error; - } - }); - - // ───────────────────────────────────────────────────────────── - // GET /api/auth/me — Get current user (requires auth) - // ───────────────────────────────────────────────────────────── - app.get('/api/auth/me', { preHandler: [requireAuth] }, async (request, reply) => { - try { - // request.user is guaranteed by requireAuth - const user = await authService.verify( - // Re-fetch user from DB via the access token's user ID - // Since requireAuth already validated the token, we can trust request.user - // We use the service to get the full PublicUser object - request.headers.authorization?.slice(7) ?? '', - ); - - const response: Record = { user }; - - // Include device info if authenticated with device - if (request.user?.deviceId) { - const device = db - .prepare('SELECT * FROM devices WHERE id = ?') - .get(request.user.deviceId) as DeviceRow | undefined; - - if (device) { - response.device = formatDevice(device); - } - } - - return response; - } catch (error: unknown) { - if (error instanceof AuthError) { - await sendAuthError(reply, error); - return; - } - throw error; - } - }); - - // ───────────────────────────────────────────────────────────── - // Legacy: POST /api/auth/generate-key — Generate API key - // ───────────────────────────────────────────────────────────── - app.post('/api/auth/generate-key', async (request, reply) => { - const bootstrapSecret = request.headers['x-bootstrap-secret']; - if (!validateBootstrapSecret(bootstrapSecret)) { - return reply.status(401).send({ - error: 'Invalid or missing X-Bootstrap-Secret header', - }); - } - - const row = db.prepare('SELECT COUNT(*) as count FROM api_keys').get() as { count: number }; - - if (row.count > 0) { - return reply.status(403).send({ - error: 'API keys already exist. Cannot generate new keys via this endpoint.', - }); - } - - const rawKey = randomBytes(32).toString('hex'); - const keyHash = hashKey(rawKey); - const id = nanoid(); - const now = new Date().toISOString(); - - db.prepare( - 'INSERT INTO api_keys (id, key_hash, name, created_at) VALUES (?, ?, ?, ?)', - ).run(id, keyHash, 'default', now); - - return reply.status(201).send({ key: rawKey }); - }); - - // ───────────────────────────────────────────────────────────── - // Legacy: GET /api/auth/keys — List API keys - // ───────────────────────────────────────────────────────────── - app.get('/api/auth/keys', async () => { - const keys = db - .prepare('SELECT id, name, created_at FROM api_keys ORDER BY created_at DESC') - .all() as Pick[]; - - return keys; - }); -} diff --git a/hub/src/routes/captures.ts b/hub/src/routes/captures.ts deleted file mode 100644 index 1611863d..00000000 --- a/hub/src/routes/captures.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { FastifyInstance } from 'fastify'; -import { nanoid } from 'nanoid'; - -import type { Capture } from '../lib/types.js'; -import { broadcast } from '../ws/broadcaster.js'; - -export async function captureRoutes(app: FastifyInstance): Promise { - const db = app.db; - - // GET /api/captures?limit=20 — Recent captures - app.get<{ Querystring: { limit?: string } }>('/api/captures', async (request) => { - const limit = Number(request.query.limit) || 20; - - return db - .prepare('SELECT * FROM captures ORDER BY created_at DESC LIMIT ?') - .all(limit) as Capture[]; - }); - - // POST /api/captures — Create capture - app.post<{ Body: { text: string; project_id?: string } }>( - '/api/captures', - async (request, reply) => { - const { text, project_id } = request.body; - - if (!text) { - return reply.status(400).send({ error: 'text is required' }); - } - - const id = nanoid(); - const now = new Date().toISOString(); - - db.prepare( - 'INSERT INTO captures (id, text, project_id, created_at) VALUES (?, ?, ?, ?)', - ).run(id, text, project_id ?? null, now); - - const capture = db.prepare('SELECT * FROM captures WHERE id = ?').get(id) as Capture; - broadcast('captures', 'created', id, capture); - return reply.status(201).send(capture); - }, - ); - - // DELETE /api/captures/:id — Delete capture - app.delete<{ Params: { id: string } }>('/api/captures/:id', async (request, reply) => { - const existing = db.prepare('SELECT * FROM captures WHERE id = ?').get(request.params.id) as - | Capture - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Capture not found' }); - } - - db.prepare('DELETE FROM captures WHERE id = ?').run(request.params.id); - broadcast('captures', 'deleted', request.params.id, existing); - return reply.status(204).send(); - }); -} diff --git a/hub/src/routes/devices.ts b/hub/src/routes/devices.ts deleted file mode 100644 index 0554cdd9..00000000 --- a/hub/src/routes/devices.ts +++ /dev/null @@ -1,316 +0,0 @@ -import type { FastifyInstance } from 'fastify'; -import { nanoid } from 'nanoid'; - -import { broadcast } from '../ws/broadcaster.js'; - -// ─── Types ──────────────────────────────────────────────────── - -interface DeviceRow { - id: string; - machine_id: string | null; - user_id: string; - device_type: string; - device_name: string; - capabilities: string; - is_online: number; - last_seen: string | null; - app_version: string | null; - created_at: string; -} - -// ─── Helpers ────────────────────────────────────────────────── - -function formatDevice(row: DeviceRow) { - return { - id: row.id, - machineId: row.machine_id ?? undefined, - userId: row.user_id, - deviceType: row.device_type as 'desktop' | 'mobile' | 'web', - deviceName: row.device_name, - capabilities: JSON.parse(row.capabilities), - isOnline: row.is_online === 1, - lastSeen: row.last_seen ?? undefined, - appVersion: row.app_version ?? undefined, - createdAt: row.created_at, - }; -} - -// ─── Routes ─────────────────────────────────────────────────── - -export async function deviceRoutes(app: FastifyInstance): Promise { - const db = app.db; - - // ───────────────────────────────────────────────────────────── - // POST /api/devices — Register a new device - // ───────────────────────────────────────────────────────────── - app.post<{ - Body: { - machineId?: string; - deviceName: string; - deviceType: 'desktop' | 'mobile' | 'web'; - capabilities: { canExecute: boolean; repos: string[] }; - appVersion?: string; - }; - }>('/api/devices', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const { machineId, deviceName, deviceType, capabilities, appVersion } = request.body; - - // Validate required fields - if (!deviceName || !deviceType) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'deviceName and deviceType are required' }, - }); - } - - // Validate device type - if (!['desktop', 'mobile', 'web'].includes(deviceType)) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'deviceType must be desktop, mobile, or web' }, - }); - } - - const now = new Date().toISOString(); - let device: DeviceRow | undefined; - - // Check if device with machineId already exists for this user - if (machineId) { - device = db - .prepare('SELECT * FROM devices WHERE machine_id = ? AND user_id = ?') - .get(machineId, request.user.id) as DeviceRow | undefined; - - if (device) { - // Update existing device - db.prepare( - 'UPDATE devices SET device_name = ?, capabilities = ?, is_online = 1, last_seen = ?, app_version = ? WHERE id = ?', - ).run( - deviceName, - JSON.stringify(capabilities ?? { canExecute: false, repos: [] }), - now, - appVersion ?? null, - device.id, - ); - - device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device.id) as DeviceRow; - const formatted = formatDevice(device); - - // Broadcast device online - broadcast('devices', 'updated', device.id, formatted); - - return formatted; - } - } - - // Create new device - const deviceId = nanoid(); - db.prepare( - 'INSERT INTO devices (id, machine_id, user_id, device_type, device_name, capabilities, is_online, last_seen, app_version, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)', - ).run( - deviceId, - machineId ?? null, - request.user.id, - deviceType, - deviceName, - JSON.stringify(capabilities ?? { canExecute: false, repos: [] }), - now, - appVersion ?? null, - now, - ); - - device = db.prepare('SELECT * FROM devices WHERE id = ?').get(deviceId) as DeviceRow; - const formatted = formatDevice(device); - - // Broadcast device created - broadcast('devices', 'created', deviceId, formatted); - - return reply.status(201).send(formatted); - }); - - // ───────────────────────────────────────────────────────────── - // GET /api/devices — List user's devices - // ───────────────────────────────────────────────────────────── - app.get('/api/devices', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const devices = db - .prepare('SELECT * FROM devices WHERE user_id = ? ORDER BY created_at DESC') - .all(request.user.id) as DeviceRow[]; - - return { - devices: devices.map(formatDevice), - }; - }); - - // ───────────────────────────────────────────────────────────── - // GET /api/devices/:id — Get single device - // ───────────────────────────────────────────────────────────── - app.get<{ Params: { id: string } }>('/api/devices/:id', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const device = db - .prepare('SELECT * FROM devices WHERE id = ? AND user_id = ?') - .get(request.params.id, request.user.id) as DeviceRow | undefined; - - if (!device) { - return reply.status(404).send({ - error: { code: 'NOT_FOUND', message: 'Device not found' }, - }); - } - - return formatDevice(device); - }); - - // ───────────────────────────────────────────────────────────── - // PATCH /api/devices/:id — Update device - // ───────────────────────────────────────────────────────────── - app.patch<{ - Params: { id: string }; - Body: { - deviceName?: string; - capabilities?: Partial<{ canExecute: boolean; repos: string[] }>; - isOnline?: boolean; - appVersion?: string; - }; - }>('/api/devices/:id', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const device = db - .prepare('SELECT * FROM devices WHERE id = ? AND user_id = ?') - .get(request.params.id, request.user.id) as DeviceRow | undefined; - - if (!device) { - return reply.status(404).send({ - error: { code: 'NOT_FOUND', message: 'Device not found' }, - }); - } - - const { deviceName, capabilities, isOnline, appVersion } = request.body; - const now = new Date().toISOString(); - - // Merge capabilities - let newCapabilities = JSON.parse(device.capabilities); - if (capabilities) { - newCapabilities = { ...newCapabilities, ...capabilities }; - } - - // Build update - const updates: string[] = []; - const values: unknown[] = []; - - if (deviceName !== undefined) { - updates.push('device_name = ?'); - values.push(deviceName); - } - - if (capabilities !== undefined) { - updates.push('capabilities = ?'); - values.push(JSON.stringify(newCapabilities)); - } - - if (isOnline !== undefined) { - updates.push('is_online = ?'); - values.push(isOnline ? 1 : 0); - if (isOnline) { - updates.push('last_seen = ?'); - values.push(now); - } - } - - if (appVersion !== undefined) { - updates.push('app_version = ?'); - values.push(appVersion); - } - - if (updates.length === 0) { - return formatDevice(device); - } - - values.push(request.params.id); - db.prepare(`UPDATE devices SET ${updates.join(', ')} WHERE id = ?`).run(...values); - - const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(request.params.id) as DeviceRow; - const formatted = formatDevice(updated); - - // Broadcast update - broadcast('devices', 'updated', request.params.id, formatted); - - return formatted; - }); - - // ───────────────────────────────────────────────────────────── - // DELETE /api/devices/:id — Remove device - // ───────────────────────────────────────────────────────────── - app.delete<{ Params: { id: string } }>('/api/devices/:id', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const device = db - .prepare('SELECT * FROM devices WHERE id = ? AND user_id = ?') - .get(request.params.id, request.user.id) as DeviceRow | undefined; - - if (!device) { - return reply.status(404).send({ - error: { code: 'NOT_FOUND', message: 'Device not found' }, - }); - } - - // Delete associated sessions - db.prepare('DELETE FROM sessions WHERE device_id = ?').run(request.params.id); - - // Delete device - db.prepare('DELETE FROM devices WHERE id = ?').run(request.params.id); - - // Broadcast deletion - broadcast('devices', 'deleted', request.params.id, { id: request.params.id }); - - return reply.status(204).send(); - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/devices/:id/heartbeat — Update device online status - // ───────────────────────────────────────────────────────────── - app.post<{ Params: { id: string } }>('/api/devices/:id/heartbeat', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const device = db - .prepare('SELECT * FROM devices WHERE id = ? AND user_id = ?') - .get(request.params.id, request.user.id) as DeviceRow | undefined; - - if (!device) { - return reply.status(404).send({ - error: { code: 'NOT_FOUND', message: 'Device not found' }, - }); - } - - const now = new Date().toISOString(); - db.prepare('UPDATE devices SET is_online = 1, last_seen = ? WHERE id = ?').run( - now, - request.params.id, - ); - - return { success: true, lastSeen: now }; - }); -} diff --git a/hub/src/routes/planner.ts b/hub/src/routes/planner.ts deleted file mode 100644 index bfd56603..00000000 --- a/hub/src/routes/planner.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { FastifyInstance } from 'fastify'; -import { nanoid } from 'nanoid'; - -import type { PlannerEvent } from '../lib/types.js'; -import { broadcast } from '../ws/broadcaster.js'; - -export async function plannerRoutes(app: FastifyInstance): Promise { - const db = app.db; - - // GET /api/planner/events?date=YYYY-MM-DD&end_date=YYYY-MM-DD - app.get<{ Querystring: { date?: string; end_date?: string } }>( - '/api/planner/events', - async (request) => { - const { date, end_date } = request.query; - - if (date && end_date) { - return db - .prepare( - 'SELECT * FROM planner_events WHERE date >= ? AND date <= ? ORDER BY date ASC, start_time ASC', - ) - .all(date, end_date) as PlannerEvent[]; - } - - if (date) { - return db - .prepare('SELECT * FROM planner_events WHERE date = ? ORDER BY start_time ASC') - .all(date) as PlannerEvent[]; - } - - return db - .prepare('SELECT * FROM planner_events ORDER BY date ASC, start_time ASC') - .all() as PlannerEvent[]; - }, - ); - - // POST /api/planner/events — Create event - app.post<{ - Body: { - date: string; - start_time: string; - end_time: string; - title: string; - category?: string; - task_id?: string; - }; - }>('/api/planner/events', async (request, reply) => { - const { date, start_time, end_time, title, category, task_id } = request.body; - - if (!date || !start_time || !end_time || !title) { - return reply.status(400).send({ error: 'date, start_time, end_time, and title are required' }); - } - - const id = nanoid(); - const now = new Date().toISOString(); - - db.prepare( - 'INSERT INTO planner_events (id, date, start_time, end_time, title, category, task_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - ).run(id, date, start_time, end_time, title, category ?? 'personal', task_id ?? null, now, now); - - const event = db.prepare('SELECT * FROM planner_events WHERE id = ?').get(id) as PlannerEvent; - broadcast('planner_events', 'created', id, event); - return reply.status(201).send(event); - }); - - // PUT /api/planner/events/:id — Update event - app.put<{ - Params: { id: string }; - Body: Partial>; - }>('/api/planner/events/:id', async (request, reply) => { - const existing = db - .prepare('SELECT * FROM planner_events WHERE id = ?') - .get(request.params.id) as PlannerEvent | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Event not found' }); - } - - const date = request.body.date ?? existing.date; - const start_time = request.body.start_time ?? existing.start_time; - const end_time = request.body.end_time ?? existing.end_time; - const title = request.body.title ?? existing.title; - const category = request.body.category ?? existing.category; - const task_id = request.body.task_id !== undefined ? request.body.task_id : existing.task_id; - const now = new Date().toISOString(); - - db.prepare( - 'UPDATE planner_events SET date = ?, start_time = ?, end_time = ?, title = ?, category = ?, task_id = ?, updated_at = ? WHERE id = ?', - ).run(date, start_time, end_time, title, category, task_id, now, request.params.id); - - const event = db - .prepare('SELECT * FROM planner_events WHERE id = ?') - .get(request.params.id) as PlannerEvent; - broadcast('planner_events', 'updated', request.params.id, event); - return event; - }); - - // DELETE /api/planner/events/:id — Delete event - app.delete<{ Params: { id: string } }>('/api/planner/events/:id', async (request, reply) => { - const existing = db - .prepare('SELECT * FROM planner_events WHERE id = ?') - .get(request.params.id) as PlannerEvent | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Event not found' }); - } - - db.prepare('DELETE FROM planner_events WHERE id = ?').run(request.params.id); - broadcast('planner_events', 'deleted', request.params.id, existing); - return reply.status(204).send(); - }); -} diff --git a/hub/src/routes/projects.ts b/hub/src/routes/projects.ts deleted file mode 100644 index b76a96ed..00000000 --- a/hub/src/routes/projects.ts +++ /dev/null @@ -1,517 +0,0 @@ -import type { FastifyInstance } from 'fastify'; -import { nanoid } from 'nanoid'; - -import { broadcast } from '../ws/broadcaster.js'; - -// ─── Types ──────────────────────────────────────────────────── - -interface ProjectRow { - id: string; - name: string; - path: string; - workspace_id: string | null; - git_url: string | null; - repo_structure: string; - default_branch: string | null; - description: string | null; - created_at: string; - updated_at: string; -} - -interface SubProjectRow { - id: string; - project_id: string; - name: string; - relative_path: string; - git_url: string | null; - default_branch: string | null; - created_at: string; -} - -interface WorkspaceRow { - id: string; - user_id: string; -} - -// ─── Helpers ────────────────────────────────────────────────── - -function formatProject(row: ProjectRow) { - return { - id: row.id, - name: row.name, - rootPath: row.path, - workspaceId: row.workspace_id ?? undefined, - gitUrl: row.git_url ?? undefined, - repoStructure: row.repo_structure as 'single' | 'monorepo' | 'multi-repo', - defaultBranch: row.default_branch ?? 'main', - description: row.description ?? undefined, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -function formatSubProject(row: SubProjectRow) { - return { - id: row.id, - projectId: row.project_id, - name: row.name, - relativePath: row.relative_path, - gitUrl: row.git_url ?? undefined, - defaultBranch: row.default_branch ?? 'main', - createdAt: row.created_at, - }; -} - -// ─── Routes ─────────────────────────────────────────────────── - -export async function projectRoutes(app: FastifyInstance): Promise { - const db = app.db; - - // ───────────────────────────────────────────────────────────── - // GET /api/projects — List all projects (legacy, no auth required) - // ───────────────────────────────────────────────────────────── - app.get('/api/projects', async () => { - const projects = db.prepare('SELECT * FROM projects ORDER BY created_at DESC').all() as ProjectRow[]; - return projects.map(formatProject); - }); - - // ───────────────────────────────────────────────────────────── - // GET /api/workspaces/:wid/projects — List projects in workspace - // ───────────────────────────────────────────────────────────── - app.get<{ Params: { wid: string } }>('/api/workspaces/:wid/projects', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - // Verify workspace belongs to user - const workspace = db - .prepare('SELECT * FROM workspaces WHERE id = ? AND user_id = ?') - .get(request.params.wid, request.user.id) as WorkspaceRow | undefined; - - if (!workspace) { - return reply.status(404).send({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }); - } - - const projects = db - .prepare('SELECT * FROM projects WHERE workspace_id = ? ORDER BY created_at DESC') - .all(request.params.wid) as ProjectRow[]; - - // Include sub-projects for each project - const projectsWithSubProjects = projects.map((project) => { - const subProjects = db - .prepare('SELECT * FROM sub_projects WHERE project_id = ? ORDER BY relative_path ASC') - .all(project.id) as SubProjectRow[]; - - return { - ...formatProject(project), - subProjects: subProjects.map(formatSubProject), - }; - }); - - return { projects: projectsWithSubProjects }; - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/workspaces/:wid/projects — Create project in workspace - // ───────────────────────────────────────────────────────────── - app.post<{ - Params: { wid: string }; - Body: { - name: string; - rootPath: string; - description?: string; - gitUrl?: string; - repoStructure: 'single' | 'monorepo' | 'multi-repo'; - defaultBranch?: string; - subProjects?: Array<{ - name: string; - relativePath: string; - gitUrl?: string; - defaultBranch?: string; - }>; - }; - }>('/api/workspaces/:wid/projects', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - // Verify workspace belongs to user - const workspace = db - .prepare('SELECT * FROM workspaces WHERE id = ? AND user_id = ?') - .get(request.params.wid, request.user.id) as WorkspaceRow | undefined; - - if (!workspace) { - return reply.status(404).send({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }); - } - - const { name, rootPath, description, gitUrl, repoStructure, defaultBranch, subProjects } = request.body; - - if (!name || !rootPath) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'name and rootPath are required' }, - }); - } - - if (!['single', 'monorepo', 'multi-repo'].includes(repoStructure)) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'repoStructure must be single, monorepo, or multi-repo' }, - }); - } - - // Check for duplicate path in workspace - const existing = db - .prepare('SELECT id FROM projects WHERE workspace_id = ? AND path = ?') - .get(request.params.wid, rootPath) as { id: string } | undefined; - - if (existing) { - return reply.status(409).send({ - error: { code: 'CONFLICT', message: 'A project with this path already exists in this workspace' }, - }); - } - - const projectId = nanoid(); - const now = new Date().toISOString(); - - // Create project - db.prepare( - 'INSERT INTO projects (id, name, path, workspace_id, git_url, repo_structure, default_branch, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - ).run( - projectId, - name, - rootPath, - request.params.wid, - gitUrl ?? null, - repoStructure, - defaultBranch ?? 'main', - description ?? null, - now, - now, - ); - - // Create sub-projects if provided - const createdSubProjects: SubProjectRow[] = []; - if (subProjects && subProjects.length > 0) { - const insertSubProject = db.prepare( - 'INSERT INTO sub_projects (id, project_id, name, relative_path, git_url, default_branch, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', - ); - - for (const sp of subProjects) { - const subProjectId = nanoid(); - insertSubProject.run( - subProjectId, - projectId, - sp.name, - sp.relativePath, - sp.gitUrl ?? null, - sp.defaultBranch ?? 'main', - now, - ); - createdSubProjects.push({ - id: subProjectId, - project_id: projectId, - name: sp.name, - relative_path: sp.relativePath, - git_url: sp.gitUrl ?? null, - default_branch: sp.defaultBranch ?? null, - created_at: now, - }); - } - } - - const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId) as ProjectRow; - const formatted = { - ...formatProject(project), - subProjects: createdSubProjects.map(formatSubProject), - }; - - broadcast('projects', 'created', projectId, formatted); - - return reply.status(201).send(formatted); - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/projects — Create project (legacy, no workspace) - // ───────────────────────────────────────────────────────────── - app.post<{ Body: { name: string; path: string } }>('/api/projects', async (request, reply) => { - const { name, path } = request.body; - - if (!name || !path) { - return reply.status(400).send({ error: 'name and path are required' }); - } - - const id = nanoid(); - const now = new Date().toISOString(); - - db.prepare( - 'INSERT INTO projects (id, name, path, repo_structure, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', - ).run(id, name, path, 'single', now, now); - - const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as ProjectRow; - const formatted = formatProject(project); - broadcast('projects', 'created', id, formatted); - return reply.status(201).send(formatted); - }); - - // ───────────────────────────────────────────────────────────── - // GET /api/projects/:id — Get single project with sub-projects - // ───────────────────────────────────────────────────────────── - app.get<{ Params: { id: string } }>('/api/projects/:id', async (request, reply) => { - const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(request.params.id) as - | ProjectRow - | undefined; - - if (!project) { - return reply.status(404).send({ error: 'Project not found' }); - } - - const subProjects = db - .prepare('SELECT * FROM sub_projects WHERE project_id = ? ORDER BY relative_path ASC') - .all(request.params.id) as SubProjectRow[]; - - return { - ...formatProject(project), - subProjects: subProjects.map(formatSubProject), - }; - }); - - // ───────────────────────────────────────────────────────────── - // PATCH /api/projects/:id — Update project - // ───────────────────────────────────────────────────────────── - app.patch<{ - Params: { id: string }; - Body: { - name?: string; - description?: string; - gitUrl?: string; - defaultBranch?: string; - }; - }>('/api/projects/:id', async (request, reply) => { - const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(request.params.id) as - | ProjectRow - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Project not found' }); - } - - // If project belongs to a workspace, verify user ownership - if (existing.workspace_id && request.user) { - const workspace = db - .prepare('SELECT * FROM workspaces WHERE id = ? AND user_id = ?') - .get(existing.workspace_id, request.user.id) as WorkspaceRow | undefined; - - if (!workspace) { - return reply.status(403).send({ - error: { code: 'FORBIDDEN', message: 'Not authorized to modify this project' }, - }); - } - } - - const { name, description, gitUrl, defaultBranch } = request.body; - const now = new Date().toISOString(); - - const updates: string[] = []; - const values: unknown[] = []; - - if (name !== undefined) { - updates.push('name = ?'); - values.push(name); - } - - if (description !== undefined) { - updates.push('description = ?'); - values.push(description); - } - - if (gitUrl !== undefined) { - updates.push('git_url = ?'); - values.push(gitUrl); - } - - if (defaultBranch !== undefined) { - updates.push('default_branch = ?'); - values.push(defaultBranch); - } - - if (updates.length === 0) { - return formatProject(existing); - } - - updates.push('updated_at = ?'); - values.push(now); - values.push(request.params.id); - - db.prepare(`UPDATE projects SET ${updates.join(', ')} WHERE id = ?`).run(...values); - - const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(request.params.id) as ProjectRow; - const formatted = formatProject(project); - broadcast('projects', 'updated', request.params.id, formatted); - return formatted; - }); - - // ───────────────────────────────────────────────────────────── - // PUT /api/projects/:id — Update project (legacy) - // ───────────────────────────────────────────────────────────── - app.put<{ Params: { id: string }; Body: Partial<{ name: string; path: string }> }>( - '/api/projects/:id', - async (request, reply) => { - const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(request.params.id) as - | ProjectRow - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Project not found' }); - } - - const name = request.body.name ?? existing.name; - const path = request.body.path ?? existing.path; - const now = new Date().toISOString(); - - db.prepare('UPDATE projects SET name = ?, path = ?, updated_at = ? WHERE id = ?').run( - name, - path, - now, - request.params.id, - ); - - const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(request.params.id) as ProjectRow; - const formatted = formatProject(project); - broadcast('projects', 'updated', request.params.id, formatted); - return formatted; - }, - ); - - // ───────────────────────────────────────────────────────────── - // DELETE /api/projects/:id — Delete project + cascade - // ───────────────────────────────────────────────────────────── - app.delete<{ Params: { id: string } }>('/api/projects/:id', async (request, reply) => { - const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(request.params.id) as - | ProjectRow - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Project not found' }); - } - - // If project belongs to a workspace, verify user ownership - if (existing.workspace_id && request.user) { - const workspace = db - .prepare('SELECT * FROM workspaces WHERE id = ? AND user_id = ?') - .get(existing.workspace_id, request.user.id) as WorkspaceRow | undefined; - - if (!workspace) { - return reply.status(403).send({ - error: { code: 'FORBIDDEN', message: 'Not authorized to delete this project' }, - }); - } - } - - // Sub-projects and tasks will cascade delete - db.prepare('DELETE FROM projects WHERE id = ?').run(request.params.id); - broadcast('projects', 'deleted', request.params.id, { id: request.params.id }); - return reply.status(204).send(); - }); - - // ───────────────────────────────────────────────────────────── - // GET /api/projects/:id/sub-projects — List sub-projects - // ───────────────────────────────────────────────────────────── - app.get<{ Params: { id: string } }>('/api/projects/:id/sub-projects', async (request, reply) => { - const project = db.prepare('SELECT id FROM projects WHERE id = ?').get(request.params.id) as - | { id: string } - | undefined; - - if (!project) { - return reply.status(404).send({ error: 'Project not found' }); - } - - const subProjects = db - .prepare('SELECT * FROM sub_projects WHERE project_id = ? ORDER BY relative_path ASC') - .all(request.params.id) as SubProjectRow[]; - - return { subProjects: subProjects.map(formatSubProject) }; - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/projects/:id/sub-projects — Add sub-project - // ───────────────────────────────────────────────────────────── - app.post<{ - Params: { id: string }; - Body: { - name: string; - relativePath: string; - gitUrl?: string; - defaultBranch?: string; - }; - }>('/api/projects/:id/sub-projects', async (request, reply) => { - const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(request.params.id) as - | ProjectRow - | undefined; - - if (!project) { - return reply.status(404).send({ error: 'Project not found' }); - } - - const { name, relativePath, gitUrl, defaultBranch } = request.body; - - if (!name || !relativePath) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'name and relativePath are required' }, - }); - } - - // Check for duplicate relative path - const existing = db - .prepare('SELECT id FROM sub_projects WHERE project_id = ? AND relative_path = ?') - .get(request.params.id, relativePath) as { id: string } | undefined; - - if (existing) { - return reply.status(409).send({ - error: { code: 'CONFLICT', message: 'A sub-project with this path already exists' }, - }); - } - - const id = nanoid(); - const now = new Date().toISOString(); - - db.prepare( - 'INSERT INTO sub_projects (id, project_id, name, relative_path, git_url, default_branch, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', - ).run(id, request.params.id, name, relativePath, gitUrl ?? null, defaultBranch ?? 'main', now); - - const subProject = db.prepare('SELECT * FROM sub_projects WHERE id = ?').get(id) as SubProjectRow; - const formatted = formatSubProject(subProject); - - broadcast('sub_projects', 'created', id, formatted); - - return reply.status(201).send(formatted); - }); - - // ───────────────────────────────────────────────────────────── - // DELETE /api/projects/:pid/sub-projects/:sid — Delete sub-project - // ───────────────────────────────────────────────────────────── - app.delete<{ Params: { pid: string; sid: string } }>( - '/api/projects/:pid/sub-projects/:sid', - async (request, reply) => { - const subProject = db - .prepare('SELECT * FROM sub_projects WHERE id = ? AND project_id = ?') - .get(request.params.sid, request.params.pid) as SubProjectRow | undefined; - - if (!subProject) { - return reply.status(404).send({ error: 'Sub-project not found' }); - } - - db.prepare('DELETE FROM sub_projects WHERE id = ?').run(request.params.sid); - - broadcast('sub_projects', 'deleted', request.params.sid, { id: request.params.sid }); - - return reply.status(204).send(); - }, - ); -} diff --git a/hub/src/routes/settings.ts b/hub/src/routes/settings.ts deleted file mode 100644 index 08ca8a15..00000000 --- a/hub/src/routes/settings.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { FastifyInstance } from 'fastify'; - -import type { Setting } from '../lib/types.js'; -import { broadcast } from '../ws/broadcaster.js'; - -export async function settingsRoutes(app: FastifyInstance): Promise { - const db = app.db; - - // GET /api/settings — Get all settings as object - app.get('/api/settings', async () => { - const rows = db.prepare('SELECT * FROM settings').all() as Setting[]; - - const result: Record = {}; - for (const row of rows) { - result[row.key] = row.value; - } - - return result; - }); - - // PUT /api/settings — Bulk update settings { key: value, ... } - app.put<{ Body: Record }>('/api/settings', async (request) => { - const entries = request.body; - const now = new Date().toISOString(); - - const upsert = db.prepare( - 'INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', - ); - - const updateMany = db.transaction((items: [string, string][]) => { - for (const [key, value] of items) { - upsert.run(key, value, now); - } - }); - - updateMany(Object.entries(entries)); - - // Return updated settings - const rows = db.prepare('SELECT * FROM settings').all() as Setting[]; - const result: Record = {}; - for (const row of rows) { - result[row.key] = row.value; - } - - broadcast('settings', 'updated', 'bulk', result); - return result; - }); -} diff --git a/hub/src/routes/tasks.ts b/hub/src/routes/tasks.ts deleted file mode 100644 index 22b3d122..00000000 --- a/hub/src/routes/tasks.ts +++ /dev/null @@ -1,716 +0,0 @@ -import type { FastifyInstance } from 'fastify'; -import { nanoid } from 'nanoid'; - -import type { Subtask } from '../lib/types.js'; -import { broadcast } from '../ws/broadcaster.js'; - -// ─── Types ──────────────────────────────────────────────────── - -interface TaskRow { - id: string; - project_id: string; - workspace_id: string | null; - sub_project_id: string | null; - title: string; - description: string; - status: string; - priority: number; - assigned_device_id: string | null; - created_by_device_id: string | null; - execution_session_id: string | null; - progress: string | null; - metadata: string | null; - agent_name: string | null; - activity_history: string; - cost_tokens: number; - cost_usd: number; - pr_number: number | null; - pr_state: string | null; - pr_ci_status: string | null; - pr_url: string | null; - completed_at: string | null; - created_at: string; - updated_at: string; -} - -// ─── Helpers ────────────────────────────────────────────────── - -const PRIORITY_NUMBER_TO_STRING: Record = { - 0: 'low', - 1: 'normal', - 2: 'high', - 3: 'urgent', -}; - -function formatTask(row: TaskRow) { - return { - id: row.id, - projectId: row.project_id, - workspaceId: row.workspace_id ?? undefined, - subProjectId: row.sub_project_id ?? undefined, - title: row.title, - description: row.description, - status: row.status, - priority: PRIORITY_NUMBER_TO_STRING[row.priority] ?? 'normal', - assignedDeviceId: row.assigned_device_id ?? undefined, - createdByDeviceId: row.created_by_device_id ?? undefined, - executionSessionId: row.execution_session_id ?? undefined, - progress: row.progress ? JSON.parse(row.progress) : undefined, - metadata: row.metadata ? JSON.parse(row.metadata) : undefined, - agentName: row.agent_name ?? undefined, - activityHistory: JSON.parse(row.activity_history), - costTokens: row.cost_tokens, - costUsd: row.cost_usd, - prNumber: row.pr_number ?? undefined, - prState: row.pr_state ?? undefined, - prCiStatus: row.pr_ci_status ?? undefined, - prUrl: row.pr_url ?? undefined, - completedAt: row.completed_at ?? undefined, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -// ─── Routes ─────────────────────────────────────────────────── - -export async function taskRoutes(app: FastifyInstance): Promise { - const db = app.db; - - // ───────────────────────────────────────────────────────────── - // GET /api/tasks — List tasks with filters - // ───────────────────────────────────────────────────────────── - app.get<{ - Querystring: { - project_id?: string; - workspace_id?: string; - status?: string; - limit?: string; - offset?: string; - }; - }>('/api/tasks', async (request) => { - const { project_id, workspace_id, status, limit, offset } = request.query; - - let query = 'SELECT * FROM tasks WHERE 1=1'; - const params: unknown[] = []; - - if (project_id) { - query += ' AND project_id = ?'; - params.push(project_id); - } - - if (workspace_id) { - query += ' AND workspace_id = ?'; - params.push(workspace_id); - } - - if (status) { - query += ' AND status = ?'; - params.push(status); - } - - query += ' ORDER BY priority DESC, created_at DESC'; - - if (limit) { - query += ' LIMIT ?'; - params.push(parseInt(limit, 10)); - } - - if (offset) { - query += ' OFFSET ?'; - params.push(parseInt(offset, 10)); - } - - const tasks = db.prepare(query).all(...params) as TaskRow[]; - return { tasks: tasks.map(formatTask) }; - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/tasks — Create task - // ───────────────────────────────────────────────────────────── - app.post<{ - Body: { - project_id: string; - title: string; - description?: string; - status?: string; - priority?: number; - workspace_id?: string; - sub_project_id?: string; - agent_name?: string; - metadata?: Record; - }; - }>('/api/tasks', async (request, reply) => { - const { - project_id, - title, - description, - status, - priority, - workspace_id, - sub_project_id, - agent_name, - metadata, - } = request.body; - - if (!project_id || !title) { - return reply.status(400).send({ error: 'project_id and title are required' }); - } - - const id = nanoid(); - const now = new Date().toISOString(); - - // Get device ID if authenticated - const createdByDeviceId = request.user?.deviceId ?? null; - - db.prepare( - `INSERT INTO tasks ( - id, project_id, workspace_id, sub_project_id, title, description, - status, priority, created_by_device_id, agent_name, metadata, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - id, - project_id, - workspace_id ?? null, - sub_project_id ?? null, - title, - description ?? '', - status ?? 'backlog', - priority ?? 0, - createdByDeviceId, - agent_name ?? null, - metadata ? JSON.stringify(metadata) : null, - now, - now, - ); - - const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow; - const formatted = formatTask(task); - broadcast('tasks', 'created', id, formatted); - return reply.status(201).send(formatted); - }); - - // ───────────────────────────────────────────────────────────── - // GET /api/tasks/:id — Get task with subtasks - // ───────────────────────────────────────────────────────────── - app.get<{ Params: { id: string } }>('/api/tasks/:id', async (request, reply) => { - const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as - | TaskRow - | undefined; - - if (!task) { - return reply.status(404).send({ error: 'Task not found' }); - } - - const subtasks = db - .prepare('SELECT * FROM subtasks WHERE task_id = ? ORDER BY sort_order ASC') - .all(request.params.id) as Subtask[]; - - return { - ...formatTask(task), - subtasks, - }; - }); - - // ───────────────────────────────────────────────────────────── - // PUT /api/tasks/:id — Update task - // ───────────────────────────────────────────────────────────── - app.put<{ - Params: { id: string }; - Body: Partial<{ - title: string; - description: string; - status: string; - priority: number; - project_id: string; - workspace_id: string | null; - sub_project_id: string | null; - assigned_device_id: string | null; - agent_name: string | null; - activity_history: unknown[]; - cost_tokens: number; - cost_usd: number; - pr_number: number | null; - pr_state: string | null; - pr_ci_status: string | null; - pr_url: string | null; - metadata: Record; - }>; - }>('/api/tasks/:id', async (request, reply) => { - const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as - | TaskRow - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Task not found' }); - } - - const now = new Date().toISOString(); - const updates: string[] = []; - const values: unknown[] = []; - - if (request.body.title !== undefined) { - updates.push('title = ?'); - values.push(request.body.title); - } - if (request.body.description !== undefined) { - updates.push('description = ?'); - values.push(request.body.description); - } - if (request.body.status !== undefined) { - updates.push('status = ?'); - values.push(request.body.status); - } - if (request.body.priority !== undefined) { - updates.push('priority = ?'); - values.push(request.body.priority); - } - if (request.body.project_id !== undefined) { - updates.push('project_id = ?'); - values.push(request.body.project_id); - } - if (request.body.workspace_id !== undefined) { - updates.push('workspace_id = ?'); - values.push(request.body.workspace_id); - } - if (request.body.sub_project_id !== undefined) { - updates.push('sub_project_id = ?'); - values.push(request.body.sub_project_id); - } - if (request.body.assigned_device_id !== undefined) { - updates.push('assigned_device_id = ?'); - values.push(request.body.assigned_device_id); - } - if (request.body.agent_name !== undefined) { - updates.push('agent_name = ?'); - values.push(request.body.agent_name); - } - if (request.body.activity_history !== undefined) { - updates.push('activity_history = ?'); - values.push(JSON.stringify(request.body.activity_history)); - } - if (request.body.cost_tokens !== undefined) { - updates.push('cost_tokens = ?'); - values.push(request.body.cost_tokens); - } - if (request.body.cost_usd !== undefined) { - updates.push('cost_usd = ?'); - values.push(request.body.cost_usd); - } - if (request.body.pr_number !== undefined) { - updates.push('pr_number = ?'); - values.push(request.body.pr_number); - } - if (request.body.pr_state !== undefined) { - updates.push('pr_state = ?'); - values.push(request.body.pr_state); - } - if (request.body.pr_ci_status !== undefined) { - updates.push('pr_ci_status = ?'); - values.push(request.body.pr_ci_status); - } - if (request.body.pr_url !== undefined) { - updates.push('pr_url = ?'); - values.push(request.body.pr_url); - } - if (request.body.metadata !== undefined) { - const existingMeta = existing.metadata ? JSON.parse(existing.metadata) : {}; - const merged = { ...existingMeta, ...request.body.metadata }; - updates.push('metadata = ?'); - values.push(JSON.stringify(merged)); - } - - updates.push('updated_at = ?'); - values.push(now); - values.push(request.params.id); - - db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...values); - - const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as TaskRow; - const formatted = formatTask(task); - broadcast('tasks', 'updated', request.params.id, formatted); - return formatted; - }); - - // ───────────────────────────────────────────────────────────── - // DELETE /api/tasks/:id — Delete task - // ───────────────────────────────────────────────────────────── - app.delete<{ Params: { id: string } }>('/api/tasks/:id', async (request, reply) => { - const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as - | TaskRow - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Task not found' }); - } - - db.prepare('DELETE FROM tasks WHERE id = ?').run(request.params.id); - broadcast('tasks', 'deleted', request.params.id, { - id: request.params.id, - projectId: existing.project_id, - }); - return reply.status(204).send(); - }); - - // ───────────────────────────────────────────────────────────── - // PATCH /api/tasks/:id/status — Update just status - // ───────────────────────────────────────────────────────────── - app.patch<{ Params: { id: string }; Body: { status: string } }>( - '/api/tasks/:id/status', - async (request, reply) => { - const { status } = request.body; - - if (!status) { - return reply.status(400).send({ error: 'status is required' }); - } - - const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as - | TaskRow - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Task not found' }); - } - - const now = new Date().toISOString(); - db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?').run( - status, - now, - request.params.id, - ); - - const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as TaskRow; - const formatted = formatTask(task); - broadcast('tasks', 'updated', request.params.id, formatted); - return formatted; - }, - ); - - // ───────────────────────────────────────────────────────────── - // POST /api/tasks/:id/progress — Push progress update - // ───────────────────────────────────────────────────────────── - app.post<{ - Params: { id: string }; - Body: { - phase: string; - phaseIndex: number; - totalPhases: number; - currentAgent: string | null; - filesChanged: number; - logLines: string[]; - status?: string; - cost_tokens?: number; - cost_usd?: number; - }; - }>('/api/tasks/:id/progress', async (request, reply) => { - const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as - | TaskRow - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Task not found' }); - } - - const { phase, phaseIndex, totalPhases, currentAgent, filesChanged, logLines, status, cost_tokens, cost_usd } = - request.body; - - const now = new Date().toISOString(); - - // Build progress object - const progress = { - phase, - phaseIndex, - totalPhases, - currentAgent, - filesChanged, - lastActivity: now, - logs: logLines, - }; - - // Append snapshot to activity_history - const existingHistory: unknown[] = JSON.parse(existing.activity_history); - existingHistory.push({ - timestamp: now, - phase, - phaseIndex, - filesChanged, - }); - - const updates: string[] = ['progress = ?', 'activity_history = ?', 'updated_at = ?']; - const values: unknown[] = [JSON.stringify(progress), JSON.stringify(existingHistory), now]; - - // Optionally update status - if (status) { - updates.push('status = ?'); - values.push(status); - } - - // Update cost tracking - if (cost_tokens !== undefined) { - updates.push('cost_tokens = ?'); - values.push(cost_tokens); - } - if (cost_usd !== undefined) { - updates.push('cost_usd = ?'); - values.push(cost_usd); - } - - values.push(request.params.id); - db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...values); - - const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as TaskRow; - const formatted = formatTask(task); - - // Broadcast progress event with projectId - broadcast('tasks', 'progress', request.params.id, { - taskId: request.params.id, - projectId: task.project_id, - progress: formatted.progress, - }); - - return { - received: true, - taskId: request.params.id, - broadcastedTo: 1, - }; - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/tasks/:id/complete — Mark task complete - // ───────────────────────────────────────────────────────────── - app.post<{ - Params: { id: string }; - Body: { - result: 'success' | 'error'; - prUrl?: string; - errorMessage?: string; - summary?: string; - }; - }>('/api/tasks/:id/complete', async (request, reply) => { - const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as - | TaskRow - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Task not found' }); - } - - const { result, prUrl, errorMessage, summary } = request.body; - const now = new Date().toISOString(); - - const newStatus = result === 'success' ? 'done' : 'error'; - - // Update metadata with completion info - const existingMeta = existing.metadata ? JSON.parse(existing.metadata) : {}; - const updatedMeta = { - ...existingMeta, - completedAt: now, - result, - prUrl, - errorMessage, - summary, - }; - - db.prepare( - 'UPDATE tasks SET status = ?, metadata = ?, completed_at = ?, updated_at = ?, progress = NULL WHERE id = ?', - ).run(newStatus, JSON.stringify(updatedMeta), now, now, request.params.id); - - const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as TaskRow; - const formatted = formatTask(task); - - // Broadcast completion event with projectId - broadcast('tasks', 'completed', request.params.id, { - taskId: request.params.id, - projectId: task.project_id, - result, - prUrl, - summary, - }); - - return formatted; - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/tasks/:id/execute — Request task execution - // ───────────────────────────────────────────────────────────── - app.post<{ Params: { id: string } }>('/api/tasks/:id/execute', async (request, reply) => { - const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as - | TaskRow - | undefined; - - if (!task) { - return reply.status(404).send({ error: 'Task not found' }); - } - - // Get workspace to find host device - if (!task.workspace_id) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'Task must belong to a workspace to execute' }, - }); - } - - const workspace = db - .prepare('SELECT * FROM workspaces WHERE id = ?') - .get(task.workspace_id) as { host_device_id: string | null } | undefined; - - if (!workspace?.host_device_id) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'Workspace has no host device configured' }, - }); - } - - // Check if host device is online - const device = db - .prepare('SELECT * FROM devices WHERE id = ?') - .get(workspace.host_device_id) as { is_online: number } | undefined; - - if (!device?.is_online) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'Host device is offline' }, - }); - } - - const sessionId = `exec-${nanoid()}`; - const now = new Date().toISOString(); - - // Update task status and session - db.prepare( - 'UPDATE tasks SET status = ?, execution_session_id = ?, assigned_device_id = ?, updated_at = ? WHERE id = ?', - ).run('queued', sessionId, workspace.host_device_id, now, request.params.id); - - const updatedTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as TaskRow; - const formatted = formatTask(updatedTask); - - // Broadcast execute command to host device - broadcast('tasks', 'execute', request.params.id, { - type: 'command:execute', - taskId: request.params.id, - projectId: task.project_id, - task: formatted, - }); - - return { - sessionId, - status: 'queued' as const, - }; - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/tasks/:id/cancel — Cancel task execution - // ───────────────────────────────────────────────────────────── - app.post<{ - Params: { id: string }; - Body: { reason?: string }; - }>('/api/tasks/:id/cancel', async (request, reply) => { - const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id) as - | TaskRow - | undefined; - - if (!task) { - return reply.status(404).send({ error: 'Task not found' }); - } - - if (!['queued', 'running'].includes(task.status)) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'Task is not running or queued' }, - }); - } - - const now = new Date().toISOString(); - const previousStatus = task.status; - - // Update task status - db.prepare( - 'UPDATE tasks SET status = ?, execution_session_id = NULL, progress = NULL, updated_at = ? WHERE id = ?', - ).run('backlog', now, request.params.id); - - // Broadcast cancel command to device - if (task.assigned_device_id) { - broadcast('tasks', 'cancel', request.params.id, { - type: 'command:cancel', - taskId: request.params.id, - projectId: task.project_id, - reason: request.body.reason, - }); - } - - return { - success: true, - previousStatus, - }; - }); - - // ───────────────────────────────────────────────────────────── - // Subtask routes (unchanged) - // ───────────────────────────────────────────────────────────── - - // POST /api/tasks/:taskId/subtasks — Add subtask - app.post<{ Params: { taskId: string }; Body: { title: string; description?: string; sort_order?: number } }>( - '/api/tasks/:taskId/subtasks', - async (request, reply) => { - const task = db.prepare('SELECT id FROM tasks WHERE id = ?').get(request.params.taskId) as - | { id: string } - | undefined; - - if (!task) { - return reply.status(404).send({ error: 'Task not found' }); - } - - const { title, description, sort_order } = request.body; - - if (!title) { - return reply.status(400).send({ error: 'title is required' }); - } - - const id = nanoid(); - db.prepare( - 'INSERT INTO subtasks (id, task_id, title, description, sort_order) VALUES (?, ?, ?, ?, ?)', - ).run(id, request.params.taskId, title, description ?? '', sort_order ?? 0); - - const subtask = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(id) as Subtask; - broadcast('subtasks', 'created', id, subtask); - return reply.status(201).send(subtask); - }, - ); - - // PUT /api/subtasks/:id — Update subtask - app.put<{ - Params: { id: string }; - Body: Partial>; - }>('/api/subtasks/:id', async (request, reply) => { - const existing = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(request.params.id) as - | Subtask - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Subtask not found' }); - } - - const title = request.body.title ?? existing.title; - const description = request.body.description ?? existing.description; - const status = request.body.status ?? existing.status; - const sort_order = request.body.sort_order ?? existing.sort_order; - - db.prepare( - 'UPDATE subtasks SET title = ?, description = ?, status = ?, sort_order = ? WHERE id = ?', - ).run(title, description, status, sort_order, request.params.id); - - const subtask = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(request.params.id) as Subtask; - broadcast('subtasks', 'updated', request.params.id, subtask); - return subtask; - }); - - // DELETE /api/subtasks/:id — Delete subtask - app.delete<{ Params: { id: string } }>('/api/subtasks/:id', async (request, reply) => { - const existing = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(request.params.id) as - | Subtask - | undefined; - - if (!existing) { - return reply.status(404).send({ error: 'Subtask not found' }); - } - - db.prepare('DELETE FROM subtasks WHERE id = ?').run(request.params.id); - broadcast('subtasks', 'deleted', request.params.id, existing); - return reply.status(204).send(); - }); -} diff --git a/hub/src/routes/webhooks/github.ts b/hub/src/routes/webhooks/github.ts deleted file mode 100644 index 8696e369..00000000 --- a/hub/src/routes/webhooks/github.ts +++ /dev/null @@ -1,269 +0,0 @@ -import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { nanoid } from 'nanoid'; - -import type { Setting, WebhookCommand } from '../../lib/types.js'; -import { validateGitHubSignature } from '../../lib/webhook-validator.js'; -import { broadcast } from '../../ws/broadcaster.js'; - -const MENTION_PATTERN = /@claudeassistant/i; -const GITHUB_API_BASE = 'https://api.github.com'; - -interface GitHubComment { - id: number; - body: string; - user: { login: string }; - html_url: string; -} - -interface GitHubIssue { - number: number; - title: string; - html_url: string; - pull_request?: { url: string }; -} - -interface GitHubRepo { - full_name: string; - owner: { login: string }; - name: string; -} - -interface GitHubIssueCommentPayload { - action: string; - comment: GitHubComment; - issue: GitHubIssue; - repository: GitHubRepo; -} - -interface GitHubPrReviewCommentPayload { - action: string; - comment: GitHubComment; - pull_request: { - number: number; - title: string; - html_url: string; - }; - repository: GitHubRepo; -} - -function getSetting(db: FastifyInstance['db'], key: string): string | null { - const row = db - .prepare('SELECT value FROM settings WHERE key = ?') - .get(key) as Setting | undefined; - return row?.value ?? null; -} - -/** - * Post a comment on a GitHub issue/PR. - */ -async function postGitHubComment( - token: string, - owner: string, - repo: string, - issueNumber: number, - body: string, -): Promise { - const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/issues/${issueNumber}/comments`; - - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github.v3+json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ body }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`GitHub API error ${String(response.status)}: ${text}`); - } -} - -function insertWebhookCommand( - db: FastifyInstance['db'], - sourceId: string, - sourceChannel: string, - sourceUrl: string, - commandText: string, -): WebhookCommand { - const id = nanoid(); - const now = new Date().toISOString(); - - db.prepare( - `INSERT INTO webhook_commands (id, source, source_id, source_channel, source_url, command_text, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ).run(id, 'github', sourceId, sourceChannel, sourceUrl, commandText, 'pending', now); - - return db.prepare('SELECT * FROM webhook_commands WHERE id = ?').get(id) as WebhookCommand; -} - -async function replyOnGitHub( - app: FastifyInstance, - db: FastifyInstance['db'], - owner: string, - repoName: string, - issueNumber: number, - commandText: string, -): Promise { - const githubToken = getSetting(db, 'github.token'); - - if (!githubToken) { - return; - } - - try { - await postGitHubComment( - githubToken, - owner, - repoName, - issueNumber, - `\u2713 Task created: "${commandText}"\n\n\u2014 Claude Assistant`, - ); - } catch (error: unknown) { - app.log.error({ error }, 'Failed to post GitHub comment'); - } -} - -export async function githubWebhookRoutes(app: FastifyInstance): Promise { - const db = app.db; - - // We need the raw body for signature verification - app.addContentTypeParser( - 'application/json', - { parseAs: 'string' }, - (_req, body, done) => { - done(null, body); - }, - ); - - /** - * POST /api/webhooks/github — GitHub webhook handler - * Filters for issue_comment and pull_request_review_comment events - * that mention @claudeassistant. - */ - app.post('/api/webhooks/github', async (request: FastifyRequest, reply: FastifyReply) => { - const rawBody = request.body as string; - - const webhookSecret = getSetting(db, 'github.webhookSecret'); - - if (!webhookSecret) { - return reply.status(500).send({ error: 'GitHub webhook secret not configured' }); - } - - // Validate signature - const signature = request.headers['x-hub-signature-256'] as string | undefined; - if (!signature) { - return reply.status(401).send({ error: 'Missing X-Hub-Signature-256 header' }); - } - - if (!validateGitHubSignature(webhookSecret, rawBody, signature)) { - return reply.status(401).send({ error: 'Invalid GitHub signature' }); - } - - const eventType = request.headers['x-github-event'] as string | undefined; - - // Only process comment events - if (eventType !== 'issue_comment' && eventType !== 'pull_request_review_comment') { - return reply.send({ ok: true, message: 'Event type ignored' }); - } - - let payload: unknown; - try { - payload = JSON.parse(rawBody); - } catch { - return reply.status(400).send({ error: 'Invalid JSON body' }); - } - - if (eventType === 'issue_comment') { - const data = payload as GitHubIssueCommentPayload; - - if (data.action !== 'created') { - return reply.send({ ok: true, message: 'Action ignored' }); - } - - if (!MENTION_PATTERN.test(data.comment.body)) { - return reply.send({ ok: true, message: 'No mention found' }); - } - - const commandText = data.comment.body.replace(MENTION_PATTERN, '').trim(); - - if (!commandText) { - return reply.send({ ok: true, message: 'Empty command' }); - } - - const repo = data.repository; - const issue = data.issue; - const isPr = Boolean(issue.pull_request); - - const record = insertWebhookCommand( - db, - data.comment.user.login, - repo.full_name, - data.comment.html_url, - commandText, - ); - - broadcast('webhook_command', 'created', record.id, { - ...record, - context: { - repo: repo.full_name, - issueNumber: issue.number, - issueTitle: issue.title, - issueUrl: issue.html_url, - isPullRequest: isPr, - commentAuthor: data.comment.user.login, - }, - }); - - await replyOnGitHub(app, db, repo.owner.login, repo.name, issue.number, commandText); - - return reply.send({ ok: true }); - } - - // pull_request_review_comment - const data = payload as GitHubPrReviewCommentPayload; - - if (data.action !== 'created') { - return reply.send({ ok: true, message: 'Action ignored' }); - } - - if (!MENTION_PATTERN.test(data.comment.body)) { - return reply.send({ ok: true, message: 'No mention found' }); - } - - const commandText = data.comment.body.replace(MENTION_PATTERN, '').trim(); - - if (!commandText) { - return reply.send({ ok: true, message: 'Empty command' }); - } - - const repo = data.repository; - const pr = data.pull_request; - - const record = insertWebhookCommand( - db, - data.comment.user.login, - repo.full_name, - data.comment.html_url, - commandText, - ); - - broadcast('webhook_command', 'created', record.id, { - ...record, - context: { - repo: repo.full_name, - prNumber: pr.number, - prTitle: pr.title, - prUrl: pr.html_url, - isPullRequest: true, - commentAuthor: data.comment.user.login, - }, - }); - - await replyOnGitHub(app, db, repo.owner.login, repo.name, pr.number, commandText); - - return reply.send({ ok: true }); - }); -} diff --git a/hub/src/routes/webhooks/index.ts b/hub/src/routes/webhooks/index.ts deleted file mode 100644 index e72a7038..00000000 --- a/hub/src/routes/webhooks/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { FastifyInstance } from 'fastify'; - -import { githubWebhookRoutes } from './github.js'; -import { slackWebhookRoutes } from './slack.js'; - -export async function webhookRoutes(app: FastifyInstance): Promise { - await app.register(slackWebhookRoutes); - await app.register(githubWebhookRoutes); -} diff --git a/hub/src/routes/webhooks/slack.ts b/hub/src/routes/webhooks/slack.ts deleted file mode 100644 index 7a406e9c..00000000 --- a/hub/src/routes/webhooks/slack.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { FastifyInstance, FastifyRequest } from 'fastify'; -import { nanoid } from 'nanoid'; - -import type { Setting, WebhookCommand } from '../../lib/types.js'; -import { validateSlackSignature } from '../../lib/webhook-validator.js'; -import { broadcast } from '../../ws/broadcaster.js'; - -/** Maximum allowed age for Slack request timestamps (5 minutes). */ -const MAX_TIMESTAMP_AGE_SECONDS = 300; - -interface SlackSlashCommandBody { - command: string; - text: string; - user_id: string; - user_name: string; - channel_id: string; - channel_name: string; - response_url: string; - trigger_id: string; -} - -interface SlackEventPayload { - type: string; - challenge?: string; - event?: { - type: string; - text: string; - user: string; - channel: string; - ts: string; - thread_ts?: string; - }; -} - -function getSetting(db: FastifyInstance['db'], key: string): string | null { - const row = db - .prepare('SELECT value FROM settings WHERE key = ?') - .get(key) as Setting | undefined; - return row?.value ?? null; -} - -export async function slackWebhookRoutes(app: FastifyInstance): Promise { - const db = app.db; - - // Slack sends URL-encoded form data for slash commands, but JSON for events. - // We need the raw body for signature verification, so register a custom content-type parser. - app.addContentTypeParser( - 'application/x-www-form-urlencoded', - { parseAs: 'string' }, - (_req, body, done) => { - done(null, body); - }, - ); - - /** - * POST /api/webhooks/slack/commands — Slash command handler (/claude) - */ - app.post('/api/webhooks/slack/commands', async (request: FastifyRequest, reply) => { - const rawBody = request.body as string; - const signingSecret = getSetting(db, 'slack.signingSecret'); - - if (!signingSecret) { - return reply.status(500).send({ error: 'Slack signing secret not configured' }); - } - - // Validate signature - const timestamp = request.headers['x-slack-request-timestamp'] as string | undefined; - const signature = request.headers['x-slack-signature'] as string | undefined; - - if (!timestamp || !signature) { - return reply.status(401).send({ error: 'Missing Slack signature headers' }); - } - - // Guard against replay attacks - const requestAge = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); - if (requestAge > MAX_TIMESTAMP_AGE_SECONDS) { - return reply.status(401).send({ error: 'Request timestamp too old' }); - } - - if (!validateSlackSignature(signingSecret, timestamp, rawBody, signature)) { - return reply.status(401).send({ error: 'Invalid Slack signature' }); - } - - // Parse URL-encoded body - const params = new URLSearchParams(rawBody); - const commandData: SlackSlashCommandBody = { - command: params.get('command') ?? '', - text: params.get('text') ?? '', - user_id: params.get('user_id') ?? '', - user_name: params.get('user_name') ?? '', - channel_id: params.get('channel_id') ?? '', - channel_name: params.get('channel_name') ?? '', - response_url: params.get('response_url') ?? '', - trigger_id: params.get('trigger_id') ?? '', - }; - - if (!commandData.text.trim()) { - return reply.send({ - response_type: 'ephemeral', - text: 'Usage: /claude ', - }); - } - - // Insert into webhook_commands - const id = nanoid(); - const now = new Date().toISOString(); - - db.prepare( - `INSERT INTO webhook_commands (id, source, source_id, source_channel, source_url, command_text, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - id, - 'slack', - commandData.user_id, - commandData.channel_name, - commandData.response_url, - commandData.text, - 'pending', - now, - ); - - const record = db.prepare('SELECT * FROM webhook_commands WHERE id = ?').get(id) as WebhookCommand; - - // Broadcast via WebSocket - broadcast('webhook_command', 'created', id, record); - - // Respond with ephemeral Slack message (must be within 3 seconds) - return reply.send({ - response_type: 'ephemeral', - text: `\u2713 Got it \u2014 processing: "${commandData.text}"`, - }); - }); - - /** - * POST /api/webhooks/slack/events — Event subscription handler - * Handles URL verification challenge and app_mention events. - */ - app.post('/api/webhooks/slack/events', async (request: FastifyRequest, reply) => { - // Slack events are JSON - const rawBody = typeof request.body === 'string' ? request.body : JSON.stringify(request.body); - - let payload: SlackEventPayload; - try { - payload = JSON.parse(rawBody) as SlackEventPayload; - } catch { - return reply.status(400).send({ error: 'Invalid JSON body' }); - } - - // URL verification challenge — respond immediately without signature check - if (payload.type === 'url_verification' && payload.challenge) { - return reply.send({ challenge: payload.challenge }); - } - - // Validate signature for all other events - const signingSecret = getSetting(db, 'slack.signingSecret'); - - if (!signingSecret) { - return reply.status(500).send({ error: 'Slack signing secret not configured' }); - } - - const timestamp = request.headers['x-slack-request-timestamp'] as string | undefined; - const signature = request.headers['x-slack-signature'] as string | undefined; - - if (!timestamp || !signature) { - return reply.status(401).send({ error: 'Missing Slack signature headers' }); - } - - const requestAge = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); - if (requestAge > MAX_TIMESTAMP_AGE_SECONDS) { - return reply.status(401).send({ error: 'Request timestamp too old' }); - } - - if (!validateSlackSignature(signingSecret, timestamp, rawBody, signature)) { - return reply.status(401).send({ error: 'Invalid Slack signature' }); - } - - // Handle event_callback - if (payload.type === 'event_callback' && payload.event) { - const event = payload.event; - - if (event.type === 'app_mention') { - // Strip the bot mention prefix (e.g., "<@U12345> ") to get the command text - const commandText = event.text.replace(/^<@[A-Z\d]+>\s*/i, '').trim(); - - if (!commandText) { - return reply.send({ ok: true }); - } - - const id = nanoid(); - const now = new Date().toISOString(); - - db.prepare( - `INSERT INTO webhook_commands (id, source, source_id, source_channel, source_url, command_text, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - id, - 'slack', - event.user, - event.channel, - null, - commandText, - 'pending', - now, - ); - - const record = db.prepare('SELECT * FROM webhook_commands WHERE id = ?').get(id) as WebhookCommand; - broadcast('webhook_command', 'created', id, record); - } - } - - return reply.send({ ok: true }); - }); -} diff --git a/hub/src/routes/workspaces.ts b/hub/src/routes/workspaces.ts deleted file mode 100644 index e05458d7..00000000 --- a/hub/src/routes/workspaces.ts +++ /dev/null @@ -1,337 +0,0 @@ -import type { FastifyInstance } from 'fastify'; -import { nanoid } from 'nanoid'; - -import { broadcast } from '../ws/broadcaster.js'; - -// ─── Types ──────────────────────────────────────────────────── - -interface WorkspaceRow { - id: string; - user_id: string; - name: string; - description: string | null; - host_device_id: string | null; - settings: string | null; - created_at: string; - updated_at: string; -} - -interface DeviceRow { - id: string; - user_id: string; - device_name: string; - device_type: string; - is_online: number; -} - -// ─── Helpers ────────────────────────────────────────────────── - -function formatWorkspace(row: WorkspaceRow) { - return { - id: row.id, - userId: row.user_id, - name: row.name, - description: row.description ?? undefined, - hostDeviceId: row.host_device_id ?? undefined, - settings: row.settings ? JSON.parse(row.settings) : undefined, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -// ─── Routes ─────────────────────────────────────────────────── - -export async function workspaceRoutes(app: FastifyInstance): Promise { - const db = app.db; - - // ───────────────────────────────────────────────────────────── - // GET /api/workspaces — List user's workspaces - // ───────────────────────────────────────────────────────────── - app.get('/api/workspaces', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const workspaces = db - .prepare('SELECT * FROM workspaces WHERE user_id = ? ORDER BY created_at DESC') - .all(request.user.id) as WorkspaceRow[]; - - return { - workspaces: workspaces.map(formatWorkspace), - }; - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/workspaces — Create workspace - // ───────────────────────────────────────────────────────────── - app.post<{ - Body: { - name: string; - description?: string; - hostDeviceId?: string; - settings?: { - autoStartQueuedTasks?: boolean; - maxConcurrentAgents?: number; - defaultBranch?: string; - }; - }; - }>('/api/workspaces', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const { name, description, hostDeviceId, settings } = request.body; - - if (!name || name.trim().length < 1) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'Workspace name is required' }, - }); - } - - // Validate host device belongs to user - if (hostDeviceId) { - const device = db - .prepare('SELECT id FROM devices WHERE id = ? AND user_id = ?') - .get(hostDeviceId, request.user.id) as { id: string } | undefined; - - if (!device) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'Invalid host device' }, - }); - } - } - - const id = nanoid(); - const now = new Date().toISOString(); - - db.prepare( - 'INSERT INTO workspaces (id, user_id, name, description, host_device_id, settings, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - ).run( - id, - request.user.id, - name.trim(), - description ?? null, - hostDeviceId ?? null, - settings ? JSON.stringify(settings) : null, - now, - now, - ); - - const workspace = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id) as WorkspaceRow; - const formatted = formatWorkspace(workspace); - - broadcast('workspaces', 'created', id, formatted); - - return reply.status(201).send(formatted); - }); - - // ───────────────────────────────────────────────────────────── - // GET /api/workspaces/:id — Get single workspace - // ───────────────────────────────────────────────────────────── - app.get<{ Params: { id: string } }>('/api/workspaces/:id', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const workspace = db - .prepare('SELECT * FROM workspaces WHERE id = ? AND user_id = ?') - .get(request.params.id, request.user.id) as WorkspaceRow | undefined; - - if (!workspace) { - return reply.status(404).send({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }); - } - - return formatWorkspace(workspace); - }); - - // ───────────────────────────────────────────────────────────── - // PATCH /api/workspaces/:id — Update workspace - // ───────────────────────────────────────────────────────────── - app.patch<{ - Params: { id: string }; - Body: { - name?: string; - description?: string; - hostDeviceId?: string | null; - settings?: Partial<{ - autoStartQueuedTasks: boolean; - maxConcurrentAgents: number; - defaultBranch: string; - }>; - }; - }>('/api/workspaces/:id', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const workspace = db - .prepare('SELECT * FROM workspaces WHERE id = ? AND user_id = ?') - .get(request.params.id, request.user.id) as WorkspaceRow | undefined; - - if (!workspace) { - return reply.status(404).send({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }); - } - - const { name, description, hostDeviceId, settings } = request.body; - - // Validate host device if changing - if (hostDeviceId !== undefined && hostDeviceId !== null) { - const device = db - .prepare('SELECT id FROM devices WHERE id = ? AND user_id = ?') - .get(hostDeviceId, request.user.id) as { id: string } | undefined; - - if (!device) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'Invalid host device' }, - }); - } - } - - const now = new Date().toISOString(); - const updates: string[] = []; - const values: unknown[] = []; - - if (name !== undefined) { - updates.push('name = ?'); - values.push(name.trim()); - } - - if (description !== undefined) { - updates.push('description = ?'); - values.push(description); - } - - if (hostDeviceId !== undefined) { - updates.push('host_device_id = ?'); - values.push(hostDeviceId); - } - - if (settings !== undefined) { - // Merge with existing settings - const existingSettings = workspace.settings ? JSON.parse(workspace.settings) : {}; - const mergedSettings = { ...existingSettings, ...settings }; - updates.push('settings = ?'); - values.push(JSON.stringify(mergedSettings)); - } - - updates.push('updated_at = ?'); - values.push(now); - values.push(request.params.id); - - db.prepare(`UPDATE workspaces SET ${updates.join(', ')} WHERE id = ?`).run(...values); - - const updated = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(request.params.id) as WorkspaceRow; - const formatted = formatWorkspace(updated); - - broadcast('workspaces', 'updated', request.params.id, formatted); - - return formatted; - }); - - // ───────────────────────────────────────────────────────────── - // DELETE /api/workspaces/:id — Delete workspace - // ───────────────────────────────────────────────────────────── - app.delete<{ Params: { id: string } }>('/api/workspaces/:id', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const workspace = db - .prepare('SELECT * FROM workspaces WHERE id = ? AND user_id = ?') - .get(request.params.id, request.user.id) as WorkspaceRow | undefined; - - if (!workspace) { - return reply.status(404).send({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }); - } - - // Note: Projects in this workspace will have workspace_id set to NULL (ON DELETE SET NULL) - db.prepare('DELETE FROM workspaces WHERE id = ?').run(request.params.id); - - broadcast('workspaces', 'deleted', request.params.id, { id: request.params.id }); - - return reply.status(204).send(); - }); - - // ───────────────────────────────────────────────────────────── - // POST /api/workspaces/:id/host — Change host device - // ───────────────────────────────────────────────────────────── - app.post<{ - Params: { id: string }; - Body: { hostDeviceId: string | null }; - }>('/api/workspaces/:id/host', async (request, reply) => { - if (!request.user) { - return reply.status(401).send({ - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, - }); - } - - const workspace = db - .prepare('SELECT * FROM workspaces WHERE id = ? AND user_id = ?') - .get(request.params.id, request.user.id) as WorkspaceRow | undefined; - - if (!workspace) { - return reply.status(404).send({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }); - } - - const { hostDeviceId } = request.body; - - // Validate new host device - if (hostDeviceId !== null) { - const device = db - .prepare('SELECT * FROM devices WHERE id = ? AND user_id = ?') - .get(hostDeviceId, request.user.id) as DeviceRow | undefined; - - if (!device) { - return reply.status(400).send({ - error: { code: 'INVALID_REQUEST', message: 'Invalid host device' }, - }); - } - - // Warn if device is not a desktop - if (device.device_type !== 'desktop') { - return reply.status(400).send({ - error: { - code: 'INVALID_REQUEST', - message: 'Host device must be a desktop device capable of execution', - }, - }); - } - } - - const now = new Date().toISOString(); - db.prepare('UPDATE workspaces SET host_device_id = ?, updated_at = ? WHERE id = ?').run( - hostDeviceId, - now, - request.params.id, - ); - - const updated = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(request.params.id) as WorkspaceRow; - const formatted = formatWorkspace(updated); - - broadcast('workspaces', 'updated', request.params.id, { - ...formatted, - hostChanged: true, - previousHostDeviceId: workspace.host_device_id, - }); - - return formatted; - }); -} diff --git a/hub/src/services/auth-service.ts b/hub/src/services/auth-service.ts deleted file mode 100644 index d4bffdd8..00000000 --- a/hub/src/services/auth-service.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { nanoid } from 'nanoid'; - -import { - generateSessionId, - generateTokenPair, - getSessionExpiry, - hashToken, - verifyAccessToken, - verifyRefreshToken, -} from '../lib/jwt.js'; -import { hashPassword, validateEmail, validatePasswordStrength, verifyPassword } from '../lib/password.js'; - -import type Database from 'better-sqlite3'; - -// ─── Types ──────────────────────────────────────────────────── - -/** Raw user row from the database. */ -interface UserRow { - id: string; - email: string; - password_hash: string; - display_name: string; - avatar_url: string | null; - settings: string | null; - created_at: string; - last_login_at: string | null; -} - -/** Raw session row from the database. */ -interface SessionRow { - id: string; - user_id: string; - device_id: string | null; - refresh_token_hash: string; - expires_at: string; - created_at: string; - last_used_at: string; -} - -/** Public user data (no password_hash). */ -export interface PublicUser { - id: string; - email: string; - displayName: string; - avatarUrl: string | null; - settings?: unknown; - createdAt: string; - lastLoginAt: string | null; -} - -/** Result from register or login operations. */ -export interface AuthResult { - user: PublicUser; - accessToken: string; - refreshToken: string; - expiresAt: string; -} - -/** Result from token refresh. */ -export interface RefreshResult { - accessToken: string; - refreshToken: string; - expiresAt: string; -} - -/** Auth service error with HTTP-friendly code. */ -export class AuthError extends Error { - constructor( - message: string, - public readonly code: 'INVALID_REQUEST' | 'UNAUTHORIZED' | 'CONFLICT' | 'NOT_FOUND', - public readonly statusCode: number, - ) { - super(message); - this.name = 'AuthError'; - } -} - -/** The public interface for the auth service. */ -export interface AuthService { - register: (email: string, password: string, displayName: string) => Promise; - login: (email: string, password: string, deviceId?: string) => Promise; - verify: (accessToken: string) => Promise; - refresh: (refreshToken: string) => Promise; - logout: (sessionId: string) => void; - logoutByRefreshToken: (refreshToken: string) => Promise; - logoutAllSessions: (userId: string) => void; -} - -// ─── Helpers ────────────────────────────────────────────────── - -function formatUser(row: UserRow): PublicUser { - const user: PublicUser = { - id: row.id, - email: row.email, - displayName: row.display_name, - avatarUrl: row.avatar_url ?? null, - createdAt: row.created_at, - lastLoginAt: row.last_login_at ?? null, - }; - - if (row.settings) { - user.settings = JSON.parse(row.settings) as unknown; - } - - return user; -} - -function insertSession( - db: Database.Database, - sessionId: string, - userId: string, - refreshTokenHash: string, - deviceId?: string, -): void { - const now = new Date().toISOString(); - const sessionExpiry = getSessionExpiry(); - - db.prepare( - 'INSERT INTO sessions (id, user_id, device_id, refresh_token_hash, expires_at, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, ?)', - ).run(sessionId, userId, deviceId ?? null, refreshTokenHash, sessionExpiry, now, now); -} - -// ─── Factory ────────────────────────────────────────────────── - -export function createAuthService(db: Database.Database): AuthService { - return { - async register(email: string, password: string, displayName: string): Promise { - // Validate email format - if (!email || !validateEmail(email)) { - throw new AuthError('Invalid email address', 'INVALID_REQUEST', 400); - } - - // Validate password strength - const passwordCheck = validatePasswordStrength(password); - if (!passwordCheck.valid) { - throw new AuthError(passwordCheck.reason ?? 'Invalid password', 'INVALID_REQUEST', 400); - } - - // Validate display name - if (!displayName || displayName.trim().length < 2) { - throw new AuthError( - 'Display name must be at least 2 characters', - 'INVALID_REQUEST', - 400, - ); - } - - // Check email uniqueness - const existing = db - .prepare('SELECT id FROM users WHERE email = ?') - .get(email.toLowerCase()) as { id: string } | undefined; - - if (existing) { - throw new AuthError('Email already registered', 'CONFLICT', 409); - } - - // Hash password with Argon2id - const passwordHash = await hashPassword(password); - const userId = nanoid(); - const now = new Date().toISOString(); - - // Insert user - db.prepare( - 'INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)', - ).run(userId, email.toLowerCase(), passwordHash, displayName.trim(), now); - - // Create session and issue tokens - const sessionId = generateSessionId(); - const tokens = await generateTokenPair(userId, sessionId); - const refreshTokenHash = hashToken(tokens.refreshToken); - insertSession(db, sessionId, userId, refreshTokenHash); - - // Fetch created user - const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as UserRow; - - return { - user: formatUser(user), - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: tokens.expiresAt, - }; - }, - - async login(email: string, password: string, deviceId?: string): Promise { - if (!email || !password) { - throw new AuthError('Email and password are required', 'INVALID_REQUEST', 400); - } - - // Find user by email - const user = db - .prepare('SELECT * FROM users WHERE email = ?') - .get(email.toLowerCase()) as UserRow | undefined; - - if (!user) { - throw new AuthError('Invalid email or password', 'UNAUTHORIZED', 401); - } - - // Verify password - const validPassword = await verifyPassword(password, user.password_hash); - if (!validPassword) { - throw new AuthError('Invalid email or password', 'UNAUTHORIZED', 401); - } - - const now = new Date().toISOString(); - - // Create session and issue tokens - const sessionId = generateSessionId(); - const tokens = await generateTokenPair(user.id, sessionId, deviceId); - const refreshTokenHash = hashToken(tokens.refreshToken); - insertSession(db, sessionId, user.id, refreshTokenHash, deviceId); - - // Update last_login_at - db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(now, user.id); - - return { - user: formatUser({ ...user, last_login_at: now }), - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: tokens.expiresAt, - }; - }, - - async verify(accessToken: string): Promise { - // Verify JWT signature and expiry - const payload = await verifyAccessToken(accessToken); - if (!payload) { - throw new AuthError('Invalid or expired access token', 'UNAUTHORIZED', 401); - } - - // Fetch user from database - const user = db - .prepare('SELECT * FROM users WHERE id = ?') - .get(payload.sub) as UserRow | undefined; - - if (!user) { - throw new AuthError('User not found', 'NOT_FOUND', 404); - } - - return formatUser(user); - }, - - async refresh(refreshToken: string): Promise { - if (!refreshToken) { - throw new AuthError('Refresh token is required', 'INVALID_REQUEST', 400); - } - - // Verify refresh token JWT - const payload = await verifyRefreshToken(refreshToken); - if (!payload) { - throw new AuthError('Invalid or expired refresh token', 'UNAUTHORIZED', 401); - } - - // Find session by ID from the token payload - const session = db - .prepare('SELECT * FROM sessions WHERE id = ?') - .get(payload.sessionId) as SessionRow | undefined; - - if (!session) { - throw new AuthError('Session not found', 'UNAUTHORIZED', 401); - } - - // Check session expiry - if (new Date(session.expires_at) < new Date()) { - db.prepare('DELETE FROM sessions WHERE id = ?').run(session.id); - throw new AuthError('Session expired', 'UNAUTHORIZED', 401); - } - - // Verify the refresh token hash matches the stored hash - const tokenHash = hashToken(refreshToken); - if (tokenHash !== session.refresh_token_hash) { - throw new AuthError('Invalid refresh token', 'UNAUTHORIZED', 401); - } - - // Generate new token pair - const newTokens = await generateTokenPair( - payload.sub, - payload.sessionId, - session.device_id ?? undefined, - ); - - // Update session with new refresh token hash and expiry - const now = new Date().toISOString(); - const newRefreshHash = hashToken(newTokens.refreshToken); - const newExpiry = getSessionExpiry(); - - db.prepare( - 'UPDATE sessions SET refresh_token_hash = ?, expires_at = ?, last_used_at = ? WHERE id = ?', - ).run(newRefreshHash, newExpiry, now, session.id); - - return { - accessToken: newTokens.accessToken, - refreshToken: newTokens.refreshToken, - expiresAt: newTokens.expiresAt, - }; - }, - - logout(sessionId: string): void { - db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId); - }, - - async logoutByRefreshToken(refreshToken: string): Promise { - const payload = await verifyRefreshToken(refreshToken); - if (payload) { - db.prepare('DELETE FROM sessions WHERE id = ?').run(payload.sessionId); - } - }, - - logoutAllSessions(userId: string): void { - db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId); - }, - }; -} diff --git a/hub/src/ws/broadcaster.ts b/hub/src/ws/broadcaster.ts deleted file mode 100644 index cdc3dc02..00000000 --- a/hub/src/ws/broadcaster.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { WebSocket } from 'ws'; - -import type { WsBroadcastMessage } from '../lib/types.js'; - -const authenticatedClients = new Set(); - -/** - * Add an authenticated client to the broadcast pool. - * Only call this after the client has been authenticated via the first-message protocol. - */ -export function addAuthenticatedClient(socket: WebSocket): void { - authenticatedClients.add(socket); - - socket.on('close', () => { - authenticatedClients.delete(socket); - }); - - socket.on('error', () => { - authenticatedClients.delete(socket); - }); -} - -export function broadcast( - entity: string, - action: WsBroadcastMessage['action'], - id: string, - data: unknown, -): void { - const message: WsBroadcastMessage = { - type: 'mutation', - entity, - action, - id, - data, - timestamp: new Date().toISOString(), - }; - - const payload = JSON.stringify(message); - - for (const client of authenticatedClients) { - if (client.readyState === client.OPEN) { - client.send(payload); - } - } -} diff --git a/hub/tsconfig.json b/hub/tsconfig.json deleted file mode 100644 index cc487cc9..00000000 --- a/hub/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 00000000..9d6d211c --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,44 @@ +# lefthook.yml — git hook automation for Agent Desktop Command +# Run once after cloning: npx lefthook install +# Docs: https://github.com/evilmartians/lefthook +# +# What this covers (complements the Claude Code PostToolUse hooks): +# - Pre-commit runs on ALL staged files, including manual edits Claude didn't touch +# - Typecheck catches cross-file type errors that per-file eslint misses +# - Stage-fixed ensures auto-fixed files are included in the commit +# +# To add a custom pre-commit script: add to pre-commit.commands and reference +# a script from .claude/scripts/ or scripts/ + +pre-commit: + parallel: true + commands: + + # Full TypeScript check — catches cross-file errors eslint misses. + # ~10-30s on this codebase. Skip with: git commit --no-verify + typecheck: + run: npx tsc --noEmit + fail_text: "TypeScript errors — fix before committing (or git commit --no-verify to skip)" + + # ESLint on staged TS/JS files only — faster than full lint + eslint: + glob: "*.{ts,tsx,js,jsx,mjs,cjs}" + run: npx eslint --fix --no-warn-ignored {staged_files} + stage_fixed: true + fail_text: "ESLint errors found" + + # Prettier on staged files + prettier: + glob: "*.{ts,tsx,css,json,md,yaml,yml}" + run: npx prettier --write {staged_files} + stage_fixed: true + + # Regenerate ai-codex index when source files change. + # Keeps .claude/codex/lib.md current so Claude always has accurate function signatures. + ai-codex: + glob: "*.{ts,tsx,mjs,cjs}" + run: npx ai-codex --output .claude/codex && git add .claude/codex/ + stage_fixed: true + +# pre-push: typecheck already runs pre-commit, no need to repeat here. +# Add to pre-push if you want a second safety net before pushing. diff --git a/mempalace.yaml b/mempalace.yaml new file mode 100644 index 00000000..c22ffdd7 --- /dev/null +++ b/mempalace.yaml @@ -0,0 +1,115 @@ +wing: agent_desktop_command +rooms: +- name: agents + description: Files from agents/ + keywords: + - agents + - agents +- name: ai_docs + description: Files from ai-docs/ + keywords: + - ai_docs + - ai-docs +- name: design + description: Files from assets/ + keywords: + - design + - assets +- name: autoresearch + description: Files from autoresearch/ + keywords: + - autoresearch + - autoresearch +- name: brand + description: Files from brand/ + keywords: + - brand + - brand +- name: certs + description: Files from certs/ + keywords: + - certs + - certs +- name: documentation + description: Files from docs/ + keywords: + - documentation + - docs +- name: drizzle + description: Files from drizzle/ + keywords: + - drizzle + - drizzle +- name: hub + description: Files from hub/ + keywords: + - hub + - hub +- name: logs + description: Files from logs/ + keywords: + - logs + - logs +- name: nginx + description: Files from nginx/ + keywords: + - nginx + - nginx +- name: out + description: Files from out/ + keywords: + - out + - out +- name: progress + description: Files from progress/ + keywords: + - progress + - progress +- name: resources + description: Files from resources/ + keywords: + - resources + - resources +- name: scripts + description: Files from scripts/ + keywords: + - scripts + - scripts +- name: src + description: Files from src/ + keywords: + - src + - src +- name: test_suite + description: Files from test-suite/ + keywords: + - test_suite + - test-suite +- name: testing + description: Files from tests/ + keywords: + - testing + - tests +- name: tracking + description: Files from tracking/ + keywords: + - tracking + - tracking +- name: planning + description: Files from specs/ + keywords: + - planning + - specs +- name: research + description: Files from research/ + keywords: + - research + - research +- name: frontend + description: Files from ui/ + keywords: + - frontend + - ui +- name: general + description: Files that don't fit other rooms + keywords: [] diff --git a/nginx/conf.d/claude-ui.conf b/nginx/conf.d/claude-ui.conf deleted file mode 100644 index 8ea7b406..00000000 --- a/nginx/conf.d/claude-ui.conf +++ /dev/null @@ -1,44 +0,0 @@ -upstream hub_backend { - server hub:3200; -} - -server { - listen 80; - server_name _; - return 301 https://$host$request_uri; -} - -server { - listen 443 ssl; - server_name _; - - ssl_certificate /certs/server.crt; - ssl_certificate_key /certs/server.key; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; - - # API routes - location /api/ { - proxy_pass http://hub_backend; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # WebSocket - location /ws { - proxy_pass http://hub_backend; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_read_timeout 86400; - } - - # Health check - location /health { - proxy_pass http://hub_backend; - } -} diff --git a/nginx/nginx.conf b/nginx/nginx.conf deleted file mode 100644 index 999b88c6..00000000 --- a/nginx/nginx.conf +++ /dev/null @@ -1,11 +0,0 @@ -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/conf.d/*.conf; -} diff --git a/package-lock.json b/package-lock.json index bd1cf344..b2733f65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,27 @@ { "name": "adc", - "version": "0.1.0", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "adc", - "version": "0.1.0", + "version": "0.3.2", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "@dagrejs/dagre": "^1.0.4", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist-mono": "^5.2.7", "@git-diff-view/core": "^0.0.39", "@git-diff-view/react": "^0.0.39", "@lydell/node-pty": "^1.1.0", + "@peculiar/webcrypto": "^1.5.0", + "@peculiar/x509": "^2.0.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", @@ -45,6 +50,7 @@ "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.10.2", "better-sqlite3": "^12.8.0", + "bonjour-service": "^1.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", @@ -91,6 +97,7 @@ "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^4.0.16", "autoprefixer": "^10.4.22", + "cross-env": "^10.1.0", "drizzle-kit": "^0.31.10", "electron": "39.2.7", "electron-builder": "^26.4.0", @@ -108,6 +115,7 @@ "eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-sonarjs": "^3.0.7", "eslint-plugin-unicorn": "^58.0.0", + "fast-check": "^4.7.0", "gif-encoder-2": "^1.0.5", "globals": "^16.5.0", "icon-gen": "^5.0.0", @@ -117,6 +125,7 @@ "postcss": "^8.5.6", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.6.14", + "reflect-metadata": "^0.2.2", "svgo": "^4.0.1", "tailwindcss": "^4.1.17", "typescript": "^5.9.3", @@ -558,6 +567,59 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", @@ -1040,6 +1102,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", @@ -3265,6 +3334,12 @@ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", "license": "MIT" }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@lydell/node-pty": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", @@ -3485,6 +3560,181 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", + "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", + "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", + "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", + "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", + "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pfx": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", + "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", + "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2", + "webcrypto-core": "^1.8.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@peculiar/x509": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.0.0.tgz", + "integrity": "sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -7530,6 +7780,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -7786,6 +8050,16 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -8601,6 +8875,24 @@ "optional": true, "peer": true }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9186,6 +9478,18 @@ "@babel/runtime": "^7.9.2" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -10838,6 +11142,29 @@ "license": "MIT", "optional": true }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -14544,6 +14871,19 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -15687,6 +16027,41 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -16114,6 +16489,13 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -17639,6 +18021,12 @@ "tslib": "^2" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -18325,6 +18713,24 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -19521,6 +19927,19 @@ "defaults": "^1.0.3" } }, + "node_modules/webcrypto-core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", + "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.7.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c5d262ad..bea30dbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "adc", - "version": "0.1.0", + "version": "0.3.2", "type": "module", "description": "ADC — Desktop UI for multi-project management with agent team orchestration, automated QA loops, and agentic local software testing", "main": "./out/main/index.cjs", @@ -14,7 +14,10 @@ "scripts": { "dev": "electron-vite dev", "dev:mcp": "electron-vite dev -- --remote-debugging-port=9222", + "dev:kill": "node scripts/kill-dev.mjs", "build": "electron-vite build", + "build:local": "cross-env ADC_CHANNEL=local electron-vite build && electron-builder --win --dir --config.productName=ADC-Local --config.appId=com.adc.app.local --config.win.icon=resources/icon-local.ico --config.nsis.artifactName=ADC-Local-Setup-${version}.${ext} --config.extraMetadata.name=adc-local", + "build:local:installer": "cross-env ADC_CHANNEL=local electron-vite build && electron-builder --win --config.productName=ADC-Local --config.appId=com.adc.app.local --config.win.icon=resources/icon-local.ico --config.nsis.artifactName=ADC-Local-Setup-${version}.${ext} --config.extraMetadata.name=adc-local", "start": "electron .", "preview": "electron-vite preview", "lint": "eslint src/", @@ -23,12 +26,17 @@ "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"", "typecheck": "tsc --noEmit", "test": "npm run test:unit && npm run test:integration", + "pretest:unit": "node scripts/rebuild-sqlite-for-node.mjs", "test:unit": "vitest run --config vitest.config.ts", + "posttest:unit": "node scripts/rebuild-sqlite-for-electron.mjs", "test:unit:watch": "vitest --config vitest.config.ts", + "rebuild:sqlite:node": "node scripts/rebuild-sqlite-for-node.mjs", + "rebuild:sqlite:electron": "node scripts/rebuild-sqlite-for-electron.mjs", "test:integration": "vitest run --config vitest.integration.config.ts", "test:integration:watch": "vitest --config vitest.integration.config.ts", "test:coverage": "vitest run --config vitest.config.ts --coverage", "test:watch": "vitest", + "pretest:e2e": "node scripts/rebuild-sqlite-for-electron.mjs", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright test && bash scripts/post-e2e-screenshots.sh", @@ -37,11 +45,17 @@ "validate:tracker": "node scripts/validate-tracker.mjs", "verify": "npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:docs && npm run validate:tracker", "icons:generate": "node scripts/generate-logo-svg.js && svgo resources/icon.svg -o resources/icon-optimized.svg && node scripts/build-icons.js", + "icons:channels": "node scripts/build-channel-icons.mjs", + "reset:local": "node scripts/reset-channel.mjs local", + "reset:dev": "node scripts/reset-channel.mjs dev", "postinstall": "electron-rebuild -f -w better-sqlite3" }, "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "@dagrejs/dagre": "^1.0.4", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist-mono": "^5.2.7", "@git-diff-view/core": "^0.0.39", @@ -75,6 +89,9 @@ "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.10.2", "better-sqlite3": "^12.8.0", + "@peculiar/webcrypto": "^1.5.0", + "@peculiar/x509": "^2.0.0", + "bonjour-service": "^1.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", @@ -121,6 +138,7 @@ "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^4.0.16", "autoprefixer": "^10.4.22", + "cross-env": "^10.1.0", "drizzle-kit": "^0.31.10", "electron": "39.2.7", "electron-builder": "^26.4.0", @@ -138,6 +156,7 @@ "eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-sonarjs": "^3.0.7", "eslint-plugin-unicorn": "^58.0.0", + "fast-check": "^4.7.0", "gif-encoder-2": "^1.0.5", "globals": "^16.5.0", "icon-gen": "^5.0.0", @@ -147,6 +166,7 @@ "postcss": "^8.5.6", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.6.14", + "reflect-metadata": "^0.2.2", "svgo": "^4.0.1", "tailwindcss": "^4.1.17", "typescript": "^5.9.3", @@ -165,8 +185,22 @@ "out/**/*" ], "extraResources": [ - "drizzle/**" + "drizzle/**", + { + "from": "resources/icon-16.png", + "to": "icon-16.png" + }, + { + "from": "resources/icon-32.png", + "to": "icon-32.png" + } ], + "publish": { + "provider": "github", + "owner": "ParkerM2", + "repo": "Agentic-Desktop-Command", + "releaseType": "draft" + }, "win": { "icon": "resources/icon.ico", "target": [ @@ -176,11 +210,14 @@ "x64" ] } - ] + ], + "artifactName": "ADC-Setup-${version}-${arch}.${ext}" }, "mac": { "icon": "resources/icon.icns", "category": "public.app-category.developer-tools", + "identity": "-", + "gatekeeperAssess": false, "target": [ { "target": "dmg", @@ -188,8 +225,16 @@ "x64", "arm64" ] + }, + { + "target": "zip", + "arch": [ + "x64", + "arm64" + ] } - ] + ], + "artifactName": "ADC-${version}-${arch}.${ext}" }, "linux": { "icon": "resources/icon.png", diff --git a/resources/icon-dev.ico b/resources/icon-dev.ico new file mode 100644 index 00000000..dcbaee28 Binary files /dev/null and b/resources/icon-dev.ico differ diff --git a/resources/icon-local.ico b/resources/icon-local.ico new file mode 100644 index 00000000..a077a231 Binary files /dev/null and b/resources/icon-local.ico differ diff --git a/scripts/build-channel-icons.mjs b/scripts/build-channel-icons.mjs new file mode 100644 index 00000000..822d1765 --- /dev/null +++ b/scripts/build-channel-icons.mjs @@ -0,0 +1,76 @@ +/** + * build-channel-icons.mjs + * + * Produces per-channel Windows ICO variants for taskbar disambiguation: + * resources/icon-local.ico — orange-tinted (hue +30°) — local production builds + * resources/icon-dev.ico — green-tinted (hue +140°) — dev/HMR builds + * + * Source: resources/icon-optimized.svg (same authoritative SVG used by build-icons.js). + * Pipeline: sharp rasterizes the SVG at each ICO size and applies .modulate({ hue, saturation }) + * → PNG buffers written to a temp dir → icon-gen assembles .ico from that dir. + * + * Run via: npm run icons:channels + */ + +import generateIcon from 'icon-gen' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.join(__dirname, '..') + +const INPUT = path.join(ROOT, 'resources', 'icon-optimized.svg') +const OUTPUT = path.join(ROOT, 'resources') + +// Match the sizes used for the release icon (see scripts/build-icons.js). +const ICO_SIZES = [16, 24, 32, 48, 64, 128, 256] + +// Channels: hue rotation in degrees + a small saturation boost so the shift is +// visible even when the source art has moderate saturation. Values picked so +// local (orange) and dev (green) are clearly distinct from each other and from +// the release icon. +const CHANNELS = [ + { name: 'local', hue: 30, saturation: 1.25 }, + { name: 'dev', hue: 140, saturation: 1.25 }, +] + +if (!fs.existsSync(INPUT)) { + console.error(`✗ Source SVG not found: ${INPUT}`) + console.error(' Run: npm run icons:generate') + process.exit(1) +} + +for (const channel of CHANNELS) { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), `adc-icon-${channel.name}-`)) + try { + // Rasterize the SVG at each ICO size with hue+saturation shift applied. + // icon-gen's directory mode expects files named `.png`. + for (const size of ICO_SIZES) { + const buf = await sharp(INPUT) + .resize(size, size) + .modulate({ hue: channel.hue, saturation: channel.saturation }) + .png() + .toBuffer() + fs.writeFileSync(path.join(workDir, `${size}.png`), buf) + } + + // icon-gen writes to a directory; point it at our temp dir and let it + // emit icon-.ico directly in resources/. + await generateIcon(workDir, OUTPUT, { + report: true, + ico: { + name: `icon-${channel.name}`, + sizes: ICO_SIZES, + }, + }) + + console.log(`✓ resources/icon-${channel.name}.ico`) + } finally { + fs.rmSync(workDir, { force: true, recursive: true }) + } +} + +console.log('✓ Per-channel icons generated') diff --git a/scripts/generate-certs.sh b/scripts/generate-certs.sh deleted file mode 100644 index ab9013f5..00000000 --- a/scripts/generate-certs.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -# Generate self-signed TLS certificates for Claude-UI Hub -# Usage: ./scripts/generate-certs.sh [output-dir] - -CERT_DIR="${1:-./certs}" -mkdir -p "$CERT_DIR" - -openssl req -x509 -nodes -days 3650 \ - -newkey rsa:2048 \ - -keyout "$CERT_DIR/server.key" \ - -out "$CERT_DIR/server.crt" \ - -subj "/C=US/ST=Local/L=Local/O=Claude-UI/CN=claude-ui.local" \ - -addext "subjectAltName=DNS:claude-ui.local,DNS:localhost,IP:127.0.0.1,IP:192.168.0.0/16,IP:10.0.0.0/8" - -echo "Certificates generated in $CERT_DIR/" -echo " - server.crt (certificate)" -echo " - server.key (private key)" diff --git a/scripts/hooks/on-edit-dispatch.mjs b/scripts/hooks/on-edit-dispatch.mjs new file mode 100644 index 00000000..575108a1 --- /dev/null +++ b/scripts/hooks/on-edit-dispatch.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * PostToolUse Hook — Custom script dispatcher + * + * Fires after Edit or Write. Reads .claude/automate.json, matches the edited + * file path against onEdit[].glob patterns, and runs matching scripts from + * .claude/scripts/. Never blocks (always exits 0). + * + * Scripts receive: FILE_PATH (env), PROJECT_DIR (env) + * Scripts must exit 0; their stdout is forwarded to Claude context. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '../..'); + +// ─── Glob matcher ────────────────────────────────────────────────────────────── +// Handles: * (non-slash), ** (any), ?, {a,b} (alternation) + +function matchesGlob(filePath, pattern) { + const fp = filePath.replace(/\\/g, '/'); + const pat = pattern.replace(/\\/g, '/'); + + // Expand {a,b} alternations recursively + const braceMatch = pat.match(/\{([^}]+)\}/); + if (braceMatch) { + return braceMatch[1].split(',').some((alt) => + matchesGlob(fp, pat.replace(braceMatch[0], alt.trim())) + ); + } + + const regexStr = pat + .replace(/[.+^$|()[\]\\]/g, '\\$&') // escape regex specials (not * ? {}) + .replace(/\*\*\//g, '(?:[^/]+/)*') // **/ → zero or more path segments + .replace(/\*\*/g, '.*') // ** → anything + .replace(/\*/g, '[^/]+') // * → one segment (no slashes) + .replace(/\?/g, '[^/]'); // ? → single non-slash char + + try { + return new RegExp('^' + regexStr + '$').test(fp); + } catch { + return false; + } +} + +// ─── Main ────────────────────────────────────────────────────────────────────── + +try { + const raw = readFileSync(0, 'utf-8').trim(); + if (!raw) process.exit(0); + + const payload = JSON.parse(raw); + const toolName = payload.tool_name ?? ''; + if (!['Edit', 'Write'].includes(toolName)) process.exit(0); + + // file_path comes from tool_input (stdin JSON) or TOOL_ARG_FILE_PATH env var + const filePath = + payload.tool_input?.file_path ?? + process.env.TOOL_ARG_FILE_PATH ?? + ''; + + if (!filePath) process.exit(0); + + // Normalize to project-relative path + const relPath = filePath.replace(/\\/g, '/').replace(ROOT.replace(/\\/g, '/') + '/', ''); + + // Load automate.json + const automateFile = resolve(ROOT, '.claude/automate.json'); + if (!existsSync(automateFile)) process.exit(0); + + const config = JSON.parse(readFileSync(automateFile, 'utf-8')); + const rules = config.onEdit ?? []; + const scriptsDir = resolve(ROOT, config.scriptsDir ?? '.claude/scripts'); + + const output = []; + + for (const rule of rules) { + if (!matchesGlob(relPath, rule.glob)) continue; + if (!rule.scripts?.length) continue; + + for (const scriptName of rule.scripts) { + const scriptPath = resolve(scriptsDir, `${scriptName}.mjs`); + if (!existsSync(scriptPath)) { + output.push(`[automate] WARN: script not found: ${scriptName}.mjs`); + continue; + } + + const result = spawnSync(process.execPath, [scriptPath], { + env: { + ...process.env, + FILE_PATH: filePath, + REL_PATH: relPath, + PROJECT_DIR: ROOT, + }, + encoding: 'utf-8', + timeout: 15_000, + }); + + const out = (result.stdout ?? '').trim(); + const err = (result.stderr ?? '').trim(); + + if (out) output.push(`[${scriptName}] ${out}`); + if (err) output.push(`[${scriptName}] ERR: ${err}`); + if (result.status !== 0 && result.status !== null) { + output.push(`[${scriptName}] exited ${result.status}`); + } + } + } + + if (output.length) process.stdout.write(output.join('\n') + '\n'); +} catch { + // Silent fail — never block an edit +} + +process.exit(0); diff --git a/scripts/hooks/on-session-start.mjs b/scripts/hooks/on-session-start.mjs new file mode 100644 index 00000000..e34227d6 --- /dev/null +++ b/scripts/hooks/on-session-start.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * SessionStart Hook — Project context loader + * + * Fires at the start of every Claude session. Outputs: + * 1. Pending doc updates (from PENDING_DOC_UPDATE.json) so Claude knows + * what changed and what needs to be documented without a full audit. + * 2. Active automate.json rules summary so Claude knows what runs on edits. + * + * Keep output concise — it's injected into every session's context. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '../..'); + +function loadJSON(path) { + if (!existsSync(path)) return null; + try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; } +} + +// ─── Main ────────────────────────────────────────────────────────────────────── + +try { + const config = loadJSON(resolve(ROOT, '.claude/automate.json')); + const pending = loadJSON( + resolve(ROOT, config?.docSync?.pendingFile ?? '.claude/progress/PENDING_DOC_UPDATE.json') + ); + + const lines = []; + + // ── Pending doc updates ──────────────────────────────────────────────────── + if (pending?.pendingDomains?.length) { + const domains = pending.pendingDomains.slice(0, 10); + const extra = pending.pendingDomains.length > 10 ? ` +${pending.pendingDomains.length - 10} more` : ''; + const age = pending.lastSession + ? `last session ${new Date(pending.lastSession).toLocaleDateString()}` + : 'previous session'; + + lines.push( + `[doc-sync] PENDING: ${pending.pendingDomains.length} domain(s) changed since last doc sync (${age}).`, + ` Domains: ${domains.join(', ')}${extra}`, + ` Files changed: ${pending.fileChangeSummary?.totalFiles ?? '?'} ` + + `(ipc:${pending.fileChangeSummary?.ipcFiles ?? 0} ` + + `main:${pending.fileChangeSummary?.mainFiles ?? 0} ` + + `renderer:${pending.fileChangeSummary?.rendererFiles ?? 0})`, + ` Action: run /doc-sync or ask Claude to update .claude/progress/adc-codebase-state-2026-04-13.html for affected domains.` + ); + } + + // ── Active onEdit rules summary ──────────────────────────────────────────── + if (config?.onEdit?.length) { + const activeRules = config.onEdit + .filter((r) => r.scripts?.length > 0) + .map((r) => `${r.glob} → ${r.scripts.join(', ')}`); + + if (activeRules.length) { + lines.push(`[automate] Active onEdit rules: ${activeRules.join(' | ')}`); + } + } + + if (lines.length) { + process.stdout.write(lines.join('\n') + '\n'); + } +} catch { + // Silent fail — never disrupt session start +} + +process.exit(0); diff --git a/scripts/hooks/on-stop-doc-sync.mjs b/scripts/hooks/on-stop-doc-sync.mjs new file mode 100644 index 00000000..71170b98 --- /dev/null +++ b/scripts/hooks/on-stop-doc-sync.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * Stop Hook — Domain change tracker + * + * Fires when Claude ends a session. Reads git status to detect modified files, + * extracts domain names from paths using automate.json patterns, and writes + * .claude/progress/PENDING_DOC_UPDATE.json. + * + * The next SessionStart hook reads this file and surfaces pending doc updates + * as context so Claude knows what changed without a full audit. + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '../..'); + +function loadConfig() { + const f = resolve(ROOT, '.claude/automate.json'); + if (!existsSync(f)) return null; + try { return JSON.parse(readFileSync(f, 'utf-8')); } catch { return null; } +} + +function getChangedFiles() { + // Use git status --short to get all modified/untracked files in working tree + const result = spawnSync('git', ['-C', ROOT, 'status', '--short', '--porcelain'], { + encoding: 'utf-8', + timeout: 5_000, + }); + if (result.status !== 0 || !result.stdout) return []; + + return result.stdout + .split('\n') + .filter(Boolean) + .map((line) => { + // Format: "XY filename" or "XY old -> new" + const parts = line.trim().split(/\s+/); + return parts[parts.length - 1].replace(/\\/g, '/'); + }) + .filter(Boolean); +} + +function extractDomains(files, patterns) { + const domains = new Set(); + for (const file of files) { + for (const pattern of patterns) { + try { + const m = file.match(new RegExp(pattern)); + if (m?.[1]) domains.add(m[1]); + } catch { /* ignore bad patterns */ } + } + } + return [...domains].sort(); +} + +function categorizeFiles(files) { + const categories = { + ipc: files.filter((f) => f.includes('src/shared/ipc/')), + main: files.filter((f) => f.includes('src/main/features/')), + renderer: files.filter((f) => f.includes('src/renderer/features/')), + schema: files.filter((f) => f.includes('schema.ts')), + migration: files.filter((f) => f.includes('drizzle/')), + other: files.filter( + (f) => + !f.includes('src/shared/ipc/') && + !f.includes('src/main/features/') && + !f.includes('src/renderer/features/') + ), + }; + return categories; +} + +// ─── Main ────────────────────────────────────────────────────────────────────── + +try { + const config = loadConfig(); + const domainPatterns = config?.docSync?.domainPatterns ?? [ + 'src/renderer/features/([^/]+)/', + 'src/main/features/([^/]+)/', + 'src/shared/ipc/([^/]+)/', + ]; + + const changedFiles = getChangedFiles(); + if (!changedFiles.length) process.exit(0); + + const domains = extractDomains(changedFiles, domainPatterns); + const categories = categorizeFiles(changedFiles); + const pendingFile = resolve(ROOT, config?.docSync?.pendingFile ?? '.claude/progress/PENDING_DOC_UPDATE.json'); + + // Load existing pending updates (accumulate across sessions) + let existing = {}; + if (existsSync(pendingFile)) { + try { existing = JSON.parse(readFileSync(pendingFile, 'utf-8')); } catch { existing = {}; } + } + + // Merge new changes + const update = { + lastSession: new Date().toISOString(), + sessions: (existing.sessions ?? 0) + 1, + pendingDomains: [...new Set([...(existing.pendingDomains ?? []), ...domains])].sort(), + fileChangeSummary: { + ipcFiles: categories.ipc.length, + mainFiles: categories.main.length, + rendererFiles: categories.renderer.length, + schemaChanges: categories.schema.length, + migrationChanges: categories.migration.length, + totalFiles: changedFiles.length, + }, + recentFiles: changedFiles.slice(0, 20), // cap at 20 for file size + }; + + writeFileSync(pendingFile, JSON.stringify(update, null, 2)); + + // Output summary to Claude context (capped at ~400 chars) + if (domains.length > 0) { + const domainList = domains.slice(0, 8).join(', ') + (domains.length > 8 ? ` +${domains.length - 8} more` : ''); + process.stdout.write( + `[doc-sync] ${changedFiles.length} files changed across domains: ${domainList}. ` + + `PENDING_DOC_UPDATE.json updated. Run doc sync to patch codebase-state.html.\n` + ); + } +} catch { + // Silent fail +} + +process.exit(0); diff --git a/scripts/install-mac.sh b/scripts/install-mac.sh new file mode 100644 index 00000000..5f9585e4 --- /dev/null +++ b/scripts/install-mac.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# ADC macOS installer — downloads the latest release, installs it to +# /Applications, and strips com.apple.quarantine so Gatekeeper doesn't +# claim the app is "damaged". +# +# Usage: +# curl -sSL https://github.com/ParkerM2/Agentic-Desktop-Command/releases/latest/download/install-mac.sh | bash +# +# Or download and run locally: +# bash install-mac.sh # latest +# bash install-mac.sh 0.1.6 # specific version +# +# ADC is distributed unsigned (no Apple Developer subscription). This script +# is the supported install path — using `curl` to fetch the DMG avoids the +# browser-applied quarantine attribute that triggers the "damaged" dialog, +# and the final `xattr -cr` scrubs anything still attached. + +set -euo pipefail + +REPO="ParkerM2/Agentic-Desktop-Command" +APP_NAME="ADC" +VERSION="${1:-latest}" + +log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +# ── Resolve arch ──────────────────────────────────────────────── +arch=$(uname -m) +case "$arch" in + arm64) asset_arch=arm64 ;; + x86_64) asset_arch=x64 ;; + *) fail "Unsupported architecture: $arch" ;; +esac + +# ── Resolve download URL ──────────────────────────────────────── +if [ "$VERSION" = "latest" ]; then + download_base="https://github.com/${REPO}/releases/latest/download" +else + download_base="https://github.com/${REPO}/releases/download/v${VERSION}" +fi + +dmg_name="ADC-${VERSION#v}-${asset_arch}.dmg" +if [ "$VERSION" = "latest" ]; then + # When using /latest/download, filename must match without version prefix: + # GitHub serves the latest release asset by exact filename, so we need the + # resolved version. Peek at the release JSON. + latest_ver=$(curl -sSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | awk -F'"' '/"tag_name":/ {print $4; exit}' \ + | sed 's/^v//') + [ -n "$latest_ver" ] || fail "Could not resolve latest version" + VERSION="$latest_ver" + dmg_name="ADC-${VERSION}-${asset_arch}.dmg" + download_base="https://github.com/${REPO}/releases/download/v${VERSION}" +fi + +dmg_url="${download_base}/${dmg_name}" +tmp_dmg="$(mktemp -t adc-install).dmg" +trap 'rm -f "$tmp_dmg"' EXIT + +log "Downloading ${dmg_name} (${asset_arch})..." +curl -fLSs -o "$tmp_dmg" "$dmg_url" || fail "Download failed: $dmg_url" + +log "Mounting DMG..." +mount_point=$(hdiutil attach "$tmp_dmg" -nobrowse -noautoopen -quiet \ + | awk '/Volumes\// { sub(/^[^\/]*/, ""); print; exit }') +[ -n "$mount_point" ] || fail "Could not determine DMG mount point" + +# Ensure we detach on any exit +cleanup() { + rm -f "$tmp_dmg" + if [ -n "${mount_point:-}" ] && [ -d "$mount_point" ]; then + hdiutil detach "$mount_point" -quiet >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +src_app="${mount_point}/${APP_NAME}.app" +[ -d "$src_app" ] || fail "Could not find ${APP_NAME}.app in mounted DMG" + +dest_app="/Applications/${APP_NAME}.app" + +if [ -d "$dest_app" ]; then + log "Removing existing installation at ${dest_app}..." + rm -rf "$dest_app" || fail "Could not remove existing app (try running with sudo?)" +fi + +log "Copying ${APP_NAME}.app to /Applications..." +cp -R "$src_app" "$dest_app" || fail "Copy failed — /Applications writable?" + +log "Removing quarantine attributes..." +xattr -cr "$dest_app" || true + +log "Installed ${APP_NAME} ${VERSION} (${asset_arch}). Launching..." +open "$dest_app" diff --git a/scripts/kill-dev.mjs b/scripts/kill-dev.mjs new file mode 100644 index 00000000..afeec936 --- /dev/null +++ b/scripts/kill-dev.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Kills every ADC dev process and its children — electron-vite wrappers, + * the Electron main + utility + renderer processes, and any spawned PTY + * shells rooted at them. + * + * Use when: + * - `Ctrl+C` on `npm run dev` left orphans (common on Windows). + * - The single-instance lock blocked a new dev from starting because + * a crashed prior instance never released the lock. + * - An agent spawned dev in a way you can't reach from your terminal. + * + * Safe: only kills processes whose command line references this repo path + * or electron-vite. Does not touch unrelated Electron/node processes. + * + * Run: + * node scripts/kill-dev.mjs + */ +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function run(cmd) { + try { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + } catch { + return ''; + } +} + +function findAdcRoots() { + const csv = run(`wmic process where "name='node.exe'" get ProcessId,CommandLine /format:csv`); + const pids = new Set(); + for (const line of csv.split(/\r?\n/)) { + if (!line.includes('electron-vite')) continue; + if (!line.toLowerCase().includes(repoRoot.toLowerCase().replace(/\\/g, '\\'))) continue; + const match = line.match(/,(\d+),\d+\s*$/); + if (match) pids.add(match[1]); + } + return [...pids]; +} + +function killTree(pid) { + run(`taskkill /F /T /PID ${pid}`); +} + +function killAllElectron() { + run(`taskkill /F /IM electron.exe /T`); +} + +const roots = findAdcRoots(); +if (roots.length === 0) { + console.log('No ADC electron-vite processes found.'); +} else { + console.log(`Killing ${roots.length} electron-vite root(s): ${roots.join(', ')}`); + for (const pid of roots) killTree(pid); +} + +console.log('Sweeping any remaining electron.exe processes...'); +killAllElectron(); + +console.log('Done.'); diff --git a/scripts/rebuild-sqlite-for-electron.mjs b/scripts/rebuild-sqlite-for-electron.mjs new file mode 100644 index 00000000..3b404c74 --- /dev/null +++ b/scripts/rebuild-sqlite-for-electron.mjs @@ -0,0 +1,18 @@ +/** + * Rebuild better-sqlite3 against the current Electron ABI (for running the app). + * + * Matches the repo's existing postinstall convention which invokes the + * `electron-rebuild` binary shipped by `@electron/rebuild`. + */ +import { execSync } from 'node:child_process'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const electronVersion = require('electron/package.json').version; + +console.log(`Rebuilding better-sqlite3 for Electron ${electronVersion}...`); +execSync( + `npx electron-rebuild -f -w better-sqlite3 -v ${electronVersion}`, + { stdio: 'inherit' }, +); +console.log('Done — better-sqlite3 now targets Electron.'); diff --git a/scripts/rebuild-sqlite-for-node.mjs b/scripts/rebuild-sqlite-for-node.mjs new file mode 100644 index 00000000..c69b66be --- /dev/null +++ b/scripts/rebuild-sqlite-for-node.mjs @@ -0,0 +1,10 @@ +/** + * Rebuild better-sqlite3 against the current Node ABI (for Vitest unit tests). + */ +import { execSync } from 'node:child_process'; + +console.log('Rebuilding better-sqlite3 for Node ABI...'); +execSync('npm rebuild better-sqlite3 --build-from-source', { + stdio: 'inherit', +}); +console.log('Done — better-sqlite3 now targets Node.'); diff --git a/scripts/reset-channel.mjs b/scripts/reset-channel.mjs new file mode 100644 index 00000000..ec989dd0 --- /dev/null +++ b/scripts/reset-channel.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Wipe a channel's userData directory so the next launch starts fresh. +// Usage: node scripts/reset-channel.mjs local +// node scripts/reset-channel.mjs dev +// node scripts/reset-channel.mjs release # DANGER: deletes real user data + +import { rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline/promises'; +import { stdin, stdout } from 'node:process'; + +const CHANNEL_DIR = { + release: 'ADC', + local: 'ADC-Local', + dev: 'ADC-Dev', +}; + +async function confirm(message) { + const rl = createInterface({ input: stdin, output: stdout }); + const answer = await rl.question(`${message} (yes/no) `); + rl.close(); + return answer.trim().toLowerCase() === 'yes'; +} + +async function main() { + const channel = process.argv[2]; + if (!channel || !(channel in CHANNEL_DIR)) { + console.error(`Usage: node scripts/reset-channel.mjs `); + process.exit(1); + } + + const appData = process.env.APPDATA; + if (!appData) { + console.error('APPDATA env var not set — this script is Windows-only.'); + process.exit(1); + } + + const target = join(appData, CHANNEL_DIR[channel]); + if (!existsSync(target)) { + console.log(`Nothing to do — ${target} does not exist.`); + return; + } + + if (channel === 'release') { + const ok = await confirm(`About to delete ${target} — this is your REAL installed-app data.`); + if (!ok) { + console.log('Aborted.'); + return; + } + } + + console.log(`Removing ${target} ...`); + await rm(target, { recursive: true, force: true }); + console.log('Done.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/main/bootstrap/event-wiring.ts b/src/main/bootstrap/event-wiring.ts index 057d951a..0da37a6b 100644 --- a/src/main/bootstrap/event-wiring.ts +++ b/src/main/bootstrap/event-wiring.ts @@ -2,22 +2,17 @@ * Event Wiring — forwards service events to the renderer via IPC. * * Handles: - * - Webhook relay → Hub WebSocket → assistant service * - Watch evaluator → proactive assistant notifications */ import { ASSISTANT_EVENTS } from '@shared/ipc/assistant/channels'; import type { createWatchEvaluator } from '../features/assistant/watch-evaluator'; -import type { createHubConnectionManager } from '../features/hub/hub-connection'; -import type { createWebhookRelay } from '../features/hub/webhook-relay'; import type { IpcRouter } from '../ipc/router'; interface EventWiringDeps { router: IpcRouter; watchEvaluator: ReturnType; - webhookRelay: ReturnType; - hubConnectionManager: ReturnType; } /** Wires all service events to IPC for renderer consumption. */ @@ -25,8 +20,6 @@ export function wireEventForwarding(deps: EventWiringDeps): void { const { router, watchEvaluator, - webhookRelay, - hubConnectionManager, } = deps; // ─── Watch evaluator → assistant response notifications ───── @@ -38,9 +31,4 @@ export function wireEventForwarding(deps: EventWiringDeps): void { }); }); watchEvaluator.start(); - - // ─── Webhook relay — Hub WebSocket → assistant service ─────── - hubConnectionManager.onWebSocketMessage((data) => { - webhookRelay.handleHubMessage(data); - }); } diff --git a/src/main/bootstrap/lifecycle.ts b/src/main/bootstrap/lifecycle.ts index d7cd3b22..5f6f8f73 100644 --- a/src/main/bootstrap/lifecycle.ts +++ b/src/main/bootstrap/lifecycle.ts @@ -19,22 +19,22 @@ import type { AppUpdateService } from '../features/app/app-update-service'; import type { ErrorCollector, HealthRegistry, HealthService } from '../features/app/health'; import type { createWatchEvaluator } from '../features/assistant/watch-evaluator'; import type { createBriefingService } from '../features/briefing/briefing-service'; -import type { createHubConnectionManager } from '../features/hub/hub-connection'; -import type { createNotificationManager } from '../features/integrations/notifications'; +import type { CleanupService } from '../features/data-management'; +import type { createNotificationManager } from '../features/notifications'; import type { QaTrigger } from '../features/qa/qa-trigger'; -import type { CleanupService } from '../features/settings/data-management'; -import type { createTerminalService } from '../features/terminal/terminal-service'; +import type { RunnersService } from '../features/runners/runners-service'; +import type { createTerminalService } from '../features/terminals/terminals-service'; import type { HotkeyManager } from '../tray/hotkey-manager'; export interface LifecycleDeps { createWindow: () => void; terminalService: ReturnType; + runnersService: RunnersService; errorCollector: ErrorCollector; healthRegistry: HealthRegistry; healthService: HealthService; qaTrigger: QaTrigger; alertService: ReturnType; - hubConnectionManager: ReturnType; notificationManager: ReturnType; briefingService: ReturnType; watchEvaluator: ReturnType; @@ -43,7 +43,7 @@ export interface LifecycleDeps { appUpdateService: AppUpdateService; commandBus: CommandBus; busSessionManager: BusSessionManager; - getHeartbeatIntervalId: () => ReturnType | null; + disposePeerTransport: () => Promise; } /** Registers Electron app lifecycle event handlers. */ @@ -76,16 +76,12 @@ export function setupLifecycle(deps: LifecycleDeps): void { deps.hotkeyManager.unregisterAll(); deps.qaTrigger.dispose(); deps.terminalService.dispose(); + deps.runnersService.dispose(); deps.alertService.stopChecking(); - deps.hubConnectionManager.dispose(); deps.notificationManager.dispose(); deps.briefingService.stopScheduler(); deps.watchEvaluator.stop(); - - const heartbeatId = deps.getHeartbeatIntervalId(); - if (heartbeatId !== null) { - clearInterval(heartbeatId); - } + void deps.disposePeerTransport(); // Dispose bus + sessions deps.busSessionManager.dispose(); diff --git a/src/main/bootstrap/service-registry.ts b/src/main/bootstrap/service-registry.ts index f289d2f7..b5f8c16e 100644 --- a/src/main/bootstrap/service-registry.ts +++ b/src/main/bootstrap/service-registry.ts @@ -9,12 +9,14 @@ * on first property access (first IPC call). */ -import { hostname } from 'node:os'; +import { existsSync } from 'node:fs'; import { app } from 'electron'; import { APP_EVENTS } from '@shared/ipc/app/channels'; import { WORKFLOW_ENGINE_EVENTS } from '@shared/ipc/workflow-engine/channels'; +import { computeSchemaHash } from '@shared/replication/schema-hash'; +import type { AppChannel } from '@shared/types/channel'; import { createOAuthManager } from '../auth/oauth-manager'; import { GITHUB_OAUTH_CONFIG } from '../auth/providers/github'; @@ -26,9 +28,10 @@ import { createTokenStore } from '../auth/token-store'; import { createCommandBus } from '../bus'; import { createBusSessionManager } from '../bus/session-manager'; import { initDatabase } from '../db'; +import { LEGACY_HUB_ID, LOCAL_HUB_ID, migrateLegacyDb, resolveActiveDbPath } from '../db/legacy-migrator'; +import { loadMigrationTags } from '../db/migration-tags'; import { createAlertService } from '../features/alerts/alert-service'; import { createAppUpdateService } from '../features/app/app-update-service'; -import { createDockerService } from '../features/app/docker'; import { createErrorCollector, createHealthRegistry, @@ -44,52 +47,54 @@ import { createSuggestionEngine } from '../features/briefing/suggestion-engine'; import { createChangelogService } from '../features/changelog/changelog-service'; import { createClaudeClient } from '../features/claude'; import { createDashboardService } from '../features/dashboard/dashboard-service'; -import { createFileTreeService } from '../features/file-tree/file-tree-service'; +import { + createCleanupService, + createConfigReader, + createDataMigrator, + createStorageInspector, + createUserDataMigrator, + createUserDataResolver, +} from '../features/data-management'; +import { createDockerService } from '../features/docker'; +import { createFileTreeService } from '../features/files/files-service'; import { createFitnessService } from '../features/fitness/fitness-service'; import { createGitService } from '../features/git/git-service'; import { createPolyrepoService } from '../features/git/polyrepo-service'; import { createWorktreeService } from '../features/git/worktree-service'; -import { createDeviceService } from '../features/hub/device'; -import { createHubApiClient } from '../features/hub/hub-api-client'; -import { createHubAuthService } from '../features/hub/hub-auth-service'; -import { createHubConnectionManager } from '../features/hub/hub-connection'; -import { createHubSyncService } from '../features/hub/hub-sync'; -import { createWebhookRelay } from '../features/hub/webhook-relay'; +import { createGitHubService } from '../features/github'; import { createIdeasService } from '../features/ideas/ideas-service'; import { createInsightsService } from '../features/insights/insights-service'; import { createIntegrationsService } from '../features/integrations/integrations-service'; import { createMergeService } from '../features/merge/merge-service'; -import { createMilestonesService } from '../features/milestones/milestones-service'; import { createNotesService } from '../features/notes/notes-service'; +import { loadPeerConfig } from '../features/peers/peer-config'; +import { getOrCreatePeerIdentity } from '../features/peers/peer-identity'; +import { createPeerStore } from '../features/peers/peer-store'; +import { createPeersService, type PeersService } from '../features/peers/peers-service'; +import { wrapAsyncPeersService } from '../features/peers/peers-service-async'; +import { createReplicationEngine } from '../features/peers/replication-engine'; import { createPlannerService } from '../features/planner/planner-service'; import { createProgressService } from '../features/progress'; -import { createClaudeMdGenerator } from '../features/project/claudemd-generator'; -import { createCodebaseAnalyzer } from '../features/project/codebase-analyzer'; -import { createDocGenerator } from '../features/project/doc-generator'; -import { createGitHubRepoCreator } from '../features/project/github-repo-creator'; -import { createProjectService } from '../features/project/project-service'; -import { createSetupPipeline } from '../features/project/setup-pipeline'; -import { createSkillsResolver } from '../features/project/skills-resolver'; +import { createClaudeMdGenerator } from '../features/projects/claudemd-generator'; +import { createCodebaseAnalyzer } from '../features/projects/codebase-analyzer'; +import { createDocGenerator } from '../features/projects/doc-generator'; +import { createGitHubRepoCreator } from '../features/projects/github-repo-creator'; +import { createProjectService } from '../features/projects/project-service'; +import { createSetupPipeline } from '../features/projects/setup-pipeline'; +import { createSkillsResolver } from '../features/projects/skills-resolver'; import { createQaRunner } from '../features/qa/qa-runner'; import { createQaTrigger } from '../features/qa/qa-trigger'; -import { createQaRecorderService } from '../features/qa/recorder'; -import { - createCleanupService, - createConfigReader, - createDataMigrator, - createStorageInspector, - createUserDataMigrator, - createUserDataResolver, -} from '../features/settings/data-management'; +import { createRunnersService } from '../features/runners'; import { createScreenCaptureService } from '../features/settings/screen'; import { createSettingsService } from '../features/settings/settings-service'; import { createVoiceService } from '../features/settings/voice'; -import { createTerminalService } from '../features/terminal/terminal-service'; -import { createTrackerService } from '../features/tracker/tracker-service'; +import { createSpotifyService } from '../features/spotify'; +import { createTerminalService } from '../features/terminals/terminals-service'; +import { createTestSuiteService } from '../features/test-suite'; import { createVisualizationService } from '../features/visualization'; import { createWorkflowService } from '../features/workflow/workflow-service'; import { createWorkspaceSessionManager } from '../features/workspace/workspace-session-manager'; -import { createWorkspacesService } from '../features/workspaces/workspaces-service'; +import { createWorkspacesService } from '../features/workspace/workspaces-service'; import { IpcRouter } from '../ipc/router'; import { lazyService } from '../lib/lazy-service'; import { appLogger } from '../lib/logger'; @@ -108,8 +113,7 @@ import type { CommandBus } from '../bus'; import type { BusSessionManager } from '../bus/session-manager'; import type { AdcDatabase } from '../db'; import type { UserSessionManager } from '../features/auth'; -import type { HubApiClient } from '../features/hub/hub-api-client'; -import type { NotificationManager } from '../features/integrations/notifications'; +import type { NotificationManager } from '../features/notifications'; import type { WorkspaceSessionManager } from '../features/workspace/workspace-session-manager'; import type { Services } from '../ipc'; import type { SessionJSONLReaderService } from '../services/session-jsonl/session-jsonl-reader'; @@ -130,8 +134,6 @@ export interface ServiceRegistryResult { healthService: ReturnType; qaTrigger: ReturnType; watchEvaluator: ReturnType; - webhookRelay: ReturnType; - hubConnectionManager: ReturnType; terminalService: ReturnType; alertService: ReturnType; notificationManager: NotificationManager; @@ -142,11 +144,9 @@ export interface ServiceRegistryResult { cleanupService: ReturnType; teamWatcherService: TeamWatcherService; sessionJsonlReaderService: SessionJSONLReaderService; - hubApiClient: HubApiClient; workspacesService: ReturnType; - heartbeatIntervalId: ReturnType | null; - registeredDeviceId: string | null; userSessionManager: UserSessionManager; + disposePeerTransport: () => Promise; } /** @@ -159,6 +159,7 @@ export interface ServiceRegistryResult { export function createServiceRegistry( getMainWindow: () => Electron.BrowserWindow | null, agentHostClient: AgentHostClient, + channel: AppChannel, ): ServiceRegistryResult { // ─── Tier 0: Eager — Router, DB, Auth, Settings, Error/Health, Project ─── @@ -174,7 +175,23 @@ export function createServiceRegistry( } const dataDir = configReader.resolveDataDir(); - const db = initDatabase(dataDir); + + // Move any pre-existing top-level adc.db into hubs/legacy/adc.db before + // the DB opens. Task 16's config-store migrator writes hubId='legacy' + // into the v2 blob, so once the DB is open at the new location the + // records line up. + // + // Boot-time hub selection: if a 'legacy' per-hub DB exists (either from + // this boot's move or a prior boot), open there; otherwise fall back to + // 'local' for fresh installs. activeHubId from the v2 config is only + // readable once the DB is open, so a full multi-hub active-hub lookup + // lives in Task 23. + // TODO: Task 23 switch active hub — read activeHubId from hub-config + // and reopen the DB at the selected hub's path on switch. + migrateLegacyDb(dataDir); + const legacyDbPath = resolveActiveDbPath(dataDir, LEGACY_HUB_ID); + const bootHubId = existsSync(legacyDbPath) ? LEGACY_HUB_ID : LOCAL_HUB_ID; + const db = initDatabase(dataDir, { activeHubId: bootHubId }); const errorCollector = createErrorCollector(dataDir, { onError: (entry) => { router.emit(APP_EVENTS.ERROR.OCCURRED, entry); }, @@ -190,8 +207,8 @@ export function createServiceRegistry( const healthService = createHealthService(healthRegistry); const settingsService = createSettingsService({ db, dataDir }); - const userSessionManager = createUserSessionManager(router); - const projectService = createProjectService({ hubApiClient: lazyService(() => hubApiClient) }); + const userSessionManager = createUserSessionManager(); + const projectService = createProjectService({ db }); const commandBus = createCommandBus(db); const busSessionManager = createBusSessionManager(db, agentHostClient); @@ -216,91 +233,6 @@ export function createServiceRegistry( const mcpRegistry = lazyService(() => createMcpRegistry()); const mcpManager = lazyService(() => createMcpManager({ registry: mcpRegistry })); - // ─── Tier 1: Hub domain ─────────────────────────────────────── - - const hubConnectionManager = lazyService(() => { - const mgr = createHubConnectionManager({ router, db, dataDir }); - const savedHubConfig = mgr.getConnection(); - if (savedHubConfig?.enabled) { - appLogger.info('[Hub] Auto-connecting to saved Hub:', savedHubConfig.hubUrl); - void (async () => { - const result = await mgr.connect(); - if (result.success) { - appLogger.info('[Hub] Auto-connect succeeded'); - } else { - appLogger.warn('[Hub] Auto-connect failed:', result.error); - } - })(); - } - mgr.onWebSocketMessage(() => { - healthRegistry.pulse('hubWebSocket'); - if (registeredDeviceId === null && hubAuthService.isAuthenticated()) { - void registerDeviceAndStartHeartbeat(hubApiClient); - } - }); - return mgr; - }); - - const hubAuthService = lazyService(() => - createHubAuthService({ - tokenStore, - getHubUrl: () => hubConnectionManager.getConnection()?.hubUrl ?? null, - }), - ); - - const hubApiClient = lazyService(() => - createHubApiClient( - () => hubConnectionManager.getConnection()?.hubUrl ?? null, - () => hubAuthService.getAccessToken(), - ), - ); - - const hubSyncService = lazyService(() => createHubSyncService(hubConnectionManager, db)); - - // ─── Device + heartbeat (stateful, kept at module scope) ───── - - let heartbeatIntervalId: ReturnType | null = null; - let registeredDeviceId: string | null = null; - const HEARTBEAT_INTERVAL_MS = 30_000; - - async function registerDeviceAndStartHeartbeat(client: HubApiClient): Promise { - const machineId = hostname(); - const deviceName = `${hostname()} (Desktop)`; - try { - const result = await client.registerDevice({ - machineId, - deviceType: 'desktop', - deviceName, - capabilities: { canExecute: true, repos: [] }, - appVersion: app.getVersion(), - }); - if (result.ok && result.data) { - registeredDeviceId = result.data.id; - appLogger.info(`[Hub] Device registered: ${result.data.id}`); - if (heartbeatIntervalId !== null) clearInterval(heartbeatIntervalId); - heartbeatIntervalId = setInterval(() => { - if (registeredDeviceId) { - healthRegistry.pulse('hubHeartbeat'); - void client.heartbeat(registeredDeviceId).then((res) => { - if (!res.ok) appLogger.warn('[Hub] Heartbeat failed:', res.error); - return res; - }); - } - }, HEARTBEAT_INTERVAL_MS); - appLogger.info('[Hub] Heartbeat interval started (30s)'); - } else { - appLogger.warn('[Hub] Device registration failed:', result.error); - } - } catch (error) { - appLogger.error('[Hub] Device registration error:', error instanceof Error ? error.message : 'Unknown error'); - } - } - - const deviceService = lazyService(() => createDeviceService({ hubApiClient })); - - healthRegistry.register('hubHeartbeat', 60_000); - healthRegistry.register('hubWebSocket', 30_000); - // ─── Tier 1: Workspace / Git domain ────────────────────────── const polyrepoService = lazyService(() => createPolyrepoService()); @@ -338,7 +270,6 @@ export function createServiceRegistry( const notesService = lazyService(() => createNotesService({ db, dataDir, router })); const dashboardService = lazyService(() => createDashboardService({ db, dataDir, router })); const plannerService = lazyService(() => createPlannerService({ db, dataDir, router })); - const milestonesService = lazyService(() => createMilestonesService({ db, dataDir, router })); const ideasService = lazyService(() => createIdeasService({ db, dataDir, router })); const changelogService = lazyService(() => createChangelogService({ db, router, dataDir })); const fitnessService = lazyService(() => createFitnessService({ db, dataDir, router })); @@ -349,14 +280,14 @@ export function createServiceRegistry( // ─── Tier 1: External integrations (unified) ───────────────── const integrationsService = lazyService(() => - createIntegrationsService({ db, dataDir, router, oauthManager, githubCliClient }), + createIntegrationsService({ db, dataDir, router, oauthManager }), ); // Convenience accessors — expose sub-services from the unified service const emailService = lazyService(() => integrationsService.email); const notificationManager = lazyService(() => integrationsService.notifications); - const spotifyService = lazyService(() => integrationsService.spotify); - const githubService = lazyService(() => integrationsService.github); + const spotifyService = lazyService(() => createSpotifyService({ oauthManager })); + const githubService = lazyService(() => createGitHubService({ client: githubCliClient, router })); const calendarService = lazyService(() => integrationsService.calendar); const claudeClient = lazyService(() => @@ -409,7 +340,6 @@ export function createServiceRegistry( const toolExecutor = lazyService(() => createToolExecutor({ notesService, - milestonesService, ideasService, plannerService, projectService, @@ -431,19 +361,29 @@ export function createServiceRegistry( }), ); - const webhookRelay = lazyService(() => createWebhookRelay({ assistantService, router })); - // ─── Tier 1: QA ────────────────────────────────────────────── const qaRunner = lazyService(() => createQaRunner(busSessionManager, dataDir, notificationManager)); - const qaRecorderService = lazyService(() => createQaRecorderService(db)); + const runnersService = createRunnersService({ + db, + router, + projectService: { + getProjectPath: (id: string) => projectService.getProjectPath(id), + }, + }); + const testSuiteService = lazyService(() => + createTestSuiteService(db, { + getMainWindow, + getProjectPath: (id) => projectService.getProjectPath(id), + }), + ); const qaTrigger = lazyService(() => - createQaTrigger({ qaRunner, busSessionManager, progressService, router, qaRecorderService }), + createQaTrigger({ qaRunner, busSessionManager, progressService, router, testSuiteService }), ); // ─── Tier 1: App update + hotkeys ──────────────────────────── - const appUpdateService = lazyService(() => createAppUpdateService(router)); + const appUpdateService = lazyService(() => createAppUpdateService({ router, channel })); const quickInput = lazyService(() => createQuickInputWindow({ @@ -491,6 +431,7 @@ export function createServiceRegistry( gitService, dataDir, progressBaseDir: dataDir, + replicationEngine, onStateChanged: (event: Parameters>[1]) => { router.emit(WORKFLOW_ENGINE_EVENTS.STATE.CHANGED, event); }, onCompleted: (event: Parameters>[1]) => { router.emit(WORKFLOW_ENGINE_EVENTS.RUN.COMPLETED, event); }, onError: (event: Parameters>[1]) => { router.emit(WORKFLOW_ENGINE_EVENTS.RUN.ERROR, event); }, @@ -505,9 +446,94 @@ export function createServiceRegistry( const teamWatcherService = lazyService(() => createTeamWatcherService()); const sessionJsonlReaderService = lazyService(() => createSessionJSONLReaderService()); const fileTreeService = lazyService(() => createFileTreeService()); - const trackerService = lazyService(() => createTrackerService(process.cwd())); const visualizationService = lazyService(() => createVisualizationService(agentHostClient)); - const progressService = lazyService(() => createProgressService(process.cwd(), agentHostClient, db)); + // ─── Peer replication (Phase 3b) ───────────────────────────── + // + // PeersService owns peer-identity, TLS, peer-server (TLS https hosting both + // /pair/* and wss://), mDNS advertise + browse, and the trust store. The + // renderer drives pairing via IPC; ADC_PEER_REMOTE remains a dev override. + const peerConfig = loadPeerConfig(); + + // Audit 04/L3: resolve peer identity once at boot rather than relying on + // dev-default fallbacks (`'aaaaaaaa'` / `'peer-a'`) inside the engine deps. + // peers-service reuses the same identity instead of resolving its own. The + // env-var override in peer-config wins when set; otherwise we derive from + // the on-disk Ed25519 keypair (creating it on first boot). + const peerIdentity = getOrCreatePeerIdentity(dataDir, { + allowPlaintext: process.env.ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY === '1', + }); + const peerIdFull = peerConfig.peerIdFull || peerIdentity.peerIdFull; + const peerIdShort = peerConfig.peerIdShort || peerIdentity.peerIdShort; + if (peerIdFull === '' || peerIdShort === '') { + throw new Error('peers: identity not resolved'); + } + const peerStore = createPeerStore(db); + + const replicationEngine = createReplicationEngine({ + db, + peerIdShort, + peerIdFull, + peerStore, + }); + + // PeersService bootstrap is async (TLS material, peer-server.listen, mDNS). + // Wrap the in-flight promise in a Proxy so handlers transparently `await` + // it — eliminates the IIFE race where IPC calls landing during the boot + // window hit a "peers-service not yet initialized" throw, and ensures + // disposePeerTransport awaits the same promise so half-constructed + // resources (TLS listener, mDNS) cannot leak. See audit 04 C1+C2. + // NOTE: peerConfig.listenPort > 0 gating is preserved — when peers is + // disabled we hand back a permanently-pending promise that the wrapper + // never resolves, so handlers will hang rather than silently no-op. + // TODO(audit-04 C1): replace the never-resolving disabled branch with + // a proper "peers disabled" stub once the contract supports it. + const peersServicePromise: Promise = + peerConfig.listenPort > 0 + ? (async () => { + const schemaHash = computeSchemaHash(loadMigrationTags()); + return await createPeersService({ + db, + dataDir, + engine: replicationEngine, + listenPort: peerConfig.listenPort, + schemaHash, + preferMdns: peerConfig.preferMdns, + displayName: peerConfig.displayName ?? null, + // Audit 04/L3 + M6: reuse identity + peer store from the + // registry rather than letting peers-service construct duplicates. + identity: peerIdentity, + peerStore, + }); + })() + : new Promise(() => { + // intentionally never resolves: peers feature disabled by config + }); + + // Guard against unhandled rejection if bootstrap fails. The wrapper + // re-uses the same promise and will surface the rejection on the first + // method call, so logging here is purely informational. + peersServicePromise.catch((err: unknown) => { + appLogger.error(`[Bootstrap] PeersService failed to start: ${String(err)}`); + }); + + let peersDisposed = false; + const peersServiceLazy: PeersService = wrapAsyncPeersService(peersServicePromise); + + const disposePeerTransport = async (): Promise => { + if (peersDisposed) return; + peersDisposed = true; + if (peerConfig.listenPort <= 0) return; + try { + const svc = await peersServicePromise; + await svc.dispose(); + } catch (err) { + appLogger.warn(`[Bootstrap] PeersService dispose during bootstrap-error: ${String(err)}`); + } + }; + + const progressService = lazyService(() => + createProgressService(process.cwd(), agentHostClient, db, replicationEngine), + ); // ─── User session change handling ──────────────────────────── @@ -534,7 +560,6 @@ export function createServiceRegistry( terminalService, settingsService, claudeClient, - deviceService, alertService, assistantService, calendarService, @@ -544,16 +569,14 @@ export function createServiceRegistry( fileTreeService, fitnessService, healthRegistry, - hubConnectionManager, - hubSyncService, ideasService, insightsService, mcpManager, - milestonesService, notesService, dashboardService, dockerService, notificationManager, + peersService: peersServiceLazy, plannerService, spotifyService, gitService, @@ -565,17 +588,15 @@ export function createServiceRegistry( briefingService, hotkeyManager, appUpdateService, - hubApiClient, - hubAuthService, qaRunner, - qaRecorderService, + runnersService, + testSuiteService, workflowTemplateService, cleanupService, storageInspector, oauthManager, codebaseAnalyzer, setupPipeline, - trackerService, visualizationService, userSessionManager, workspaceSessionManager, @@ -602,8 +623,6 @@ export function createServiceRegistry( healthService, qaTrigger, watchEvaluator, - webhookRelay, - hubConnectionManager, terminalService, alertService, notificationManager, @@ -611,12 +630,10 @@ export function createServiceRegistry( hotkeyManager, quickInput, settingsService, - hubApiClient, cleanupService, teamWatcherService, sessionJsonlReaderService, - heartbeatIntervalId, - registeredDeviceId, userSessionManager, + disposePeerTransport, }; } diff --git a/src/main/bus/session-manager.ts b/src/main/bus/session-manager.ts index 3edcaa46..0f16c6dd 100644 --- a/src/main/bus/session-manager.ts +++ b/src/main/bus/session-manager.ts @@ -87,12 +87,14 @@ export function createBusSessionManager( prompt: config.prompt, model: config.model, teamName: config.teamName ?? config.name, + projectId: config.projectId ?? undefined, }) : await agentManager.spawnProjectOwner({ projectPath: config.projectPath ?? process.cwd(), prompt: config.prompt, name: config.name, model: config.model, + projectId: config.projectId ?? undefined, }); // SpawnTeamLeadResult is AgentSession | SpawnTeamLeadError diff --git a/src/main/db/connection.ts b/src/main/db/connection.ts index 6d04f434..8e0c8854 100644 --- a/src/main/db/connection.ts +++ b/src/main/db/connection.ts @@ -1,4 +1,5 @@ -import { join } from 'node:path'; +import { mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { app } from 'electron'; @@ -6,6 +7,7 @@ import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; +import { resolveActiveDbPath } from './legacy-migrator'; import * as schema from './schema'; let db: ReturnType> | null = null; @@ -13,13 +15,29 @@ let sqlite: Database.Database | null = null; export interface InitDatabaseOptions { migrationsFolder?: string; + /** + * When provided, the DB opens at `userData/hubs//adc.db` + * (or `hubs/local/adc.db` when explicitly `null`). When undefined, the + * legacy top-level `userData/adc.db` path is used — retained so + * existing tests that don't care about per-hub layout keep working. + * + * TODO: Task 23 switch active hub — when the hub-switch IPC lands, the + * DB singleton will need to close and reopen at the new resolved path. + */ + activeHubId?: string | null; } export function initDatabase( userDataPath: string, options?: InitDatabaseOptions, ): ReturnType> { - const dbPath = join(userDataPath, 'adc.db'); + const dbPath = options && 'activeHubId' in options + ? resolveActiveDbPath(userDataPath, options.activeHubId ?? null) + : join(userDataPath, 'adc.db'); + + // Ensure the parent directory exists (per-hub path contains nested dirs). + mkdirSync(dirname(dbPath), { recursive: true }); + sqlite = new Database(dbPath); sqlite.pragma('journal_mode = WAL'); diff --git a/src/main/db/legacy-migrator.ts b/src/main/db/legacy-migrator.ts new file mode 100644 index 00000000..9a96ac83 --- /dev/null +++ b/src/main/db/legacy-migrator.ts @@ -0,0 +1,74 @@ +/** + * Hub DB path migrator. + * + * Resolves the on-disk `adc.db` location for a given hub and performs the + * one-time move of a legacy top-level `adc.db` into `hubs/legacy/adc.db`. + * + * Context: Task 16 introduced `HubConfigV2` with `hubs[]` + `activeHubId`. + * Each `PersistedHubRecord` has a `dbPath` like `hubs//adc.db`. + * This module provides the path resolution + the legacy-file relocation + * so that the main-process DB opens from the correct per-hub location. + * + * The actual swap of the DB singleton on hub switch lands in Task 23. + */ + +import { existsSync, mkdirSync, renameSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { hubLogger } from '../lib/logger'; + +/** Synthetic hub id used when no hub has been paired yet (local-only mode). */ +export const LOCAL_HUB_ID = 'local'; + +/** Hub id used for data migrated from the legacy pre-hubs[] layout. */ +export const LEGACY_HUB_ID = 'legacy'; + +/** + * Resolves the absolute path to the `adc.db` file for the given hub. + * + * - `resolveActiveDbPath(userData, 'abc')` → `userData/hubs/abc/adc.db` + * - `resolveActiveDbPath(userData, null)` → `userData/hubs/local/adc.db` + * + * @param userDataDir Absolute path to Electron `userData`. + * @param activeHubId The active hub id, or `null` for local-only mode. + */ +export function resolveActiveDbPath( + userDataDir: string, + activeHubId: string | null, +): string { + const hubId = activeHubId ?? LOCAL_HUB_ID; + return join(userDataDir, 'hubs', hubId, 'adc.db'); +} + +export interface LegacyMigrationResult { + moved: boolean; + from?: string; + to?: string; +} + +/** + * Moves a pre-existing top-level `adc.db` into `hubs/legacy/adc.db` + * exactly once. Never overwrites an existing destination. Idempotent + * after the first run because the source no longer exists. + */ +export function migrateLegacyDb(userDataDir: string): LegacyMigrationResult { + const from = join(userDataDir, 'adc.db'); + const to = join(userDataDir, 'hubs', LEGACY_HUB_ID, 'adc.db'); + + if (!existsSync(from)) { + return { moved: false }; + } + + if (existsSync(to)) { + hubLogger.warn( + `[Hub] Legacy adc.db move skipped — destination already exists: ${to}`, + ); + return { moved: false }; + } + + mkdirSync(dirname(to), { recursive: true }); + renameSync(from, to); + + hubLogger.info(`[Hub] Moved legacy adc.db → ${to}`); + return { moved: true, from, to }; +} diff --git a/src/main/db/migration-tags.ts b/src/main/db/migration-tags.ts new file mode 100644 index 00000000..dfc16ac3 --- /dev/null +++ b/src/main/db/migration-tags.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +interface JournalEntry { + idx: number; + tag: string; +} + +interface Journal { + entries: JournalEntry[]; +} + +/** Loads the Drizzle migration tags from `drizzle/meta/_journal.json`, sorted by idx. */ +export function loadMigrationTags(): string[] { + // Look in a few spots: dev (process.cwd()), and packaged (__dirname-relative). + const candidates = [ + resolve(process.cwd(), 'drizzle/meta/_journal.json'), + resolve(__dirname, '../../../drizzle/meta/_journal.json'), + ]; + + for (const path of candidates) { + try { + const raw = readFileSync(path, 'utf8'); + const journal = JSON.parse(raw) as Journal; + return [...journal.entries].sort((a, b) => a.idx - b.idx).map((e) => e.tag); + } catch { + continue; + } + } + + throw new Error('drizzle/meta/_journal.json not found in any known location'); +} diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 1e57bba3..a4a404a7 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -7,19 +7,22 @@ export * from '../features/dashboard/schema'; export * from '../features/notes/schema'; export * from '../features/alerts/schema'; export * from '../features/ideas/schema'; -export * from '../features/milestones/schema'; export * from '../features/planner/schema'; export * from '../features/fitness/schema'; export * from '../features/briefing/schema'; -export * from '../features/integrations/notifications/schema'; +export * from '../features/notifications/schema'; export * from '../features/progress/schema'; export * from '../features/workflow/engine/workflow-agents-schema'; export * from '../features/workflow/engine/schema'; export * from '../features/auth/schema'; -export * from '../features/integrations/email/schema'; +export * from '../features/email/schema'; export * from '../features/changelog/schema'; export * from '../features/progress/session-logs-schema'; export * from '../bus/schema'; export * from '../features/assistant/schema'; -export * from '../features/qa/recorder/schema'; -export * from '../features/workspaces/schema'; +export * from '../features/test-suite/schema'; +export * from '../features/workspace/workspaces-schema'; +export * from '../features/runners/schema'; +export * from '../features/peers/schema'; +export * from '../features/workflow/workflow-runs-summary-schema'; +export * from '../features/projects/schema'; diff --git a/src/main/features/alerts/alert-handlers.ts b/src/main/features/alerts/alert-handlers.ts index 02829291..47ea2d02 100644 --- a/src/main/features/alerts/alert-handlers.ts +++ b/src/main/features/alerts/alert-handlers.ts @@ -2,7 +2,7 @@ * Alert IPC handlers */ -import { ALERTS } from '@shared/ipc/misc/alerts.channels'; +import { ALERTS } from '@shared/ipc/alerts'; import type { AlertService } from "./alert-service"; import type { IpcRouter } from '../../ipc/router'; diff --git a/src/main/features/alerts/alert-service.ts b/src/main/features/alerts/alert-service.ts index 60d9fd8d..95561c27 100644 --- a/src/main/features/alerts/alert-service.ts +++ b/src/main/features/alerts/alert-service.ts @@ -12,7 +12,7 @@ import { join } from 'node:path'; import { eq } from 'drizzle-orm'; -import { ALERTS_EVENTS } from '@shared/ipc/misc/alerts.channels'; +import { ALERTS_EVENTS } from '@shared/ipc/alerts'; import { generateId } from '@shared/lib/id'; import type { Alert, AlertLinkedTo, RecurringConfig } from '@shared/types'; diff --git a/src/main/features/app/app-update-service.ts b/src/main/features/app/app-update-service.ts index f7b9a8b8..7f014017 100644 --- a/src/main/features/app/app-update-service.ts +++ b/src/main/features/app/app-update-service.ts @@ -5,12 +5,20 @@ * return no-op/stub values so the app continues to function. */ +import { app, shell } from 'electron'; + import { APP_EVENTS } from '@shared/ipc/app/channels'; +import type { AppChannel } from '@shared/types/channel'; import { appLogger } from '@main/lib/logger'; import type { IpcRouter } from '../../ipc/router'; +const GITHUB_RELEASES_API = + 'https://api.github.com/repos/ParkerM2/Agentic-Desktop-Command/releases/latest'; +const GITHUB_RELEASES_PAGE = + 'https://github.com/ParkerM2/Agentic-Desktop-Command/releases/latest'; + // ── Types ──────────────────────────────────────────────────── interface UpdateStatus { @@ -42,6 +50,48 @@ export interface AppUpdateService { // ── Helpers ────────────────────────────────────────────────── +function isNewerSemver(remote: string, current: string): boolean { + const parse = (v: string): number[] => + v + .replace(/^v/, '') + .split('.') + .map((p) => { + const n = Number.parseInt(p, 10); + return Number.isNaN(n) ? 0 : n; + }); + const r = parse(remote); + const c = parse(current); + for (let i = 0; i < Math.max(r.length, c.length); i++) { + const ri = r[i] ?? 0; + const ci = c[i] ?? 0; + if (ri !== ci) return ri > ci; + } + return false; +} + +interface GitHubReleaseInfo { + version: string; + htmlUrl: string; +} + +async function fetchLatestGitHubRelease(): Promise { + try { + const res = await fetch(GITHUB_RELEASES_API, { + headers: { Accept: 'application/vnd.github+json' }, + }); + if (!res.ok) { + appLogger.warn(`[AppUpdateService] GitHub releases API responded ${res.status}`); + return null; + } + const data = (await res.json()) as { tag_name?: string; html_url?: string }; + if (!data.tag_name || !data.html_url) return null; + return { version: data.tag_name.replace(/^v/, ''), htmlUrl: data.html_url }; + } catch (err) { + appLogger.warn('[AppUpdateService] GitHub releases fetch failed:', err); + return null; + } +} + function loadAutoUpdater(): AutoUpdaterLike | null { try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -58,7 +108,14 @@ function loadAutoUpdater(): AutoUpdaterLike | null { // ── Factory ────────────────────────────────────────────────── -export function createAppUpdateService(router: IpcRouter): AppUpdateService { +export interface AppUpdateServiceDeps { + router: IpcRouter; + channel: AppChannel; +} + +export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService { + const { router, channel } = deps; + const status: UpdateStatus = { checking: false, updateAvailable: false, @@ -66,6 +123,59 @@ export function createAppUpdateService(router: IpcRouter): AppUpdateService { downloaded: false, }; + // Non-release channels are local binaries — self-update would + // overwrite them with the real release build. Always no-op. + if (channel !== 'release') { + appLogger.info(`[AppUpdateService] Auto-updater disabled on channel=${channel}`); + return { + checkForUpdates: () => ({ updateAvailable: false }), + downloadUpdate: () => ({ success: false }), + quitAndInstall: () => ({ success: false }), + getStatus: () => ({ ...status }), + }; + } + + // macOS unsigned builds cannot use Squirrel.Mac (signature required). + // Fall back to manual flow: poll GitHub Releases API and open the + // download page in the browser. User drags the new .app to /Applications. + if (process.platform === 'darwin') { + appLogger.info('[AppUpdateService] macOS manual-update mode — unsigned build'); + let downloadUrl: string = GITHUB_RELEASES_PAGE; + + async function runCheck(): Promise { + status.checking = true; + status.error = undefined; + const latest = await fetchLatestGitHubRelease(); + status.checking = false; + if (!latest) return; + const current = app.getVersion(); + if (!isNewerSemver(latest.version, current)) { + appLogger.info( + `[AppUpdateService] No update (current=${current}, latest=${latest.version})`, + ); + return; + } + status.updateAvailable = true; + status.version = latest.version; + downloadUrl = latest.htmlUrl; + appLogger.info(`[AppUpdateService] Manual update available: ${latest.version}`); + router.emit(APP_EVENTS.UPDATE.AVAILABLE, { version: latest.version }); + } + + return { + checkForUpdates() { + void runCheck(); + return { updateAvailable: status.updateAvailable, version: status.version }; + }, + downloadUpdate() { + void shell.openExternal(downloadUrl); + return { success: true }; + }, + quitAndInstall: () => ({ success: false }), + getStatus: () => ({ ...status }), + }; + } + const updater = loadAutoUpdater(); // Wire autoUpdater events if available diff --git a/src/main/features/app/docker.ts b/src/main/features/app/docker.ts index 11d62288..8cbe400f 100644 --- a/src/main/features/app/docker.ts +++ b/src/main/features/app/docker.ts @@ -1,8 +1,8 @@ /** - * App — Docker sub-module barrel + * App — Docker sub-module barrel (re-exports from top-level feature) */ -export { createDockerService } from './docker/docker-service'; -export { registerDockerHandlers } from './docker/docker-handlers'; +export { createDockerService } from '../docker/docker-service'; +export { registerDockerHandlers } from '../docker/docker-handlers'; -export type { DockerService } from './docker/docker-service'; +export type { DockerService } from '../docker/docker-service'; diff --git a/src/main/features/assistant/cross-device-query.ts b/src/main/features/assistant/cross-device-query.ts index 6391ce39..659f4035 100644 --- a/src/main/features/assistant/cross-device-query.ts +++ b/src/main/features/assistant/cross-device-query.ts @@ -1,17 +1,27 @@ /** - * Cross-Device Query — Query other ADC instances via Hub API + * Cross-Device Query — Query other ADC instances via local op_log + peer-store * - * Handles natural language queries about device status and running tasks - * on other machines registered with the Hub. + * Handles natural language queries about device status and tasks + * originated by other paired peers. Reads entirely from the local + * SQLite replica — no Hub round-trips. */ -import type { HubApiClient } from '../hub/hub-api-client'; +import { and, eq, inArray } from 'drizzle-orm'; + +import type { AdcDatabase } from '@main/db'; +import type { PairedPeer } from '@main/features/peers/peer-store'; +import { opLog } from '@main/features/peers/schema'; +import { progressTasks } from '@main/features/progress/schema'; + +/** Narrow read-only view of PeerStore used by cross-device-query. */ +export interface PeerReader { + listActive: () => PairedPeer[]; +} interface DeviceInfo { id: string; deviceName: string; nickname?: string; - isOnline: boolean; lastSeen?: string; } @@ -25,13 +35,13 @@ const SLEEPING_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes const OFFLINE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes function getDeviceState(device: DeviceInfo): 'online' | 'sleeping' | 'offline' | 'unreachable' { - if (!device.lastSeen) { + if (device.lastSeen === undefined) { return 'unreachable'; } const elapsed = Date.now() - new Date(device.lastSeen).getTime(); - if (device.isOnline && elapsed < SLEEPING_THRESHOLD_MS) { + if (elapsed < SLEEPING_THRESHOLD_MS) { return 'online'; } if (elapsed < OFFLINE_THRESHOLD_MS) { @@ -83,26 +93,73 @@ function formatDeviceEntry(device: DeviceInfo, tasks: DeviceTaskInfo[]): string return line; } +function peerToDevice(peer: PairedPeer): DeviceInfo { + return { + id: peer.peerId, + deviceName: peer.displayName ?? peer.peerId, + nickname: peer.displayName ?? undefined, + lastSeen: + peer.lastConnectedAt === null ? undefined : new Date(peer.lastConnectedAt).toISOString(), + }; +} + +export interface CrossDeviceQueryDeps { + db: AdcDatabase; + /** Audit 04/M6: peer-reader is injected so the registry-owned PeerStore + * is reused instead of constructing a duplicate here. */ + peerStore: PeerReader; +} + export interface CrossDeviceQuery { query: (deviceNameFilter: string) => Promise; } -export function createCrossDeviceQuery(hubApiClient: HubApiClient): CrossDeviceQuery { +export function createCrossDeviceQuery(deps: CrossDeviceQueryDeps): CrossDeviceQuery { + const { db, peerStore } = deps; + + function tasksForPeer(peerId: string): DeviceTaskInfo[] { + const pkRows = db + .selectDistinct({ pk: opLog.pk }) + .from(opLog) + .where(and(eq(opLog.tableName, 'progress_tasks'), eq(opLog.originPeerId, peerId))) + .all(); + + if (pkRows.length === 0) { + return []; + } + + const slugs = pkRows.map((r) => r.pk); + const taskRows = db + .select({ + slug: progressTasks.slug, + id: progressTasks.id, + title: progressTasks.title, + status: progressTasks.status, + archivedAt: progressTasks.archivedAt, + }) + .from(progressTasks) + .where(inArray(progressTasks.slug, slugs)) + .all(); + + return taskRows + .filter((t) => t.archivedAt === null) + .map((t) => ({ + id: t.id ?? t.slug, + title: t.title, + status: t.status, + })); + } + return { - async query(deviceNameFilter) { - const devicesResult = await hubApiClient.hubGet('/devices'); + query(deviceNameFilter) { + const peers = peerStore.listActive(); - if (!devicesResult.ok || !devicesResult.data) { - return 'Unable to fetch device information from Hub.'; + if (peers.length === 0) { + return Promise.resolve('No devices paired.'); } - const devices = devicesResult.data; - - if (devices.length === 0) { - return 'No devices registered with Hub.'; - } + const devices = peers.map(peerToDevice); - // Filter to specific device if name provided const filtered = deviceNameFilter.length > 0 ? devices.filter( @@ -113,35 +170,20 @@ export function createCrossDeviceQuery(hubApiClient: HubApiClient): CrossDeviceQ : devices; if (filtered.length === 0) { - return `No device found matching "${deviceNameFilter}".`; + return Promise.resolve(`No device found matching "${deviceNameFilter}".`); } - // Fetch tasks for each device - const entries = await Promise.all( - filtered.map(async (device) => { - const tasks = await (async (): Promise => { - if (getDeviceState(device) !== 'online') { - return []; - } - const tasksResult = await hubApiClient.hubGet<{ tasks: DeviceTaskInfo[] }>( - `/tasks?assignedDeviceId=${device.id}`, - ); - if (tasksResult.ok && tasksResult.data) { - const { tasks: deviceTasks } = tasksResult.data; - return deviceTasks; - } - return []; - })(); - return formatDeviceEntry(device, tasks); - }), - ); + const entries = filtered.map((device) => { + const tasks = getDeviceState(device) === 'online' ? tasksForPeer(device.id) : []; + return formatDeviceEntry(device, tasks); + }); const header = deviceNameFilter.length > 0 ? `Device status for "${deviceNameFilter}":` : `All devices (${String(devices.length)}):`; - return `${header}\n${entries.join('\n')}`; + return Promise.resolve(`${header}\n${entries.join('\n')}`); }, }; } diff --git a/src/main/features/assistant/history-store.ts b/src/main/features/assistant/history-store.ts index 783adeac..203e8cf9 100644 --- a/src/main/features/assistant/history-store.ts +++ b/src/main/features/assistant/history-store.ts @@ -11,7 +11,7 @@ import { asc, desc, sql } from 'drizzle-orm'; import type { CommandHistoryEntry } from '@shared/types'; import type { AdcDatabase } from '@main/db'; -import type { ReinitializableService } from '@main/features/settings/data-management'; +import type { ReinitializableService } from '@main/features/data-management'; import { serviceLogger } from '@main/lib/logger'; import { commandHistory } from './schema'; diff --git a/src/main/features/assistant/tool-definitions.ts b/src/main/features/assistant/tool-definitions.ts index a17e5fbf..ffbec7db 100644 --- a/src/main/features/assistant/tool-definitions.ts +++ b/src/main/features/assistant/tool-definitions.ts @@ -43,30 +43,6 @@ export const APP_TOOLS: AppTool[] = [ queryKeyRoots: ['notes'], }, - // ── Milestones ─────────────────────────────────────────────────────────── - { - name: 'create_milestone', - description: - 'Create a new roadmap milestone. Use when the user asks to add a milestone, plan a feature release, or create a project phase.', - input_schema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Milestone title, e.g. "v1.0 Release"' }, - description: { - type: 'string', - description: 'What this milestone represents and its success criteria', - }, - targetDate: { - type: 'string', - description: 'ISO date string for when this should be achieved, e.g. "2026-06-01"', - }, - projectId: { type: 'string', description: 'Optional project ID to associate with' }, - }, - required: ['title', 'description', 'targetDate'], - }, - queryKeyRoots: ['milestones'], - }, - // ── Ideas ──────────────────────────────────────────────────────────────── { name: 'create_idea', @@ -177,13 +153,13 @@ export const APP_TOOLS: AppTool[] = [ { name: 'query_recent_items', description: - 'Query notes, milestones, or ideas created since a given date. Use when the user asks what was created, added, or done recently.', + 'Query notes or ideas created since a given date. Use when the user asks what was created, added, or done recently.', input_schema: { type: 'object', properties: { type: { type: 'string', - enum: ['notes', 'milestones', 'ideas'], + enum: ['notes', 'ideas'], description: 'Which data type to query', }, since: { @@ -408,7 +384,7 @@ ${toolList} - Always use in-app tools instead of CLI commands when available. - When the user mentions a project by name, resolve it to the project ID from the list above. - Respond concisely. Use markdown formatting for readability. -- When creating items (notes, tasks, milestones), confirm what you created. +- When creating items (notes, tasks, ideas), confirm what you created. - When the user says things like "send this to a team-lead", "have the team-lead execute this plan", "hand this off", "delegate this", or refers to sending work to an agent — use the hand_off_plan or execute_task tools. These route work to an idle team-lead agent (or spawn a new one). - Await user messages.`; } diff --git a/src/main/features/assistant/tool-executor.ts b/src/main/features/assistant/tool-executor.ts index bc967a5a..1f759387 100644 --- a/src/main/features/assistant/tool-executor.ts +++ b/src/main/features/assistant/tool-executor.ts @@ -22,16 +22,14 @@ import { import type { BriefingService } from '../briefing/briefing-service'; import type { ChangelogService } from '../changelog/changelog-service'; import type { IdeasService } from '../ideas/ideas-service'; -import type { MilestonesService } from '../milestones/milestones-service'; import type { NotesService } from '../notes/notes-service'; import type { PlannerService } from '../planner/planner-service'; import type { ProgressService } from '../progress/progress-service'; -import type { ProjectService } from '../project/project-service'; +import type { ProjectService } from '../projects/project-service'; import type { GitToolDeps } from './tool-handlers/git-tools'; import type { WorkspaceSessionManager } from '../workspace/workspace-session-manager'; const QUERY_KEY_NOTES = 'notes'; -const QUERY_KEY_MILESTONES = 'milestones'; const QUERY_KEY_IDEAS = 'ideas'; const QUERY_KEY_PLANNER = 'planner'; const QUERY_KEY_WORKSPACE = 'workspace'; @@ -39,7 +37,6 @@ const ERR_PROGRESS_UNAVAILABLE = 'Progress service unavailable'; export interface ToolExecutorDeps { notesService: NotesService | null; - milestonesService: MilestonesService | null; ideasService: IdeasService | null; plannerService: PlannerService | null; projectService: Pick | null; @@ -90,7 +87,6 @@ function executeListProjects(projectService: ToolExecutorDeps['projectService']) function executeQueryRecentItems( input: ToolInput, notesService: NotesService | null, - milestonesService: MilestonesService | null, ideasService: IdeasService | null, ): ToolResult { const type = getString(input, 'type'); @@ -104,13 +100,6 @@ function executeQueryRecentItems( const notes = notesService.listNotes({}).filter((n) => new Date(n.createdAt) >= since); return { success: true, data: notes, queryKeyRoots: [] }; } - if (type === 'milestones') { - if (!milestonesService) return fail('Milestones service unavailable'); - const items = milestonesService - .listMilestones({}) - .filter((m) => new Date(m.createdAt) >= since); - return { success: true, data: items, queryKeyRoots: [] }; - } if (type === 'ideas') { if (!ideasService) return fail('Ideas service unavailable'); const items = ideasService.listIdeas({}).filter((i) => new Date(i.createdAt) >= since); @@ -154,7 +143,7 @@ function executeReadProgressFile(input: ToolInput): ToolResult { } export function createToolExecutor(deps: ToolExecutorDeps) { - const { notesService, milestonesService, ideasService, plannerService, projectService, progressService, briefingService, changelogService, gitToolDeps, workspaceSessionManager, sendEvent } = deps; + const { notesService, ideasService, plannerService, projectService, progressService, briefingService, changelogService, gitToolDeps, workspaceSessionManager, sendEvent } = deps; function emitExecuted(toolName: string, result: ToolResult): void { sendEvent(ASSISTANT_EVENTS.TOOL.EXECUTED, { @@ -175,17 +164,6 @@ export function createToolExecutor(deps: ToolExecutorDeps) { return ok(note, QUERY_KEY_NOTES); } - function executeCreateMilestone(input: ToolInput): ToolResult { - if (!milestonesService) return fail('Milestones service unavailable'); - const milestone = milestonesService.createMilestone({ - title: getString(input, 'title'), - description: getString(input, 'description'), - targetDate: getString(input, 'targetDate', new Date().toISOString()), - projectId: typeof input.projectId === 'string' ? input.projectId : undefined, - }); - return ok(milestone, QUERY_KEY_MILESTONES); - } - function executeCreateIdea(input: ToolInput): ToolResult { if (!ideasService) return fail('Ideas service unavailable'); const validCategories = ['feature', 'improvement', 'bug', 'performance'] as const; @@ -289,11 +267,10 @@ export function createToolExecutor(deps: ToolExecutorDeps) { // Map uses string keys to avoid camelCase naming convention lint const handlerMap = new Map([ ['create_note', executeCreateNote], - ['create_milestone', executeCreateMilestone], ['create_idea', executeCreateIdea], ['add_daily_goal', executeAddDailyGoal], ['list_projects', () => executeListProjects(projectService)], - ['query_recent_items', (input) => executeQueryRecentItems(input, notesService, milestonesService, ideasService)], + ['query_recent_items', (input) => executeQueryRecentItems(input, notesService, ideasService)], ['list_progress_features', () => executeListProgressFeatures()], ['read_progress_file', (input) => executeReadProgressFile(input)], ['generate_briefing', () => executeGenerateBriefing()], diff --git a/src/main/features/assistant/tool-handlers/git-tools.ts b/src/main/features/assistant/tool-handlers/git-tools.ts index aa6b1e03..fa3d9780 100644 --- a/src/main/features/assistant/tool-handlers/git-tools.ts +++ b/src/main/features/assistant/tool-handlers/git-tools.ts @@ -7,8 +7,8 @@ */ import type { GitService } from '../../git/git-service'; -import type { GitHubService } from '../../integrations/github-integration'; -import type { ProjectService } from '../../project/project-service'; +import type { GitHubService } from '../../github'; +import type { ProjectService } from '../../projects/project-service'; import type { ToolResult } from '../tool-executor'; type ToolInput = Record; diff --git a/src/main/features/assistant/tool-handlers/project-tools.ts b/src/main/features/assistant/tool-handlers/project-tools.ts index fed10960..a24c1f79 100644 --- a/src/main/features/assistant/tool-handlers/project-tools.ts +++ b/src/main/features/assistant/tool-handlers/project-tools.ts @@ -4,7 +4,7 @@ * Handles: projects_list (alias for list_projects) */ -import type { ProjectService } from '../../project/project-service'; +import type { ProjectService } from '../../projects/project-service'; import type { ToolResult } from '../tool-executor'; type ToolInput = Record; diff --git a/src/main/features/auth/auth-handlers.ts b/src/main/features/auth/auth-handlers.ts deleted file mode 100644 index 4b71566d..00000000 --- a/src/main/features/auth/auth-handlers.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Auth IPC handlers — Hub API integration - * - * Handles user authentication via the Hub server. - * Delegates HTTP calls to HubAuthService and transforms responses - * to match the IPC contract schemas (UserSchema, AuthTokensSchema). - */ - -import { AUTH } from '@shared/ipc/auth/channels'; - -import type { UserSessionManager } from '@main/features/auth'; -import type { HubAuthService } from '@main/features/hub/hub-auth-service'; - -import type { IpcRouter } from '../../ipc/router'; - -export interface AuthHandlerDependencies { - hubAuthService: HubAuthService; - userSessionManager: UserSessionManager; -} - -/** - * Convert an ISO `expiresAt` timestamp to a relative `expiresIn` value (seconds). - * Returns 0 if the token is already expired. - */ -function expiresAtToExpiresIn(expiresAt: string): number { - const expiresMs = new Date(expiresAt).getTime(); - const nowMs = Date.now(); - return Math.max(0, Math.round((expiresMs - nowMs) / 1000)); -} - -export function registerAuthHandlers(router: IpcRouter, deps: AuthHandlerDependencies): void { - const { hubAuthService, userSessionManager } = deps; - - router.handle(AUTH.LOGIN.USER, async ({ email, password }) => { - const result = await hubAuthService.login({ email, password }); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? 'Login failed'); - } - - const { user, accessToken, refreshToken, expiresAt } = result.data; - - // Set user session for user-scoped storage - userSessionManager.setSession({ - userId: user.id, - email: user.email, - }); - - return { - user: { - id: user.id, - email: user.email, - displayName: user.displayName, - avatarUrl: user.avatarUrl ?? null, - createdAt: user.createdAt, - lastLoginAt: user.lastLoginAt ?? null, - }, - tokens: { - accessToken, - refreshToken, - expiresIn: expiresAtToExpiresIn(expiresAt), - }, - }; - }); - - router.handle(AUTH.REGISTER.USER, async ({ email, password, displayName }) => { - const result = await hubAuthService.register({ email, password, displayName }); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? 'Registration failed'); - } - - const { user, accessToken, refreshToken, expiresAt } = result.data; - return { - user: { - id: user.id, - email: user.email, - displayName: user.displayName, - avatarUrl: user.avatarUrl ?? null, - createdAt: user.createdAt, - lastLoginAt: user.lastLoginAt ?? null, - }, - tokens: { - accessToken, - refreshToken, - expiresIn: expiresAtToExpiresIn(expiresAt), - }, - }; - }); - - router.handle(AUTH.GET.USER, async () => { - const result = await hubAuthService.getCurrentUser(); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? 'Failed to get current user'); - } - - const user = result.data; - return { - id: user.id, - email: user.email, - displayName: user.displayName, - avatarUrl: user.avatarUrl ?? null, - createdAt: user.createdAt, - lastLoginAt: user.lastLoginAt ?? null, - }; - }); - - router.handle(AUTH.LOGOUT.USER, async () => { - await hubAuthService.logout(); - - // Clear user session for user-scoped storage - userSessionManager.clearSession(); - - return { success: true }; - }); - - // Main process is the authoritative token owner — it stores tokens in the - // secure TokenStore and uses its own stored refresh token for rotation. - // The renderer's refreshToken input is ignored; the rotated token from - // the Hub response is returned so the renderer can update its local copy. - router.handle(AUTH.REFRESH.TOKEN, async (_input) => { - const result = await hubAuthService.refreshToken(); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? 'Token refresh failed'); - } - - return { - tokens: { - accessToken: result.data.accessToken, - refreshToken: result.data.refreshToken, - expiresIn: expiresAtToExpiresIn(result.data.expiresAt), - }, - }; - }); - - // Restore a previously authenticated session from encrypted token storage. - // Called on app startup to silently re-authenticate without user interaction. - router.handle(AUTH.RESTORE.SESSION, async () => { - const result = await hubAuthService.restoreSession(); - - if (!result.restored) { - return { restored: false as const }; - } - - // Set user session for user-scoped storage on successful restore - userSessionManager.setSession({ - userId: result.user.id, - email: result.user.email, - }); - - return { - restored: true as const, - user: { - id: result.user.id, - email: result.user.email, - displayName: result.user.displayName, - avatarUrl: result.user.avatarUrl ?? null, - createdAt: result.user.createdAt, - lastLoginAt: result.user.lastLoginAt ?? null, - }, - tokens: { - accessToken: result.accessToken, - refreshToken: result.refreshToken, - expiresIn: expiresAtToExpiresIn(result.expiresAt), - }, - }; - }); -} diff --git a/src/main/features/auth/user-session-manager.ts b/src/main/features/auth/user-session-manager.ts index b72f9383..47d636d9 100644 --- a/src/main/features/auth/user-session-manager.ts +++ b/src/main/features/auth/user-session-manager.ts @@ -1,14 +1,11 @@ /** * User Session Manager * - * Tracks the currently logged-in user and emits events on login/logout. - * Services subscribe to these events to reinitialize with user-scoped paths. + * Tracks the currently logged-in user (local-only — no Hub coupling). + * Services subscribe to in-process callbacks to reinitialize with + * user-scoped paths when the session changes. */ -import { AUTH_EVENTS } from '@shared/ipc/auth/channels'; - -import type { IpcRouter } from '@main/ipc/router'; - export interface UserSession { userId: string; email: string; @@ -25,7 +22,7 @@ export interface UserSessionManager { onSessionChange: (callback: (session: UserSession | null) => void) => () => void; } -export function createUserSessionManager(router: IpcRouter): UserSessionManager { +export function createUserSessionManager(): UserSessionManager { let currentSession: UserSession | null = null; const listeners = new Set<(session: UserSession | null) => void>(); @@ -42,13 +39,11 @@ export function createUserSessionManager(router: IpcRouter): UserSessionManager setSession(session) { currentSession = session; - router.emit(AUTH_EVENTS.SESSION.CHANGED, { userId: session.userId, email: session.email }); notifyListeners(); }, clearSession() { currentSession = null; - router.emit(AUTH_EVENTS.SESSION.CHANGED, { userId: null, email: null }); notifyListeners(); }, diff --git a/src/main/features/briefing/briefing-generator.ts b/src/main/features/briefing/briefing-generator.ts index 7834f0ab..c9eea4f3 100644 --- a/src/main/features/briefing/briefing-generator.ts +++ b/src/main/features/briefing/briefing-generator.ts @@ -12,7 +12,7 @@ import { createBriefingSummarizer } from './briefing-summary'; import type { SuggestionEngine } from './suggestion-engine'; import type { BusSessionManager } from '../../bus/session-manager'; import type { ClaudeClient } from '../claude/claude-client'; -import type { NotificationManager } from '../integrations/notifications'; +import type { NotificationManager } from '../notifications'; import type { ProgressService } from '../progress/progress-service'; /** Dependencies for the briefing generator */ diff --git a/src/main/features/briefing/briefing-service.ts b/src/main/features/briefing/briefing-service.ts index d123870e..ac183d66 100644 --- a/src/main/features/briefing/briefing-service.ts +++ b/src/main/features/briefing/briefing-service.ts @@ -11,7 +11,7 @@ import { BRIEFING_EVENTS } from '@shared/ipc/briefing/channels'; import type { BriefingConfig, DailyBriefing, Suggestion } from '@shared/types'; -import type { ReinitializableService } from '@main/features/settings/data-management'; +import type { ReinitializableService } from '@main/features/data-management'; import { createBriefingCache } from './briefing-cache'; import { createBriefingConfigManager } from './briefing-config'; @@ -22,9 +22,9 @@ import type { BusSessionManager } from '../../bus/session-manager'; import type { AdcDatabase } from '../../db'; import type { IpcRouter } from '../../ipc/router'; import type { ClaudeClient } from '../claude/claude-client'; -import type { NotificationManager } from '../integrations/notifications'; +import type { NotificationManager } from '../notifications'; import type { ProgressService } from '../progress/progress-service'; -import type { ProjectService } from '../project/project-service'; +import type { ProjectService } from '../projects/project-service'; const BRIEFING_READY_EVENT = BRIEFING_EVENTS.BRIEFING.READY; diff --git a/src/main/features/briefing/suggestion-engine.ts b/src/main/features/briefing/suggestion-engine.ts index a42ef215..1cc0d796 100644 --- a/src/main/features/briefing/suggestion-engine.ts +++ b/src/main/features/briefing/suggestion-engine.ts @@ -11,7 +11,7 @@ import type { Suggestion, SuggestionType } from '@shared/types'; import type { BusSessionManager } from '../../bus/session-manager'; import type { ProgressService } from '../progress/progress-service'; -import type { ProjectService } from '../project/project-service'; +import type { ProjectService } from '../projects/project-service'; const STALE_PROJECT_DAYS = 7; diff --git a/src/main/features/changelog/changelog-handlers.ts b/src/main/features/changelog/changelog-handlers.ts index d07e8d73..d385566e 100644 --- a/src/main/features/changelog/changelog-handlers.ts +++ b/src/main/features/changelog/changelog-handlers.ts @@ -2,7 +2,7 @@ * Changelog IPC handlers */ -import { CHANGELOG } from '@shared/ipc/misc/changelog.channels'; +import { CHANGELOG } from '@shared/ipc/changelog'; import type { ChangelogService } from "./changelog-service"; import type { IpcRouter } from '../../ipc/router'; diff --git a/src/main/features/settings/data-management/cleanup-service.ts b/src/main/features/data-management/cleanup-service.ts similarity index 98% rename from src/main/features/settings/data-management/cleanup-service.ts rename to src/main/features/data-management/cleanup-service.ts index 3e80bcd8..90e2de8f 100644 --- a/src/main/features/settings/data-management/cleanup-service.ts +++ b/src/main/features/data-management/cleanup-service.ts @@ -16,7 +16,7 @@ import { STORE_CLEANUP_FUNCTIONS } from './store-cleaners'; import { DATA_STORE_REGISTRY } from './store-registry'; import type { CleanupFn } from './store-cleaners'; -import type { IpcRouter } from '../../../ipc/router'; +import type { IpcRouter } from '../../ipc/router'; // ─── Types ──────────────────────────────────────────────────── diff --git a/src/main/features/settings/data-management/config-reader.ts b/src/main/features/data-management/config-reader.ts similarity index 100% rename from src/main/features/settings/data-management/config-reader.ts rename to src/main/features/data-management/config-reader.ts diff --git a/src/main/features/settings/data-management/data-dir-handlers.ts b/src/main/features/data-management/data-dir-handlers.ts similarity index 97% rename from src/main/features/settings/data-management/data-dir-handlers.ts rename to src/main/features/data-management/data-dir-handlers.ts index d42c53e2..58389d5f 100644 --- a/src/main/features/settings/data-management/data-dir-handlers.ts +++ b/src/main/features/data-management/data-dir-handlers.ts @@ -9,7 +9,7 @@ import { SETTINGS } from '@shared/ipc/settings/channels'; import type { ConfigReader } from './config-reader'; import type { DataMigrator } from './data-migrator'; -import type { IpcRouter } from '../../../ipc/router'; +import type { IpcRouter } from '../../ipc/router'; export function registerDataDirHandlers( router: IpcRouter, diff --git a/src/main/features/settings/data-management/data-export.ts b/src/main/features/data-management/data-export.ts similarity index 100% rename from src/main/features/settings/data-management/data-export.ts rename to src/main/features/data-management/data-export.ts diff --git a/src/main/features/settings/data-management/data-management-handlers.ts b/src/main/features/data-management/data-management-handlers.ts similarity index 95% rename from src/main/features/settings/data-management/data-management-handlers.ts rename to src/main/features/data-management/data-management-handlers.ts index 254a9471..9a7af683 100644 --- a/src/main/features/settings/data-management/data-management-handlers.ts +++ b/src/main/features/data-management/data-management-handlers.ts @@ -15,8 +15,8 @@ import { DATA_STORE_REGISTRY } from "./store-registry"; import type { CleanupService } from "./cleanup-service"; import type { StorageInspector } from "./storage-inspector"; -import type { IpcRouter } from '../../../ipc/router'; -import type { SettingsService } from "../settings-service"; +import type { IpcRouter } from '../../ipc/router'; +import type { SettingsService } from '../settings/settings-service'; const DEFAULT_RETENTION_SETTINGS: DataRetentionSettings = { autoCleanupEnabled: true, diff --git a/src/main/features/settings/data-management/data-migrator.ts b/src/main/features/data-management/data-migrator.ts similarity index 100% rename from src/main/features/settings/data-management/data-migrator.ts rename to src/main/features/data-management/data-migrator.ts diff --git a/src/main/features/data-management/index.ts b/src/main/features/data-management/index.ts new file mode 100644 index 00000000..e5bad0b4 --- /dev/null +++ b/src/main/features/data-management/index.ts @@ -0,0 +1,14 @@ +export { createCleanupService } from './cleanup-service'; +export type { CleanupService } from './cleanup-service'; +export { createStorageInspector } from './storage-inspector'; +export type { StorageInspector } from './storage-inspector'; +export { exportData, importData } from './data-export'; +export { DATA_STORE_REGISTRY } from './store-registry'; +export { STORE_CLEANUP_FUNCTIONS } from './store-cleaners'; +export { createUserDataResolver, type UserDataResolver } from './user-data-resolver'; +export { type ReinitializableService, isReinitializable } from './reinitializable-service'; +export { createUserDataMigrator, type UserDataMigrator } from './user-data-migrator'; +export { createConfigReader, type ConfigReader } from './config-reader'; +export { createDataMigrator, type DataMigrator } from './data-migrator'; +export { registerDataDirHandlers } from './data-dir-handlers'; +export { registerDataManagementHandlers } from './data-management-handlers'; diff --git a/src/main/features/settings/data-management/reinitializable-service.ts b/src/main/features/data-management/reinitializable-service.ts similarity index 100% rename from src/main/features/settings/data-management/reinitializable-service.ts rename to src/main/features/data-management/reinitializable-service.ts diff --git a/src/main/features/settings/data-management/storage-inspector.ts b/src/main/features/data-management/storage-inspector.ts similarity index 100% rename from src/main/features/settings/data-management/storage-inspector.ts rename to src/main/features/data-management/storage-inspector.ts diff --git a/src/main/features/settings/data-management/store-cleaners.ts b/src/main/features/data-management/store-cleaners.ts similarity index 99% rename from src/main/features/settings/data-management/store-cleaners.ts rename to src/main/features/data-management/store-cleaners.ts index 6becf104..69e592a0 100644 --- a/src/main/features/settings/data-management/store-cleaners.ts +++ b/src/main/features/data-management/store-cleaners.ts @@ -13,7 +13,7 @@ import { join } from 'node:path'; import type { RetentionPolicy } from '@shared/types'; -import { safeWriteJson } from '../../../lib/safe-write-json'; +import { safeWriteJson } from '../../lib/safe-write-json'; // ── Types ──────────────────────────────────────────────────────── diff --git a/src/main/features/settings/data-management/store-registry.ts b/src/main/features/data-management/store-registry.ts similarity index 96% rename from src/main/features/settings/data-management/store-registry.ts rename to src/main/features/data-management/store-registry.ts index cc7aeb43..d4d6f3d5 100644 --- a/src/main/features/settings/data-management/store-registry.ts +++ b/src/main/features/data-management/store-registry.ts @@ -179,20 +179,6 @@ export const DATA_STORE_REGISTRY: DataStoreEntry[] = [ canExport: true, sensitive: false, }, - { - id: 'milestones', - label: 'Milestones', - description: 'Project milestones and completion tracking', - filePath: 'milestones.json', - isDirectory: false, - lifecycle: PERSISTENT, - encrypted: false, - hubSynced: false, - defaultRetention: { enabled: false }, - canClear: true, - canExport: true, - sensitive: false, - }, { id: 'changelog', label: 'Changelog', diff --git a/src/main/features/settings/data-management/user-data-migrator.ts b/src/main/features/data-management/user-data-migrator.ts similarity index 98% rename from src/main/features/settings/data-management/user-data-migrator.ts rename to src/main/features/data-management/user-data-migrator.ts index 83bfdc95..dc1023dc 100644 --- a/src/main/features/settings/data-management/user-data-migrator.ts +++ b/src/main/features/data-management/user-data-migrator.ts @@ -17,7 +17,6 @@ const USER_SCOPED_FILES = [ 'assistant-watches.json', 'alerts.json', 'ideas.json', - 'milestones.json', 'changelog.json', ]; diff --git a/src/main/features/settings/data-management/user-data-resolver.ts b/src/main/features/data-management/user-data-resolver.ts similarity index 100% rename from src/main/features/settings/data-management/user-data-resolver.ts rename to src/main/features/data-management/user-data-resolver.ts diff --git a/src/main/features/app/docker/docker-handlers.ts b/src/main/features/docker/docker-handlers.ts similarity index 79% rename from src/main/features/app/docker/docker-handlers.ts rename to src/main/features/docker/docker-handlers.ts index 50c4dee7..c8114427 100644 --- a/src/main/features/app/docker/docker-handlers.ts +++ b/src/main/features/docker/docker-handlers.ts @@ -7,9 +7,10 @@ import { DOCKER } from '@shared/ipc/docker/channels'; import type { DockerService } from "./docker-service"; -import type { IpcRouter } from '../../../ipc/router'; +import type { IpcRouter } from '../../ipc/router'; export function registerDockerHandlers(router: IpcRouter, dockerService: DockerService): void { router.handle(DOCKER.GET.STATUS, () => dockerService.getStatus()); router.handle(DOCKER.SETUP.HUB, () => dockerService.setupHub()); + router.handle(DOCKER.RESET.HUB, () => dockerService.resetHub()); } diff --git a/src/main/features/app/docker/docker-service.ts b/src/main/features/docker/docker-service.ts similarity index 78% rename from src/main/features/app/docker/docker-service.ts rename to src/main/features/docker/docker-service.ts index d857cf32..7b61fa71 100644 --- a/src/main/features/app/docker/docker-service.ts +++ b/src/main/features/docker/docker-service.ts @@ -16,14 +16,17 @@ const execFileAsync = promisify(execFile); const HUB_IMAGE = 'parkerm2/adc-hub:latest'; const HUB_CONTAINER_NAME = 'adc-hub'; +const HUB_VOLUME_NAME = 'adc-hub-data'; const HUB_PORT = 3200; const HUB_URL = `http://localhost:${String(HUB_PORT)}`; const HEALTH_TIMEOUT_MS = 30_000; const HEALTH_POLL_MS = 1_000; +const STEP_DOCKER_CHECK = 'docker-check'; export interface DockerService { getStatus: () => Promise<{ installed: boolean; running: boolean }>; setupHub: () => Promise<{ success: boolean; url?: string; apiKey?: string; error?: string; step?: string }>; + resetHub: () => Promise<{ success: boolean; url?: string; apiKey?: string; error?: string; step?: string }>; } export function createDockerService(): DockerService { @@ -98,10 +101,10 @@ export function createDockerService(): DockerService { // Step 1: Ensure Docker is running const status = await getStatus(); if (!status.installed) { - return { success: false, error: 'Docker is not installed', step: 'docker-check' }; + return { success: false, error: 'Docker is not installed', step: STEP_DOCKER_CHECK }; } if (!status.running) { - return { success: false, error: 'Docker Desktop is not running. Please start it and try again.', step: 'docker-check' }; + return { success: false, error: 'Docker Desktop is not running. Please start it and try again.', step: STEP_DOCKER_CHECK }; } // Step 2: Check if container already exists @@ -135,11 +138,12 @@ export function createDockerService(): DockerService { // Step 3: Pull the image await docker('pull', HUB_IMAGE); - // Step 4: Run the container + // Step 4: Run the container with a persistent volume so data survives resets await docker( 'run', '-d', '--name', HUB_CONTAINER_NAME, '-p', `${String(HUB_PORT)}:${String(HUB_PORT)}`, + '-v', `${HUB_VOLUME_NAME}:/app/data`, '--restart', 'unless-stopped', HUB_IMAGE, ); @@ -161,5 +165,33 @@ export function createDockerService(): DockerService { } } - return { getStatus, setupHub }; + async function resetHub(): Promise<{ + success: boolean; + url?: string; + apiKey?: string; + error?: string; + step?: string; + }> { + try { + const status = await getStatus(); + if (!status.installed) { + return { success: false, error: 'Docker is not installed', step: STEP_DOCKER_CHECK }; + } + if (!status.running) { + return { success: false, error: 'Docker Desktop is not running. Please start it and try again.', step: STEP_DOCKER_CHECK }; + } + + // Tear down existing container and its volume so the fresh Hub mints a new key. + try { await docker('stop', HUB_CONTAINER_NAME); } catch { /* already stopped or absent */ } + try { await docker('rm', HUB_CONTAINER_NAME); } catch { /* already removed */ } + try { await docker('volume', 'rm', HUB_VOLUME_NAME); } catch { /* may not exist yet */ } + + return await setupHub(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message, step: 'reset' }; + } + } + + return { getStatus, setupHub, resetHub }; } diff --git a/src/main/features/docker/index.ts b/src/main/features/docker/index.ts new file mode 100644 index 00000000..bc5e8796 --- /dev/null +++ b/src/main/features/docker/index.ts @@ -0,0 +1,4 @@ +export { createDockerService } from './docker-service'; +export { registerDockerHandlers } from './docker-handlers'; + +export type { DockerService } from './docker-service'; diff --git a/src/main/features/integrations/email/email-config.ts b/src/main/features/email/email-config.ts similarity index 100% rename from src/main/features/integrations/email/email-config.ts rename to src/main/features/email/email-config.ts diff --git a/src/main/features/integrations/email/email-encryption.ts b/src/main/features/email/email-encryption.ts similarity index 100% rename from src/main/features/integrations/email/email-encryption.ts rename to src/main/features/email/email-encryption.ts diff --git a/src/main/features/integrations/email/email-handlers.ts b/src/main/features/email/email-handlers.ts similarity index 90% rename from src/main/features/integrations/email/email-handlers.ts rename to src/main/features/email/email-handlers.ts index 3d8a2bad..be4fdf60 100644 --- a/src/main/features/integrations/email/email-handlers.ts +++ b/src/main/features/email/email-handlers.ts @@ -4,10 +4,10 @@ import { EMAIL } from '@shared/ipc/email/channels'; -import { createThrottle } from '../../../ipc/throttle'; +import { createThrottle } from '../../ipc/throttle'; import type { EmailService } from './email-service'; -import type { IpcRouter } from '../../../ipc/router'; +import type { IpcRouter } from '../../ipc/router'; export function registerEmailHandlers(router: IpcRouter, service: EmailService): void { const allowSend = createThrottle(2000); diff --git a/src/main/features/integrations/email/email-queue.ts b/src/main/features/email/email-queue.ts similarity index 96% rename from src/main/features/integrations/email/email-queue.ts rename to src/main/features/email/email-queue.ts index 548340e4..80a1d468 100644 --- a/src/main/features/integrations/email/email-queue.ts +++ b/src/main/features/email/email-queue.ts @@ -13,14 +13,14 @@ import { eq } from 'drizzle-orm'; import type { Email, EmailSendResult, QueuedEmail } from '@shared/types'; -import { emailQueue as emailQueueTable } from '../../../db/schema'; -import { createScopedLogger } from '../../../lib/logger'; +import { emailQueue as emailQueueTable } from '../../db/schema'; +import { createScopedLogger } from '../../lib/logger'; import { sendEmailViaSmtp } from './smtp-transport'; import type { StoredEmailConfig } from './email-store'; -import type { AdcDatabase } from '../../../db'; -import type { IpcRouter } from '../../../ipc/router'; +import type { AdcDatabase } from '../../db'; +import type { IpcRouter } from '../../ipc/router'; const logger = createScopedLogger('email-queue'); diff --git a/src/main/features/integrations/email/email-service.ts b/src/main/features/email/email-service.ts similarity index 100% rename from src/main/features/integrations/email/email-service.ts rename to src/main/features/email/email-service.ts diff --git a/src/main/features/integrations/email/email-store.ts b/src/main/features/email/email-store.ts similarity index 96% rename from src/main/features/integrations/email/email-store.ts rename to src/main/features/email/email-store.ts index c109423d..af0a4055 100644 --- a/src/main/features/integrations/email/email-store.ts +++ b/src/main/features/email/email-store.ts @@ -12,13 +12,13 @@ import { and, eq } from 'drizzle-orm'; import { generateId } from '@shared/lib/id'; -import { settingsKv } from '../../../db/schema'; -import { createScopedLogger } from '../../../lib/logger'; +import { settingsKv } from '../../db/schema'; +import { createScopedLogger } from '../../lib/logger'; import { encryptSecret } from './email-encryption'; import type { EncryptedSecretEntry } from './email-encryption'; -import type { AdcDatabase } from '../../../db'; +import type { AdcDatabase } from '../../db'; const logger = createScopedLogger('email-store'); diff --git a/src/main/features/integrations/email/index.ts b/src/main/features/email/index.ts similarity index 100% rename from src/main/features/integrations/email/index.ts rename to src/main/features/email/index.ts diff --git a/src/main/features/integrations/email/schema.ts b/src/main/features/email/schema.ts similarity index 100% rename from src/main/features/integrations/email/schema.ts rename to src/main/features/email/schema.ts diff --git a/src/main/features/integrations/email/smtp-transport.ts b/src/main/features/email/smtp-transport.ts similarity index 100% rename from src/main/features/integrations/email/smtp-transport.ts rename to src/main/features/email/smtp-transport.ts diff --git a/src/main/features/file-tree/files-handlers.ts b/src/main/features/files/files-handlers.ts similarity index 86% rename from src/main/features/file-tree/files-handlers.ts rename to src/main/features/files/files-handlers.ts index d2761779..224150af 100644 --- a/src/main/features/file-tree/files-handlers.ts +++ b/src/main/features/files/files-handlers.ts @@ -4,7 +4,7 @@ import { FILES } from '@shared/ipc/files/channels'; -import type { FileTreeService } from "./file-tree-service"; +import type { FileTreeService } from "./files-service"; import type { IpcRouter } from '../../ipc/router'; export function registerFilesHandlers( diff --git a/src/main/features/file-tree/file-tree-service.ts b/src/main/features/files/files-service.ts similarity index 100% rename from src/main/features/file-tree/file-tree-service.ts rename to src/main/features/files/files-service.ts diff --git a/src/main/features/github/github-handlers.ts b/src/main/features/github/github-handlers.ts new file mode 100644 index 00000000..9f4021d8 --- /dev/null +++ b/src/main/features/github/github-handlers.ts @@ -0,0 +1,42 @@ +/** + * GitHub IPC handlers. + */ + +import { GITHUB } from '@shared/ipc/github/channels'; + +import type { GitHubService } from './github-service'; +import type { IpcRouter } from '../../ipc/router'; + +export function registerGitHubHandlers(router: IpcRouter, service: GitHubService): void { + router.handle(GITHUB.GET['AUTH-STATUS'], async () => { + return await service.getAuthStatus(); + }); + + router.handle(GITHUB.LIST.REPOS, async (params) => { + return await service.getRepos(params); + }); + + router.handle(GITHUB.LIST.PRS, async (params) => { + return await service.listPrs(params); + }); + + router.handle(GITHUB.GET.PR, async (params) => { + return await service.getPr(params); + }); + + router.handle(GITHUB.GET.PR_FILES, async (params) => { + return await service.getPrFiles(params); + }); + + router.handle(GITHUB.LIST.ISSUES, async (params) => { + return await service.listIssues(params); + }); + + router.handle(GITHUB.CREATE.ISSUE, async (params) => { + return await service.createIssue(params); + }); + + router.handle(GITHUB.GET.NOTIFICATIONS, async (params) => { + return await service.getNotifications(params); + }); +} diff --git a/src/main/features/github/github-service.ts b/src/main/features/github/github-service.ts new file mode 100644 index 00000000..a937099b --- /dev/null +++ b/src/main/features/github/github-service.ts @@ -0,0 +1,162 @@ +/** + * GitHub Service — wraps the gh CLI client for GitHub API access. + * + * Maps raw API responses to the shared GitHubPullRequest / GitHubIssue / GitHubNotification types. + */ + +import type { PrDiffFile } from '@shared/ipc/github'; +import { GITHUB_EVENTS } from '@shared/ipc/github/channels'; +import type { GitHubIssue, GitHubNotification, GitHubPullRequest } from '@shared/types'; + +import type { IpcRouter } from '../../ipc/router'; +import type { + GitHubAuthStatus, + GitHubClient, + GitHubRepo, +} from '../../mcp-servers/github/github-client'; +import type { Issue, Notification, PullRequest } from '../../mcp-servers/github/types'; + +// ── Interface ───────────────────────────────────────────────── + +export interface GitHubService { + getAuthStatus: () => Promise; + + getRepos: (params?: { limit?: number }) => Promise; + + listPrs: (params: { + owner: string; + repo: string; + state?: 'open' | 'closed' | 'all'; + }) => Promise; + + getPr: (params: { owner: string; repo: string; number: number }) => Promise; + + getPrFiles: (params: { owner: string; repo: string; number: number }) => Promise; + + listIssues: (params: { + owner: string; + repo: string; + state?: 'open' | 'closed' | 'all'; + }) => Promise; + + createIssue: (params: { + owner: string; + repo: string; + title: string; + body?: string; + labels?: string[]; + assignees?: string[]; + }) => Promise; + + getNotifications: (params?: { all?: boolean }) => Promise; +} + +// ── Mappers ─────────────────────────────────────────────────── + +function mapPr(raw: PullRequest): GitHubPullRequest { + return { + id: raw.id, + number: raw.number, + title: raw.title, + body: raw.body ?? '', + state: raw.state, + merged: raw.merged, + draft: raw.draft, + author: raw.user.login, + authorAvatar: raw.user.avatar_url, + url: raw.html_url, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + headBranch: raw.head.ref, + baseBranch: raw.base.ref, + labels: raw.labels.map((l) => ({ name: l.name, color: l.color })), + reviewers: raw.requested_reviewers.map((r) => r.login), + comments: raw.review_comments, + additions: raw.additions, + deletions: raw.deletions, + changedFiles: raw.changed_files, + }; +} + +function mapIssue(raw: Issue): GitHubIssue { + return { + id: raw.id, + number: raw.number, + title: raw.title, + body: raw.body ?? '', + state: raw.state, + author: raw.user.login, + authorAvatar: raw.user.avatar_url, + url: raw.html_url, + labels: raw.labels.map((l) => ({ name: l.name, color: l.color })), + assignees: raw.assignees.map((a) => a.login), + comments: raw.comments, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + }; +} + +function mapNotification(raw: Notification): GitHubNotification { + return { + id: raw.id, + unread: raw.unread, + reason: raw.reason, + title: raw.subject.title, + type: raw.subject.type, + repoName: raw.repository.full_name, + updatedAt: raw.updated_at, + }; +} + +// ── Factory ─────────────────────────────────────────────────── + +export function createGitHubService(deps: { + client: GitHubClient; + router: IpcRouter; +}): GitHubService { + const { client, router } = deps; + + function emitUpdated(type: 'pr' | 'issue' | 'notification', owner: string, repo: string): void { + router.emit(GITHUB_EVENTS.DATA.UPDATED, { type, owner, repo }); + } + + return { + async getAuthStatus() { + return await client.getAuthStatus(); + }, + + async getRepos(params) { + return await client.getRepos(params); + }, + + async listPrs(params) { + const raw = await client.listPrs(params); + return raw.map(mapPr); + }, + + async getPr(params) { + const raw = await client.getPr(params); + return mapPr(raw); + }, + + async getPrFiles(params) { + return await client.getPrFiles(params); + }, + + async listIssues(params) { + const raw = await client.listIssues(params); + return raw.map(mapIssue); + }, + + async createIssue(params) { + const raw = await client.createIssue(params); + emitUpdated('issue', params.owner, params.repo); + return mapIssue(raw); + }, + + async getNotifications(params) { + const raw = await client.getNotifications(params); + return raw.map(mapNotification); + }, + }; +} diff --git a/src/main/features/github/index.ts b/src/main/features/github/index.ts new file mode 100644 index 00000000..46d5aae4 --- /dev/null +++ b/src/main/features/github/index.ts @@ -0,0 +1,6 @@ +/** + * GitHub feature — public API + */ +export { createGitHubService } from './github-service'; +export { registerGitHubHandlers } from './github-handlers'; +export type { GitHubService } from './github-service'; diff --git a/src/main/features/hub/FEATURE.md b/src/main/features/hub/FEATURE.md deleted file mode 100644 index 18982977..00000000 --- a/src/main/features/hub/FEATURE.md +++ /dev/null @@ -1,22 +0,0 @@ -# Hub Connection - -Manages the full lifecycle of connecting to the Hub backend server: configuration, authentication, REST API communication, WebSocket real-time updates, and bidirectional data sync. - -## Key Files - -- **`hub-connection.ts`** — Facade that orchestrates config persistence, WebSocket lifecycle, and connection status -- **`hub-api-client.ts`** — Low-level typed HTTP helpers using node:http/https for authenticated Hub API requests -- **`hub-auth-service.ts`** — User authentication: login, register, logout, token refresh via Hub server -- **`hub-client.ts`** — High-level REST client wrapping fetch calls with API key auth and typed responses -- **`hub-config-store.ts`** — Encrypted JSON persistence for Hub connection config (API keys via Electron safeStorage) -- **`hub-event-mapper.ts`** — Maps WebSocket event payloads to typed IPC events and converts config to view objects -- **`hub-ws-client.ts`** — WebSocket connection management with auth handshake and auto-reconnect -- **`hub-sync.ts`** — Bidirectional data sync: queues local mutations when offline, syncs on reconnect -- **`hub-errors.ts`** — Standardized error types (HubApiError) with HTTP status code and auth error detection -- **`webhook-relay.ts`** — Forwards Hub WebSocket webhook_command broadcasts to the assistant service - -## How It Connects - -- **Upstream:** IPC handlers (`hub-handlers.ts`, `auth-handlers.ts`), service registry at bootstrap -- **Downstream:** Hub REST API, Hub WebSocket server, assistant service (via webhook relay) -- **Events:** Emits `hub.status`, `hub.sync`, and entity update events to the renderer via IPC diff --git a/src/main/features/hub/device.ts b/src/main/features/hub/device.ts deleted file mode 100644 index 98693c4a..00000000 --- a/src/main/features/hub/device.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Hub — Device sub-module barrel - */ - -export { createDeviceService } from './device/device-service'; -export { createHeartbeatService } from './device/heartbeat'; -export { registerDeviceHandlers } from './device/device-handlers'; - -export type { DeviceService, DeviceRegisterInput, DeviceUpdateInput } from './device/device-service'; -export type { HeartbeatService } from './device/heartbeat'; diff --git a/src/main/features/hub/device/device-handlers.ts b/src/main/features/hub/device/device-handlers.ts deleted file mode 100644 index 0a7624c5..00000000 --- a/src/main/features/hub/device/device-handlers.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Device IPC handlers — Proxies to Hub API via DeviceService - */ - -import { DEVICES } from '@shared/ipc/misc/devices.channels'; - -import type { DeviceService } from "./device-service"; -import type { IpcRouter } from '../../../ipc/router'; - -export function registerDeviceHandlers(router: IpcRouter, deviceService: DeviceService): void { - router.handle(DEVICES.LIST.ALL, async () => { - return await deviceService.getDevices(); - }); - - router.handle(DEVICES.REGISTER.DEVICE, async (input) => { - return await deviceService.registerDevice(input); - }); - - router.handle(DEVICES.HEARTBEAT.DEVICE, async ({ deviceId }) => { - return await deviceService.sendHeartbeat(deviceId); - }); - - router.handle(DEVICES.UPDATE.DEVICE, async ({ deviceId, ...updates }) => { - return await deviceService.updateDevice(deviceId, updates); - }); -} diff --git a/src/main/features/hub/device/device-service.ts b/src/main/features/hub/device/device-service.ts deleted file mode 100644 index 58ff0fcd..00000000 --- a/src/main/features/hub/device/device-service.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Device Service — Manages device registration and status via Hub API - * - * All methods are async since they proxy to the Hub REST API. - */ - -import type { Device, DeviceCapabilities } from '@shared/types'; -import type { DeviceType } from '@shared/types/hub-protocol'; - -import type { HubApiClient } from '../hub-api-client'; - -// ─── Types ─────────────────────────────────────────────────── - -export interface DeviceRegisterInput { - machineId: string; - deviceName: string; - deviceType: DeviceType; - capabilities: DeviceCapabilities; - appVersion: string; -} - -export interface DeviceUpdateInput { - deviceName?: string; - nickname?: string; - capabilities?: DeviceCapabilities; - isOnline?: boolean; - appVersion?: string; -} - -export interface DeviceService { - registerDevice: (input: DeviceRegisterInput) => Promise; - getDevices: () => Promise; - updateDevice: (id: string, updates: DeviceUpdateInput) => Promise; - sendHeartbeat: (deviceId: string) => Promise<{ success: boolean; lastSeen: string }>; -} - -// ─── Factory ───────────────────────────────────────────────── - -export function createDeviceService(deps: { - hubApiClient: HubApiClient; -}): DeviceService { - const { hubApiClient } = deps; - - return { - async registerDevice(input) { - const result = await hubApiClient.registerDevice({ - machineId: input.machineId, - deviceType: input.deviceType, - deviceName: input.deviceName, - capabilities: input.capabilities, - appVersion: input.appVersion, - }); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? 'Failed to register device'); - } - - return result.data; - }, - - async getDevices() { - const result = await hubApiClient.hubGet<{ devices: Device[] }>('/api/devices'); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? 'Failed to fetch devices'); - } - - return result.data.devices; - }, - - async updateDevice(id, updates) { - const result = await hubApiClient.hubPatch( - `/api/devices/${encodeURIComponent(id)}`, - updates, - ); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? `Failed to update device ${id}`); - } - - return result.data; - }, - - async sendHeartbeat(deviceId) { - const result = await hubApiClient.heartbeat(deviceId); - - if (!result.ok) { - throw new Error(result.error ?? `Failed to send heartbeat for device ${deviceId}`); - } - - return { - success: true, - lastSeen: new Date().toISOString(), - }; - }, - }; -} diff --git a/src/main/features/hub/device/heartbeat.ts b/src/main/features/hub/device/heartbeat.ts deleted file mode 100644 index b115dea4..00000000 --- a/src/main/features/hub/device/heartbeat.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Heartbeat Service — Sends periodic heartbeat pings to Hub - * - * Starts a timer that calls deviceService.sendHeartbeat() at a configurable - * interval (default 30 seconds). Handles errors gracefully without crashing. - */ - -import { serviceLogger } from '@main/lib/logger'; - -import type { DeviceService } from './device-service'; - -const DEFAULT_INTERVAL_MS = 30_000; - -export interface HeartbeatService { - start: (deviceId: string) => void; - stop: () => void; -} - -export function createHeartbeatService(deps: { - deviceService: DeviceService; - intervalMs?: number; -}): HeartbeatService { - const { deviceService, intervalMs = DEFAULT_INTERVAL_MS } = deps; - let timer: ReturnType | null = null; - let currentDeviceId: string | null = null; - - function tick(): void { - if (!currentDeviceId) return; - - deviceService.sendHeartbeat(currentDeviceId).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - serviceLogger.warn('[HeartbeatService] Heartbeat failed:', message); - }); - } - - return { - start(deviceId) { - // Stop any existing timer first - if (timer !== null) { - clearInterval(timer); - } - - currentDeviceId = deviceId; - timer = setInterval(tick, intervalMs); - - serviceLogger.info( - `[HeartbeatService] Started heartbeat for device ${deviceId} (interval: ${String(intervalMs)}ms)`, - ); - }, - - stop() { - if (timer !== null) { - clearInterval(timer); - timer = null; - } - currentDeviceId = null; - - serviceLogger.info('[HeartbeatService] Stopped'); - }, - }; -} diff --git a/src/main/features/hub/hub-api-client.ts b/src/main/features/hub/hub-api-client.ts deleted file mode 100644 index 9f2683e7..00000000 --- a/src/main/features/hub/hub-api-client.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Hub API Client — Typed HTTP helpers - * - * Low-level helpers that make authenticated HTTP requests to the Hub server. - * Uses node:http / node:https for zero external dependencies. - * Each method returns a typed result with proper error handling. - */ - -import { request as httpRequest } from 'node:http'; -import { request as httpsRequest } from 'node:https'; - -import type { - DeleteResponse, - Device, - DeviceCapabilities, - DeviceRegisterRequest, - DeviceType, - ProgressPushRequest, - ProgressPushResponse, - Task, - TaskCancelResponse, - TaskCreateRequest, - TaskExecuteResponse, - TaskStatus, - TaskUpdateRequest, -} from '@shared/types/hub-protocol'; - -// ─── Result type ──────────────────────────────────────────── - -export interface HubApiResponse { - ok: boolean; - data?: T; - error?: string; - statusCode?: number; -} - -// ─── Client interface ─────────────────────────────────────── - -export interface HubApiClient { - hubGet: (path: string) => Promise>; - hubPost: (path: string, body: unknown) => Promise>; - hubPatch: (path: string, body: unknown) => Promise>; - hubPut: (path: string, body: unknown) => Promise>; - hubDelete: (path: string) => Promise>; - - // ── Typed task helpers ── - listTasks: (query?: Record) => Promise>; - getTask: (taskId: string) => Promise>; - createTask: (body: TaskCreateRequest) => Promise>; - updateTask: (taskId: string, body: TaskUpdateRequest) => Promise>; - deleteTask: (taskId: string) => Promise>; - pushProgress: ( - taskId: string, - body: ProgressPushRequest, - ) => Promise>; - updateTaskStatus: (taskId: string, status: TaskStatus) => Promise>; - executeTask: (taskId: string) => Promise>; - cancelTask: (taskId: string, reason?: string) => Promise>; - - // ── Device registration ── - registerDevice: (data: { - machineId: string; - deviceType: DeviceType; - deviceName: string; - capabilities: DeviceCapabilities; - appVersion: string; - }) => Promise>; - heartbeat: (deviceId: string) => Promise>; -} - -// ─── Internal HTTP helper ─────────────────────────────────── - -function makeRequest( - baseUrl: string, - accessToken: string, - method: string, - path: string, - body?: unknown, -): Promise> { - return new Promise((resolve) => { - const url = new URL(path, baseUrl); - const isHttps = url.protocol === 'https:'; - const doRequest = isHttps ? httpsRequest : httpRequest; - - const bodyString = body === undefined ? undefined : JSON.stringify(body); - - const options = { - hostname: url.hostname, - port: url.port || (isHttps ? 443 : 80), - path: url.pathname + url.search, - method, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, - ...(bodyString === undefined ? {} : { 'Content-Length': String(Buffer.byteLength(bodyString)) }), - }, - }; - - const req = doRequest(options, (res) => { - const chunks: Buffer[] = []; - - res.on('data', (chunk: Buffer) => { - chunks.push(chunk); - }); - - res.on('end', () => { - const rawBody = Buffer.concat(chunks).toString('utf-8'); - const statusCode = res.statusCode ?? 0; - - if (statusCode === 204) { - resolve({ ok: true, statusCode }); - return; - } - - if (statusCode >= 200 && statusCode < 300) { - try { - const data = JSON.parse(rawBody) as T; - resolve({ ok: true, data, statusCode }); - } catch { - resolve({ ok: false, error: 'Invalid JSON response', statusCode }); - } - return; - } - - // Error response - let errorMessage: string; - try { - const parsed = JSON.parse(rawBody) as { error?: string | { message?: string } }; - if (typeof parsed.error === 'string') { - errorMessage = parsed.error; - } else if (typeof parsed.error === 'object') { - errorMessage = parsed.error.message ?? `HTTP ${String(statusCode)}`; - } else { - errorMessage = `HTTP ${String(statusCode)}`; - } - } catch { - errorMessage = `HTTP ${String(statusCode)}: ${rawBody.slice(0, 200)}`; - } - - resolve({ ok: false, error: errorMessage, statusCode }); - }); - }); - - req.on('error', (err) => { - resolve({ ok: false, error: err.message }); - }); - - if (bodyString !== undefined) { - req.write(bodyString); - } - - req.end(); - }); -} - -// ─── Factory ──────────────────────────────────────────────── - -export function createHubApiClient( - getBaseUrl: () => string | null, - getAccessToken: () => string | null, -): HubApiClient { - function ensureConfig(): { baseUrl: string; token: string } { - const baseUrl = getBaseUrl(); - const token = getAccessToken(); - if (!baseUrl || !token) { - throw new Error('Hub not configured'); - } - return { baseUrl, token }; - } - - function hubGet(path: string): Promise> { - const { baseUrl, token } = ensureConfig(); - return makeRequest(baseUrl, token, 'GET', path); - } - - function hubPost(path: string, body: unknown): Promise> { - const { baseUrl, token } = ensureConfig(); - return makeRequest(baseUrl, token, 'POST', path, body); - } - - function hubPatch(path: string, body: unknown): Promise> { - const { baseUrl, token } = ensureConfig(); - return makeRequest(baseUrl, token, 'PATCH', path, body); - } - - function hubPut(path: string, body: unknown): Promise> { - const { baseUrl, token } = ensureConfig(); - return makeRequest(baseUrl, token, 'PUT', path, body); - } - - function hubDelete(path: string): Promise> { - const { baseUrl, token } = ensureConfig(); - return makeRequest(baseUrl, token, 'DELETE', path); - } - - return { - hubGet, - hubPost, - hubPatch, - hubPut, - hubDelete, - - listTasks(query) { - const params = new URLSearchParams(query); - const qs = params.toString(); - return hubGet(`/api/tasks${qs ? `?${qs}` : ''}`); - }, - - getTask(taskId) { - return hubGet(`/api/tasks/${encodeURIComponent(taskId)}`); - }, - - createTask(body) { - // Hub expects snake_case fields; client uses camelCase - const hubBody: Record = { - project_id: body.projectId, - title: body.title, - description: body.description, - }; - if (body.priority !== undefined) { - // Hub expects priority as a number - const priorityMap: Record = { - low: 0, - normal: 1, - medium: 1, - high: 2, - urgent: 3, - critical: 3, - }; - hubBody.priority = priorityMap[body.priority] ?? 1; - } - if (body.metadata !== undefined) { - hubBody.metadata = body.metadata; - } - return hubPost('/api/tasks', hubBody); - }, - - updateTask(taskId, body) { - // Hub expects snake_case fields; map priority string to number - const hubBody: Record = { ...body }; - if (body.priority !== undefined) { - const priorityMap: Record = { - low: 0, - normal: 1, - medium: 1, - high: 2, - urgent: 3, - critical: 3, - }; - hubBody.priority = priorityMap[body.priority] ?? 1; - } - return hubPut(`/api/tasks/${encodeURIComponent(taskId)}`, hubBody); - }, - - deleteTask(taskId) { - return hubDelete(`/api/tasks/${encodeURIComponent(taskId)}`); - }, - - pushProgress(taskId, body) { - return hubPost(`/api/tasks/${encodeURIComponent(taskId)}/progress`, body); - }, - - updateTaskStatus(taskId, status) { - return hubPatch(`/api/tasks/${encodeURIComponent(taskId)}/status`, { status }); - }, - - executeTask(taskId) { - return hubPost(`/api/tasks/${encodeURIComponent(taskId)}/execute`, {}); - }, - - cancelTask(taskId, reason) { - return hubPost(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { reason }); - }, - - registerDevice(data) { - const body: DeviceRegisterRequest = { - machineId: data.machineId, - deviceType: data.deviceType, - deviceName: data.deviceName, - capabilities: data.capabilities, - appVersion: data.appVersion, - }; - return hubPost('/api/devices', body); - }, - - heartbeat(deviceId) { - return hubPost(`/api/devices/${encodeURIComponent(deviceId)}/heartbeat`, {}); - }, - }; -} diff --git a/src/main/features/hub/hub-auth-service.ts b/src/main/features/hub/hub-auth-service.ts deleted file mode 100644 index 1b4a752f..00000000 --- a/src/main/features/hub/hub-auth-service.ts +++ /dev/null @@ -1,362 +0,0 @@ -/** - * Hub Auth Service — User authentication against the Hub server - * - * Handles login, registration, logout, token refresh, and current user retrieval. - * Uses the shared TokenStore for secure token persistence (provider: 'hub'). - */ - -import { request as httpRequest } from 'node:http'; -import { request as httpsRequest } from 'node:https'; - -import type { - AuthLoginRequest, - AuthRefreshResponse, - AuthRegisterRequest, - AuthResponse, - User, -} from '@shared/types/hub-protocol'; - -import type { TokenStore } from '@main/auth/token-store'; -import { hubLogger } from '@main/lib/logger'; - -// ─── Result type ──────────────────────────────────────────── - -export interface HubAuthResult { - ok: boolean; - data?: T; - error?: string; - statusCode?: number; -} - -// ─── Service interface ────────────────────────────────────── - -export type RestoreResult = - | { restored: true; user: User; accessToken: string; refreshToken: string; expiresAt: string } - | { restored: false }; - -export interface HubAuthService { - login: (data: { email: string; password: string }) => Promise>; - register: (data: { - email: string; - password: string; - displayName: string; - }) => Promise>; - logout: () => Promise>; - getCurrentUser: () => Promise>; - refreshToken: () => Promise>; - restoreSession: () => Promise; - getAccessToken: () => string | null; - isAuthenticated: () => boolean; -} - -// ─── Internal HTTP helper ─────────────────────────────────── - -function makeRequest( - baseUrl: string, - method: string, - path: string, - body?: unknown, - accessToken?: string, -): Promise> { - return new Promise((resolve) => { - const url = new URL(path, baseUrl); - const isHttps = url.protocol === 'https:'; - const doRequest = isHttps ? httpsRequest : httpRequest; - - const bodyString = body === undefined ? undefined : JSON.stringify(body); - - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}`; - } - - if (bodyString !== undefined) { - headers['Content-Length'] = String(Buffer.byteLength(bodyString)); - } - - const options = { - hostname: url.hostname, - port: url.port || (isHttps ? 443 : 80), - path: url.pathname + url.search, - method, - headers, - }; - - const req = doRequest(options, (res) => { - const chunks: Buffer[] = []; - - res.on('data', (chunk: Buffer) => { - chunks.push(chunk); - }); - - res.on('end', () => { - const rawBody = Buffer.concat(chunks).toString('utf-8'); - const statusCode = res.statusCode ?? 0; - - if (statusCode === 204) { - resolve({ ok: true, statusCode }); - return; - } - - if (statusCode >= 200 && statusCode < 300) { - try { - const data = JSON.parse(rawBody) as T; - resolve({ ok: true, data, statusCode }); - } catch { - resolve({ ok: false, error: 'Invalid JSON response', statusCode }); - } - return; - } - - // Error response - let errorMessage: string; - try { - const parsed = JSON.parse(rawBody) as { error?: string | { message?: string } }; - if (typeof parsed.error === 'string') { - errorMessage = parsed.error; - } else if (parsed.error === undefined) { - errorMessage = `HTTP ${String(statusCode)}`; - } else { - errorMessage = parsed.error.message ?? `HTTP ${String(statusCode)}`; - } - } catch { - errorMessage = `HTTP ${String(statusCode)}: ${rawBody.slice(0, 200)}`; - } - - resolve({ ok: false, error: errorMessage, statusCode }); - }); - }); - - req.on('error', (err) => { - resolve({ ok: false, error: err.message }); - }); - - if (bodyString !== undefined) { - req.write(bodyString); - } - - req.end(); - }); -} - -// ─── Factory ──────────────────────────────────────────────── - -const HUB_PROVIDER = 'hub'; - -export interface HubAuthServiceDeps { - tokenStore: TokenStore; - getHubUrl: () => string | null; -} - -export function createHubAuthService(deps: HubAuthServiceDeps): HubAuthService { - const { tokenStore, getHubUrl } = deps; - - function getBaseUrl(): string { - const hubUrl = getHubUrl(); - if (!hubUrl) { - throw new Error('Hub URL not configured'); - } - return hubUrl; - } - - function storeTokens(response: AuthResponse): void { - tokenStore.setTokens(HUB_PROVIDER, { - accessToken: response.accessToken, - refreshToken: response.refreshToken, - expiresAt: response.expiresAt, - tokenType: 'Bearer', - }); - } - - return { - async login(data) { - const baseUrl = getBaseUrl(); - const requestBody: AuthLoginRequest = { - email: data.email, - password: data.password, - }; - - const result = await makeRequest( - baseUrl, - 'POST', - '/api/auth/login', - requestBody, - ); - - if (result.ok && result.data) { - storeTokens(result.data); - } - - return result; - }, - - async register(data) { - const baseUrl = getBaseUrl(); - const requestBody: AuthRegisterRequest = { - email: data.email, - password: data.password, - displayName: data.displayName, - }; - - const result = await makeRequest( - baseUrl, - 'POST', - '/api/auth/register', - requestBody, - ); - - if (result.ok && result.data) { - storeTokens(result.data); - } - - return result; - }, - - async logout() { - const tokens = tokenStore.getTokens(HUB_PROVIDER); - const accessToken = tokens?.accessToken; - - // Try to call server logout, but clear local tokens regardless - try { - if (accessToken) { - const baseUrl = getBaseUrl(); - await makeRequest<{ success: boolean }>( - baseUrl, - 'POST', - '/api/auth/logout', - {}, - accessToken, - ); - } - } catch { - hubLogger.warn('[HubAuth] Server logout failed, clearing local tokens anyway'); - } - - tokenStore.deleteTokens(HUB_PROVIDER); - return { ok: true, data: { success: true } }; - }, - - async getCurrentUser() { - const tokens = tokenStore.getTokens(HUB_PROVIDER); - if (!tokens?.accessToken) { - return { ok: false, error: 'Not authenticated' }; - } - - const baseUrl = getBaseUrl(); - // Hub returns { user: User, device?: Device } — extract the user field - const result = await makeRequest<{ user: User }>( - baseUrl, - 'GET', - '/api/auth/me', - undefined, - tokens.accessToken, - ); - - if (result.ok && result.data) { - return { ok: true, data: result.data.user, statusCode: result.statusCode }; - } - - return { ok: result.ok, error: result.error, statusCode: result.statusCode }; - }, - - async refreshToken() { - const tokens = tokenStore.getTokens(HUB_PROVIDER); - if (!tokens?.refreshToken) { - return { ok: false, error: 'No refresh token available' }; - } - - const baseUrl = getBaseUrl(); - const result = await makeRequest( - baseUrl, - 'POST', - '/api/auth/refresh', - { refreshToken: tokens.refreshToken }, - ); - - if (result.ok && result.data) { - // Store the rotated refresh token from the Hub response - tokenStore.setTokens(HUB_PROVIDER, { - accessToken: result.data.accessToken, - refreshToken: result.data.refreshToken, - expiresAt: result.data.expiresAt, - tokenType: 'Bearer', - }); - } else if (result.statusCode === 401) { - // Only clear tokens when the server explicitly rejects them. - // Network errors or other failures should preserve tokens so - // the user stays logged in and can retry later. - hubLogger.warn('[HubAuth] Refresh token rejected by server (401) — clearing tokens'); - tokenStore.deleteTokens(HUB_PROVIDER); - } else { - hubLogger.warn(`[HubAuth] Token refresh failed (${result.error ?? 'unknown'}) — keeping tokens for retry`); - } - - return result; - }, - - getAccessToken() { - const tokens = tokenStore.getTokens(HUB_PROVIDER); - return tokens?.accessToken ?? null; - }, - - isAuthenticated() { - return tokenStore.hasTokens(HUB_PROVIDER); - }, - - async restoreSession(): Promise { - // No stored tokens — nothing to restore - if (!tokenStore.hasTokens(HUB_PROVIDER)) { - return { restored: false }; - } - - // Attempt to refresh the stored token - const refreshResult = await this.refreshToken(); - - if (refreshResult.ok && refreshResult.data) { - // Refresh succeeded — fetch current user with the fresh token - const userResult = await this.getCurrentUser(); - if (userResult.ok && userResult.data) { - return { - restored: true, - user: userResult.data, - accessToken: refreshResult.data.accessToken, - refreshToken: refreshResult.data.refreshToken, - expiresAt: refreshResult.data.expiresAt, - }; - } - hubLogger.warn('[HubAuth] Session restore: refreshed tokens but could not fetch user'); - } - - // Refresh failed — if tokens were cleared (401), give up - if (!tokenStore.hasTokens(HUB_PROVIDER)) { - hubLogger.warn('[HubAuth] Session restore failed: tokens were revoked'); - return { restored: false }; - } - - // Tokens still exist (network/Hub error, not 401). Try the existing - // access token — it may still be valid if it hasn't expired yet. - const existingTokens = tokenStore.getTokens(HUB_PROVIDER); - if (existingTokens?.accessToken) { - const userResult = await this.getCurrentUser(); - if (userResult.ok && userResult.data) { - hubLogger.info('[HubAuth] Session restored using existing access token (refresh failed)'); - return { - restored: true, - user: userResult.data, - accessToken: existingTokens.accessToken, - refreshToken: existingTokens.refreshToken ?? '', - expiresAt: existingTokens.expiresAt ?? new Date(Date.now() + 900_000).toISOString(), - }; - } - } - - // Both refresh and existing token failed — Hub is unreachable. - // Return false but tokens are preserved for next attempt. - hubLogger.warn('[HubAuth] Session restore failed: Hub unreachable — tokens preserved for retry'); - return { restored: false }; - }, - }; -} diff --git a/src/main/features/hub/hub-client.ts b/src/main/features/hub/hub-client.ts deleted file mode 100644 index f9abbaae..00000000 --- a/src/main/features/hub/hub-client.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Hub REST API Client - * - * HTTP client for communicating with the hub backend. - * Wraps fetch calls with API key authentication, error handling, - * and typed responses for each hub API endpoint. - */ - -import type { HubConnection } from '@shared/types'; - -/** Generic API response — either success with data or error with message. */ -export interface HubApiResult { - success: boolean; - data?: T; - error?: string; - statusCode?: number; -} - -/** Hub project entity matching the backend schema. */ -export interface HubProject { - id: string; - name: string; - path: string; - created_at: string; - updated_at: string; -} - -/** Hub task entity matching the backend schema. */ -export interface HubTask { - id: string; - project_id: string; - title: string; - description: string; - status: string; - priority: number; - created_at: string; - updated_at: string; -} - -/** Hub planner event entity matching the backend schema. */ -export interface HubPlannerEvent { - id: string; - date: string; - start_time: string; - end_time: string; - title: string; - category: string; - task_id: string | null; - created_at: string; - updated_at: string; -} - -/** Hub capture entity matching the backend schema. */ -export interface HubCapture { - id: string; - text: string; - project_id: string | null; - created_at: string; -} - -/** Hub agent run entity matching the backend schema. */ -export interface HubAgentRun { - id: string; - task_id: string | null; - project_id: string; - status: string; - started_at: string; - completed_at: string | null; - tokens_used: number; - cost_usd: number; - log: string; -} - -export interface HubClient { - /** Check if the hub server is reachable. */ - healthCheck: () => Promise>; - - // ── Projects ── - listProjects: () => Promise>; - getProject: (id: string) => Promise>; - createProject: (data: { name: string; path: string }) => Promise>; - updateProject: ( - id: string, - data: Partial>, - ) => Promise>; - deleteProject: (id: string) => Promise>; - - // ── Tasks ── - listTasks: (projectId?: string) => Promise>; - getTask: (id: string) => Promise>; - createTask: (data: { - project_id: string; - title: string; - description?: string; - status?: string; - priority?: number; - }) => Promise>; - updateTask: ( - id: string, - data: Partial>, - ) => Promise>; - deleteTask: (id: string) => Promise>; - updateTaskStatus: (id: string, status: string) => Promise>; - - // ── Planner ── - listPlannerEvents: (date?: string, endDate?: string) => Promise>; - createPlannerEvent: (data: { - date: string; - start_time: string; - end_time: string; - title: string; - category?: string; - task_id?: string; - }) => Promise>; - updatePlannerEvent: ( - id: string, - data: Partial< - Pick - >, - ) => Promise>; - deletePlannerEvent: (id: string) => Promise>; - - // ── Captures ── - listCaptures: (limit?: number) => Promise>; - createCapture: (data: { text: string; project_id?: string }) => Promise>; - deleteCapture: (id: string) => Promise>; - - // ── Agent Runs ── - listAgentRuns: (projectId?: string) => Promise>; - createAgentRun: (data: { - project_id: string; - task_id?: string; - status?: string; - tokens_used?: number; - cost_usd?: number; - log?: string; - }) => Promise>; - updateAgentRun: ( - id: string, - data: Partial< - Pick - >, - ) => Promise>; - - // ── Settings ── - getSettings: () => Promise>>; - updateSettings: (data: Record) => Promise>>; -} - -async function makeRequest( - baseUrl: string, - apiKey: string, - method: string, - path: string, - body?: unknown, -): Promise> { - const url = `${baseUrl}${path}`; - const headers: Record = { - 'Content-Type': 'application/json', - 'X-API-Key': apiKey, - }; - - try { - const response = await fetch(url, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - }); - - if (response.status === 204) { - return { success: true }; - } - - if (!response.ok) { - const errorBody = await response.text(); - let errorMessage: string; - try { - const parsed = JSON.parse(errorBody) as { error?: string }; - errorMessage = parsed.error ?? `HTTP ${String(response.status)}`; - } catch { - errorMessage = `HTTP ${String(response.status)}: ${errorBody}`; - } - return { success: false, error: errorMessage, statusCode: response.status }; - } - - const data = (await response.json()) as T; - return { success: true, data }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Network error'; - return { success: false, error: message }; - } -} - -export function createHubClient(getConnection: () => HubConnection | null): HubClient { - function request(method: string, path: string, body?: unknown): Promise> { - const conn = getConnection(); - if (!conn) { - return Promise.resolve({ success: false, error: 'Hub not configured' }); - } - if (!conn.enabled) { - return Promise.resolve({ success: false, error: 'Hub connection disabled' }); - } - return makeRequest(conn.hubUrl, conn.apiKey, method, path, body); - } - - return { - healthCheck: () => request('GET', '/api/health'), - - // ── Projects ── - listProjects: () => request('GET', '/api/projects'), - getProject: (id) => request('GET', `/api/projects/${id}`), - createProject: (data) => request('POST', '/api/projects', data), - updateProject: (id, data) => request('PUT', `/api/projects/${id}`, data), - deleteProject: (id) => request('DELETE', `/api/projects/${id}`), - - // ── Tasks ── - listTasks: (projectId) => { - const query = projectId ? `?project_id=${encodeURIComponent(projectId)}` : ''; - return request('GET', `/api/tasks${query}`); - }, - getTask: (id) => request('GET', `/api/tasks/${id}`), - createTask: (data) => request('POST', '/api/tasks', data), - updateTask: (id, data) => request('PUT', `/api/tasks/${id}`, data), - deleteTask: (id) => request('DELETE', `/api/tasks/${id}`), - updateTaskStatus: (id, status) => request('PATCH', `/api/tasks/${id}/status`, { status }), - - // ── Planner ── - listPlannerEvents: (date, endDate) => { - const params = new URLSearchParams(); - if (date) params.set('date', date); - if (endDate) params.set('end_date', endDate); - const query = params.toString(); - return request('GET', `/api/planner/events${query ? `?${query}` : ''}`); - }, - createPlannerEvent: (data) => request('POST', '/api/planner/events', data), - updatePlannerEvent: (id, data) => request('PUT', `/api/planner/events/${id}`, data), - deletePlannerEvent: (id) => request('DELETE', `/api/planner/events/${id}`), - - // ── Captures ── - listCaptures: (limit) => { - const query = limit === undefined ? '' : `?limit=${String(limit)}`; - return request('GET', `/api/captures${query}`); - }, - createCapture: (data) => request('POST', '/api/captures', data), - deleteCapture: (id) => request('DELETE', `/api/captures/${id}`), - - // ── Agent Runs ── - listAgentRuns: (projectId) => { - const query = projectId ? `?project_id=${encodeURIComponent(projectId)}` : ''; - return request('GET', `/api/agent-runs${query}`); - }, - createAgentRun: (data) => request('POST', '/api/agent-runs', data), - updateAgentRun: (id, data) => request('PUT', `/api/agent-runs/${id}`, data), - - // ── Settings ── - getSettings: () => request('GET', '/api/settings'), - updateSettings: (data) => request('PUT', '/api/settings', data), - }; -} diff --git a/src/main/features/hub/hub-config-store.ts b/src/main/features/hub/hub-config-store.ts deleted file mode 100644 index 6a864824..00000000 --- a/src/main/features/hub/hub-config-store.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Hub Config Store - * - * Encrypted persistence for hub connection configuration backed by SQLite. - * Uses the `settings_kv` table with category='hub' and key='default'. - * API keys are encrypted via Electron safeStorage (OS credential store). - * - * One-time migration from hub-config.json on first access. - */ - -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { safeStorage } from 'electron'; - -import { and, eq } from 'drizzle-orm'; - -import { generateId } from '@shared/lib/id'; - -import { settingsKv } from '../../db/schema'; -import { hubLogger } from '../../lib/logger'; - -import type { AdcDatabase } from '../../db'; - -export interface PersistedHubConfig { - hubUrl: string; - /** Base64-encoded encrypted API key. */ - encryptedApiKey: string; - enabled: boolean; - lastConnected?: string; -} - -const SINGLETON_KEY = 'default'; -const CATEGORY = 'hub'; - -// ── Encryption helpers (unchanged — still use safeStorage) ───────── - -export function encryptApiKey(apiKey: string): string { - if (safeStorage.isEncryptionAvailable()) { - const encrypted = safeStorage.encryptString(apiKey); - return encrypted.toString('base64'); - } - // Fallback: base64 encoding (not truly secure, but functional) - return Buffer.from(apiKey, 'utf-8').toString('base64'); -} - -export function decryptApiKey(encoded: string): string { - if (safeStorage.isEncryptionAvailable()) { - const buffer = Buffer.from(encoded, 'base64'); - return safeStorage.decryptString(buffer); - } - return Buffer.from(encoded, 'base64').toString('utf-8'); -} - -// ── JSON migration (one-time) ────────────────────────────────────── - -function migrateFromJson(db: AdcDatabase, dataDir: string): void { - const existing = db - .select() - .from(settingsKv) - .where(and(eq(settingsKv.category, CATEGORY), eq(settingsKv.key, SINGLETON_KEY))) - .get(); - if (existing) return; - - const jsonPath = join(dataDir, 'hub-config.json'); - if (!existsSync(jsonPath)) return; - - try { - const raw = readFileSync(jsonPath, 'utf-8'); - const parsed = JSON.parse(raw) as Partial; - - if (!parsed.hubUrl || !parsed.encryptedApiKey) { - hubLogger.warn('[Hub] Legacy hub-config.json missing required fields, skipping migration'); - return; - } - - const config: PersistedHubConfig = { - hubUrl: parsed.hubUrl, - encryptedApiKey: parsed.encryptedApiKey, - enabled: parsed.enabled ?? true, - lastConnected: parsed.lastConnected, - }; - - db.insert(settingsKv) - .values({ - id: generateId(), - key: SINGLETON_KEY, - category: CATEGORY, - settings: config as unknown, - updatedAt: new Date().toISOString(), - }) - .run(); - - hubLogger.info('[Hub] Migrated hub config from JSON to SQLite'); - } catch (err) { - hubLogger.error('[Hub] Failed to migrate hub config from JSON:', err); - } -} - -// ── SQLite-backed CRUD ───────────────────────────────────────────── - -export interface HubConfigStore { - loadConfig: () => PersistedHubConfig | null; - saveConfig: (config: PersistedHubConfig) => void; - deleteConfig: () => void; -} - -export function createHubConfigStore(db: AdcDatabase, dataDir: string): HubConfigStore { - // One-time migration from legacy JSON - migrateFromJson(db, dataDir); - - function loadConfig(): PersistedHubConfig | null { - const row = db - .select() - .from(settingsKv) - .where(and(eq(settingsKv.category, CATEGORY), eq(settingsKv.key, SINGLETON_KEY))) - .get(); - - if (!row) return null; - - const data = row.settings as PersistedHubConfig; - return { - hubUrl: data.hubUrl, - encryptedApiKey: data.encryptedApiKey, - enabled: data.enabled, - lastConnected: data.lastConnected, - }; - } - - function saveConfig(config: PersistedHubConfig): void { - db.insert(settingsKv) - .values({ - id: generateId(), - key: SINGLETON_KEY, - category: CATEGORY, - settings: config as unknown, - updatedAt: new Date().toISOString(), - }) - .onConflictDoUpdate({ - target: settingsKv.key, - set: { - settings: config as unknown, - updatedAt: new Date().toISOString(), - }, - }) - .run(); - } - - function deleteConfig(): void { - db.delete(settingsKv) - .where(and(eq(settingsKv.category, CATEGORY), eq(settingsKv.key, SINGLETON_KEY))) - .run(); - } - - return { loadConfig, saveConfig, deleteConfig }; -} diff --git a/src/main/features/hub/hub-connection.ts b/src/main/features/hub/hub-connection.ts deleted file mode 100644 index 53a6c681..00000000 --- a/src/main/features/hub/hub-connection.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Hub Connection Manager - * - * Facade that orchestrates hub connection lifecycle: - * - Config persistence (via hub-config-store) - * - WebSocket real-time updates (via hub-ws-client) - * - Event mapping (via hub-event-mapper) - */ - -import { HUB_EVENTS } from '@shared/ipc/hub/channels'; -import type { HubConnection, HubConnectionStatus } from '@shared/types'; - - -import { hubLogger } from '@main/lib/logger'; - -import { createHubClient } from './hub-client'; -import { - createHubConfigStore, - encryptApiKey, -} from './hub-config-store'; -import { configToConnection } from './hub-event-mapper'; -import { createHubWsClient } from './hub-ws-client'; - -import type { HubClient } from './hub-client'; -import type { PersistedHubConfig } from './hub-config-store'; -import type { AdcDatabase } from '../../db'; -import type { IpcRouter } from '../../ipc/router'; - -export type { HubConnectionStatus } from '@shared/types'; - -export interface HubConnectionManager { - /** Get the current hub connection config. */ - getConnection: () => HubConnection | null; - /** Configure and save hub connection (API key encrypted via safeStorage). */ - configure: (hubUrl: string, apiKey: string) => HubConnection; - /** Enable/disable the hub connection. */ - setEnabled: (enabled: boolean) => HubConnection | null; - /** Attempt to connect to the hub (health check + WebSocket). */ - connect: () => Promise<{ success: boolean; error?: string }>; - /** Disconnect from the hub. */ - disconnect: () => void; - /** Get the current connection status. */ - getStatus: () => HubConnectionStatus; - /** Get the HTTP client for hub API calls. */ - getClient: () => HubClient; - /** Check if hub is available (configured + connected). */ - isAvailable: () => boolean; - /** Remove hub configuration entirely. */ - removeConfig: () => void; - /** Register a callback for raw WebSocket messages. */ - onWebSocketMessage: (callback: (data: unknown) => void) => void; - /** Clean up resources. */ - dispose: () => void; -} - -export interface HubConnectionManagerDeps { - router: IpcRouter; - db: AdcDatabase; - dataDir: string; -} - -export function createHubConnectionManager(deps: HubConnectionManagerDeps): HubConnectionManager { - const { router, db, dataDir } = deps; - const configStore = createHubConfigStore(db, dataDir); - let persistedConfig: PersistedHubConfig | null = configStore.loadConfig(); - let status: HubConnectionStatus = 'disconnected'; - const messageListeners: Array<(data: unknown) => void> = []; - - function getConnectionForClient(): HubConnection | null { - if (!persistedConfig) { - return null; - } - return configToConnection(persistedConfig, status); - } - - const client = createHubClient(getConnectionForClient); - - function setStatus(newStatus: HubConnectionStatus): void { - if (status === newStatus) { - return; - } - status = newStatus; - router.emit(HUB_EVENTS.CONNECTION.CHANGED, { status: newStatus }); - hubLogger.info(`[Hub] Connection status: ${newStatus}`); - } - - async function performConnect(): Promise<{ success: boolean; error?: string }> { - if (!persistedConfig) { - return { success: false, error: 'Hub not configured' }; - } - - setStatus('connecting'); - - const healthResult = await client.healthCheck(); - if (!healthResult.success) { - setStatus('error'); - ws.cancelReconnect(); - return { success: false, error: healthResult.error ?? 'Health check failed' }; - } - - // Health check passed -- mark as connected - const updatedConfig: PersistedHubConfig = { - ...persistedConfig, - lastConnected: new Date().toISOString(), - }; - persistedConfig = updatedConfig; - configStore.saveConfig(persistedConfig); - setStatus('connected'); - - // Establish WebSocket for real-time updates - ws.connect(); - - return { success: true }; - } - - const ws = createHubWsClient({ - router, - getConnection: () => { - if (!persistedConfig) { - throw new Error('[Hub] getConnection called without config'); - } - return configToConnection(persistedConfig, status); - }, - isEnabledAndConnected: () => - persistedConfig !== null && persistedConfig.enabled && status === 'connected', - messageListeners, - scheduleConnect: () => { - if (persistedConfig?.enabled) { - hubLogger.info('[Hub] Attempting reconnect...'); - void performConnect(); - } - }, - }); - - return { - getConnection() { - if (!persistedConfig) { - return null; - } - return configToConnection(persistedConfig, status); - }, - - configure(hubUrl, apiKey) { - const trimmedUrl = hubUrl.replace(/\/+$/, ''); - const encrypted = encryptApiKey(apiKey); - - persistedConfig = { - hubUrl: trimmedUrl, - encryptedApiKey: encrypted, - enabled: true, - }; - - configStore.saveConfig(persistedConfig); - hubLogger.info(`[Hub] Configured hub: ${trimmedUrl}`); - - return configToConnection(persistedConfig, status); - }, - - setEnabled(enabled) { - if (!persistedConfig) { - return null; - } - - persistedConfig.enabled = enabled; - configStore.saveConfig(persistedConfig); - - if (!enabled) { - ws.disconnect(); - ws.cancelReconnect(); - setStatus('disconnected'); - } - - return configToConnection(persistedConfig, status); - }, - - connect() { - return performConnect(); - }, - - disconnect() { - ws.cancelReconnect(); - ws.disconnect(); - setStatus('disconnected'); - }, - - getStatus() { - return status; - }, - - getClient() { - return client; - }, - - isAvailable() { - return persistedConfig !== null && persistedConfig.enabled && status === 'connected'; - }, - - removeConfig() { - ws.disconnect(); - ws.cancelReconnect(); - configStore.deleteConfig(); - persistedConfig = null; - setStatus('disconnected'); - hubLogger.info('[Hub] Configuration removed'); - }, - - onWebSocketMessage(callback) { - messageListeners.push(callback); - }, - - dispose() { - ws.cancelReconnect(); - ws.disconnect(); - }, - }; -} diff --git a/src/main/features/hub/hub-errors.ts b/src/main/features/hub/hub-errors.ts deleted file mode 100644 index ed3b9a9f..00000000 --- a/src/main/features/hub/hub-errors.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Hub API Error Types - * - * Standardized error types for Hub API operations. - * Provides structured error handling across all Hub services. - */ - -/** - * Error thrown by Hub API operations. - * Contains HTTP status code and optional error code for programmatic handling. - */ -export class HubApiError extends Error { - public readonly statusCode: number; - public readonly code?: string; - - constructor(statusCode: number, message: string, code?: string) { - super(message); - this.name = 'HubApiError'; - this.statusCode = statusCode; - this.code = code; - - // Maintain proper prototype chain - Object.setPrototypeOf(this, HubApiError.prototype); - } - - /** - * Check if error is an authentication error (401). - */ - isAuthError(): boolean { - return this.statusCode === 401; - } - - /** - * Check if error is a forbidden error (403). - */ - isForbidden(): boolean { - return this.statusCode === 403; - } - - /** - * Check if error is a not found error (404). - */ - isNotFound(): boolean { - return this.statusCode === 404; - } - - /** - * Check if error is a rate limit error (429). - */ - isRateLimited(): boolean { - return this.statusCode === 429; - } - - /** - * Check if error is a server error (5xx). - */ - isServerError(): boolean { - return this.statusCode >= 500 && this.statusCode < 600; - } - - /** - * Check if the error is retryable. - */ - isRetryable(): boolean { - return this.isRateLimited() || this.isServerError(); - } -} - -/** - * Error thrown when Hub is not configured. - */ -export class HubNotConfiguredError extends Error { - constructor(message = 'Hub not configured') { - super(message); - this.name = 'HubNotConfiguredError'; - Object.setPrototypeOf(this, HubNotConfiguredError.prototype); - } -} - -/** - * Error thrown when Hub connection is not available. - */ -export class HubConnectionError extends Error { - constructor(message = 'Hub connection unavailable') { - super(message); - this.name = 'HubConnectionError'; - Object.setPrototypeOf(this, HubConnectionError.prototype); - } -} diff --git a/src/main/features/hub/hub-event-mapper.ts b/src/main/features/hub/hub-event-mapper.ts deleted file mode 100644 index 7d92d9d2..00000000 --- a/src/main/features/hub/hub-event-mapper.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Hub Event Mapper - * - * Maps WebSocket event payloads to typed IPC events. - * Converts persisted config to HubConnection view objects. - */ - - -import { HUB_EVENTS } from '@shared/ipc/hub/channels'; -import { PROJECTS_EVENTS } from '@shared/ipc/projects/channels'; -import { HUB_TASKS_EVENTS } from '@shared/ipc/tasks/channels'; -import type { HubConnection, HubConnectionStatus } from '@shared/types'; - -import { decryptApiKey } from './hub-config-store'; - -import type { PersistedHubConfig } from './hub-config-store'; -import type { IpcRouter } from '../../ipc/router'; - -export interface WsEventData { - type: string; - entity: string; - action: string; - id: string; - data?: Record; -} - -export function configToConnection( - config: PersistedHubConfig, - status: HubConnectionStatus, -): HubConnection { - return { - hubUrl: config.hubUrl, - apiKey: decryptApiKey(config.encryptedApiKey), - enabled: config.enabled, - lastConnected: config.lastConnected, - status, - }; -} - -function emitTaskEvent( - emitter: IpcRouter, - eventData: WsEventData, -): void { - const projectId = - (eventData.data?.projectId as string | undefined) ?? - (eventData.data?.project_id as string | undefined) ?? - ''; - const taskPayload = { taskId: eventData.id, projectId }; - - if (eventData.action === 'created') { - emitter.emit(HUB_TASKS_EVENTS.TASK.CREATED, taskPayload); - } else if (eventData.action === 'deleted') { - emitter.emit(HUB_TASKS_EVENTS.TASK.DELETED, taskPayload); - } else if (eventData.action === 'completed') { - const rawResult = eventData.data?.result; - emitter.emit(HUB_TASKS_EVENTS.TASK_RUN.COMPLETED, { - ...taskPayload, - result: rawResult === 'failure' ? 'failure' : 'success', - }); - } else if (eventData.action === 'progress') { - emitter.emit(HUB_TASKS_EVENTS.PROGRESS.UPDATED, { - taskId: eventData.id, - progress: typeof eventData.data?.progress === 'number' ? eventData.data.progress : 0, - phase: typeof eventData.data?.phase === 'string' ? eventData.data.phase : '', - }); - } else { - // Default: updated (covers status changes, field edits, etc.) - emitter.emit(HUB_TASKS_EVENTS.TASK.UPDATED, taskPayload); - } -} - -export function routeWebSocketEvent( - emitter: IpcRouter, - eventData: WsEventData, -): void { - switch (eventData.entity) { - case 'tasks': - emitTaskEvent(emitter, eventData); - break; - - case 'projects': - emitter.emit(HUB_EVENTS.PROJECT.UPDATED, { projectId: eventData.id }); - break; - - case 'workspaces': - emitter.emit(HUB_EVENTS.WORKSPACE.UPDATED, { workspaceId: eventData.id }); - break; - - case 'devices': - if (eventData.action === 'online') { - emitter.emit(HUB_EVENTS.DEVICE.ONLINE, { - deviceId: eventData.id, - name: typeof eventData.data?.name === 'string' ? eventData.data.name : '', - }); - } else if (eventData.action === 'offline') { - emitter.emit(HUB_EVENTS.DEVICE.OFFLINE, { deviceId: eventData.id }); - } - break; - - default: - // Fallback for unknown entities - emitter.emit(PROJECTS_EVENTS.PROJECT.UPDATED, { projectId: eventData.id }); - break; - } -} diff --git a/src/main/features/hub/hub-handlers.ts b/src/main/features/hub/hub-handlers.ts deleted file mode 100644 index 80fd3318..00000000 --- a/src/main/features/hub/hub-handlers.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Hub IPC handlers - * - * Hub task channels (`hub.tasks.*`) are registered in `task-handlers.ts`. - */ - -import { HUB, HUB_EVENTS } from '@shared/ipc/hub/channels'; - -import type { HubApiClient } from "./hub-api-client"; -import type { HubConnectionManager } from "./hub-connection"; -import type { HubSyncService } from "./hub-sync"; -import type { IpcRouter } from '../../ipc/router'; - -export function registerHubHandlers( - router: IpcRouter, - connectionManager: HubConnectionManager, - syncService: HubSyncService, - _hubApiClient: HubApiClient, -): void { - router.handle(HUB.CONNECT.SERVER, async ({ url, apiKey }) => { - connectionManager.configure(url, apiKey); - const result = await connectionManager.connect(); - return { success: result.success, error: result.error }; - }); - - router.handle(HUB.DISCONNECT.SERVER, () => { - connectionManager.disconnect(); - return Promise.resolve({ success: true }); - }); - - router.handle(HUB.GET.STATUS, () => { - const connection = connectionManager.getConnection(); - return Promise.resolve({ - status: connectionManager.getStatus(), - hubUrl: connection?.hubUrl, - enabled: connection?.enabled ?? false, - lastConnected: connection?.lastConnected, - pendingMutations: syncService.getPendingCount(), - }); - }); - - router.handle(HUB.SYNC.DATA, async () => { - const syncedCount = await syncService.syncPending(); - if (syncedCount > 0) { - router.emit(HUB_EVENTS.SYNC.COMPLETED, { entities: [], syncedCount }); - } - return { syncedCount, pendingCount: syncService.getPendingCount() }; - }); - - router.handle(HUB.GET.CONFIG, () => { - const connection = connectionManager.getConnection(); - return Promise.resolve({ - hubUrl: connection?.hubUrl, - enabled: connection?.enabled ?? false, - lastConnected: connection?.lastConnected, - }); - }); - - router.handle(HUB.REMOVE.CONFIG, () => { - connectionManager.removeConfig(); - return Promise.resolve({ success: true }); - }); -} diff --git a/src/main/features/hub/hub-sync.ts b/src/main/features/hub/hub-sync.ts deleted file mode 100644 index ca6c3891..00000000 --- a/src/main/features/hub/hub-sync.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Hub Sync Service - * - * Bidirectional data synchronization between local JSON storage - * and the hub backend. When the hub is available, mutations go - * through the hub API. When unavailable, mutations are queued - * locally and synced on reconnect. - */ - -import { eq } from 'drizzle-orm'; - -import { generateId as generateUuid } from '@shared/lib/id'; - -import type { AdcDatabase } from '@main/db'; -import { settingsKv } from '@main/db/schema'; -import { hubLogger } from '@main/lib/logger'; - -import type { HubClient } from './hub-client'; -import type { HubConnectionManager } from './hub-connection'; - -/** A queued mutation to sync when hub reconnects. */ -interface PendingMutation { - id: string; - entity: string; - action: 'create' | 'update' | 'delete'; - data: Record; - timestamp: string; -} - -export interface HubSyncService { - /** Queue a local mutation for syncing to hub. */ - queueMutation: ( - entity: string, - action: PendingMutation['action'], - data: Record, - ) => void; - /** Sync all pending mutations to the hub. Returns number of synced items. */ - syncPending: () => Promise; - /** Get the count of pending (unsynced) mutations. */ - getPendingCount: () => number; - /** Clear all pending mutations. */ - clearPending: () => void; - /** Check if a sync should be attempted (hub available + pending items). */ - shouldSync: () => boolean; -} - -const HUB_SYNC_KEY = 'hub-sync-queue'; - -function loadQueue(db: AdcDatabase): PendingMutation[] { - const rows = db.select().from(settingsKv).where(eq(settingsKv.key, HUB_SYNC_KEY)).all(); - if (rows.length === 0) return []; - try { - const stored = rows[0].settings; - if (Array.isArray(stored)) { - return stored as PendingMutation[]; - } - return []; - } catch { - hubLogger.error('[HubSync] Failed to load sync queue'); - return []; - } -} - -function saveQueue(db: AdcDatabase, queue: PendingMutation[]): void { - const now = new Date().toISOString(); - db.insert(settingsKv) - .values({ id: generateUuid(), key: HUB_SYNC_KEY, settings: queue, updatedAt: now }) - .onConflictDoUpdate({ target: settingsKv.key, set: { settings: queue, updatedAt: now } }) - .run(); -} - -function generateId(): string { - return `${String(Date.now())}-${String(Math.random()).slice(2, 8)}`; -} - -async function syncProjectMutation( - client: HubClient, - action: PendingMutation['action'], - data: Record, -): Promise { - switch (action) { - case 'create': { - const result = await client.createProject(data as { name: string; path: string }); - return result.success; - } - case 'update': { - const result = await client.updateProject(data.id as string, data); - return result.success; - } - case 'delete': { - const result = await client.deleteProject(data.id as string); - return result.success; - } - } -} - -async function syncTaskMutation( - client: HubClient, - action: PendingMutation['action'], - data: Record, -): Promise { - switch (action) { - case 'create': { - const result = await client.createTask( - data as { - project_id: string; - title: string; - description?: string; - status?: string; - priority?: number; - }, - ); - return result.success; - } - case 'update': { - const result = await client.updateTask(data.id as string, data); - return result.success; - } - case 'delete': { - const result = await client.deleteTask(data.id as string); - return result.success; - } - } -} - -async function syncPlannerMutation( - client: HubClient, - action: PendingMutation['action'], - data: Record, -): Promise { - switch (action) { - case 'create': { - const result = await client.createPlannerEvent( - data as { - date: string; - start_time: string; - end_time: string; - title: string; - category?: string; - task_id?: string; - }, - ); - return result.success; - } - case 'update': { - const result = await client.updatePlannerEvent(data.id as string, data); - return result.success; - } - case 'delete': { - const result = await client.deletePlannerEvent(data.id as string); - return result.success; - } - } -} - -async function syncMutation(client: HubClient, mutation: PendingMutation): Promise { - const { entity, action, data } = mutation; - - try { - switch (entity) { - case 'projects': - return await syncProjectMutation(client, action, data); - case 'tasks': - return await syncTaskMutation(client, action, data); - case 'captures': { - if (action === 'create') { - const result = await client.createCapture(data as { text: string; project_id?: string }); - return result.success; - } - const result = await client.deleteCapture(data.id as string); - return result.success; - } - case 'planner_events': - return await syncPlannerMutation(client, action, data); - case 'settings': { - const result = await client.updateSettings(data as Record); - return result.success; - } - default: { - hubLogger.info(`[HubSync] Unknown entity: ${entity}`); - return false; - } - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Sync error'; - hubLogger.error(`[HubSync] Failed to sync ${entity}.${action}:`, message); - return false; - } -} - -export function createHubSyncService( - connectionManager: HubConnectionManager, - db: AdcDatabase, -): HubSyncService { - let queue: PendingMutation[] = loadQueue(db); - - return { - queueMutation(entity, action, data) { - const mutation: PendingMutation = { - id: generateId(), - entity, - action, - data, - timestamp: new Date().toISOString(), - }; - queue.push(mutation); - saveQueue(db, queue); - hubLogger.info(`[HubSync] Queued ${entity}.${action} (${String(queue.length)} pending)`); - }, - - async syncPending() { - if (queue.length === 0) { - return 0; - } - - if (!connectionManager.isAvailable()) { - hubLogger.info('[HubSync] Hub not available, skipping sync'); - return 0; - } - - const client = connectionManager.getClient(); - let syncedCount = 0; - const failedMutations: PendingMutation[] = []; - - for (const mutation of queue) { - const success = await syncMutation(client, mutation); - if (success) { - syncedCount += 1; - } else { - failedMutations.push(mutation); - } - } - - queue = failedMutations; - saveQueue(db, queue); - - hubLogger.info( - `[HubSync] Synced ${String(syncedCount)} mutations, ${String(queue.length)} remaining`, - ); - return syncedCount; - }, - - getPendingCount() { - return queue.length; - }, - - clearPending() { - queue = []; - saveQueue(db, queue); - hubLogger.info('[HubSync] Cleared sync queue'); - }, - - shouldSync() { - return queue.length > 0 && connectionManager.isAvailable(); - }, - }; -} diff --git a/src/main/features/hub/hub-ws-client.ts b/src/main/features/hub/hub-ws-client.ts deleted file mode 100644 index 0f636dd4..00000000 --- a/src/main/features/hub/hub-ws-client.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Hub WebSocket Client - * - * WebSocket connection management with auto-reconnect. - * Handles auth handshake, message parsing, and reconnection scheduling. - */ - -import type { HubConnection } from '@shared/types'; - -import { hubLogger } from '@main/lib/logger'; - -import { routeWebSocketEvent } from './hub-event-mapper'; - -import type { WsEventData } from './hub-event-mapper'; -import type { IpcRouter } from '../../ipc/router'; - -const BASE_RECONNECT_MS = 30_000; -const MAX_RECONNECT_MS = 300_000; - -export interface HubWsClientOptions { - router: IpcRouter; - getConnection: () => HubConnection; - isEnabledAndConnected: () => boolean; - messageListeners: Array<(data: unknown) => void>; - scheduleConnect: () => void; -} - -export interface HubWsClient { - connect: () => void; - disconnect: () => void; - cancelReconnect: () => void; -} - -export function createHubWsClient(options: HubWsClientOptions): HubWsClient { - const { router, getConnection, isEnabledAndConnected, messageListeners, scheduleConnect } = - options; - let wsConnection: WebSocket | null = null; - let reconnectTimerId: ReturnType | null = null; - let reconnectAttempt = 0; - - function getReconnectDelay(): number { - return Math.min(BASE_RECONNECT_MS * 2 ** reconnectAttempt, MAX_RECONNECT_MS); - } - - function scheduleReconnect(): void { - if (reconnectTimerId !== null) { - return; - } - const delay = getReconnectDelay(); - reconnectAttempt += 1; - hubLogger.info(`[Hub] Scheduling reconnect in ${delay / 1000}s (attempt ${reconnectAttempt})`); - reconnectTimerId = setTimeout(() => { - reconnectTimerId = null; - scheduleConnect(); - }, delay); - } - - function cancelReconnect(): void { - if (reconnectTimerId !== null) { - clearTimeout(reconnectTimerId); - reconnectTimerId = null; - } - } - - function disconnect(): void { - if (wsConnection) { - try { - wsConnection.close(); - } catch { - // Already closed - } - wsConnection = null; - } - } - - function connect(): void { - const connection = getConnection(); - const wsUrl = `${connection.hubUrl.replace(/^http/, 'ws')}/ws`; - - try { - wsConnection = new WebSocket(wsUrl); - - wsConnection.addEventListener('open', () => { - reconnectAttempt = 0; - hubLogger.info('[Hub] WebSocket connected, sending auth message'); - // Send auth message as first message (required by hub's first-message auth protocol) - if (wsConnection?.readyState === WebSocket.OPEN) { - const authMessage = JSON.stringify({ - type: 'auth', - apiKey: connection.apiKey, - }); - wsConnection.send(authMessage); - } - }); - - wsConnection.addEventListener('message', (event) => { - try { - const data = JSON.parse(String(event.data)) as WsEventData; - hubLogger.info(`[Hub] WS event: ${data.entity}.${data.action} (${data.id})`); - - // Emit entity-specific IPC events for query invalidation - routeWebSocketEvent(router, data); - - // Forward raw message to registered listeners (e.g. webhook relay) - for (const listener of messageListeners) { - listener(data); - } - } catch { - // Ignore malformed messages - } - }); - - wsConnection.addEventListener('close', () => { - hubLogger.info('[Hub] WebSocket disconnected'); - wsConnection = null; - if (isEnabledAndConnected()) { - scheduleReconnect(); - } - }); - - wsConnection.addEventListener('error', () => { - hubLogger.error('[Hub] WebSocket error'); - wsConnection = null; - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'WebSocket error'; - hubLogger.error('[Hub] Failed to create WebSocket:', message); - } - } - - return { connect, disconnect, cancelReconnect }; -} diff --git a/src/main/features/hub/webhook-relay.ts b/src/main/features/hub/webhook-relay.ts deleted file mode 100644 index ea7918ab..00000000 --- a/src/main/features/hub/webhook-relay.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Webhook Relay — Hub WebSocket to Assistant Service - * - * Listens for webhook_command broadcast messages from the Hub WebSocket - * and forwards them to the assistant service for processing. - * Emits event:webhook.received so the renderer can show a notification. - */ - -import { WEBHOOK_EVENTS } from '@shared/ipc/misc/webhook.channels'; -import type { WebhookCommand } from '@shared/types'; - - -import { hubLogger } from '@main/lib/logger'; - -import type { IpcRouter } from '../../ipc/router'; -import type { AssistantService } from '../assistant/assistant-service'; - -export interface WebhookRelay { - handleHubMessage: (message: unknown) => void; -} - -interface WebhookRelayDeps { - assistantService: AssistantService; - router: IpcRouter; -} - -interface HubBroadcastMessage { - type: string; - entity: string; - action: string; - id: string; - data: unknown; - timestamp: string; -} - -interface HubWebhookCommandData { - source: string; - command_text: string; - source_id?: string; - source_channel?: string; - source_url?: string; - project_id?: string; -} - -function isHubBroadcast(message: unknown): message is HubBroadcastMessage { - if (typeof message !== 'object' || message === null) { - return false; - } - - const msg = message as Record; - return ( - typeof msg.type === 'string' && typeof msg.entity === 'string' && typeof msg.action === 'string' - ); -} - -function isWebhookCommandBroadcast(message: HubBroadcastMessage): boolean { - return message.entity === 'webhook_command' && message.action === 'created'; -} - -function extractWebhookCommand(data: unknown): WebhookCommand | null { - if (typeof data !== 'object' || data === null) { - return null; - } - - const raw = data as HubWebhookCommandData; - const { source } = raw; - - if (source !== 'slack' && source !== 'github') { - return null; - } - - const commandText = raw.command_text; - if (typeof commandText !== 'string' || commandText.length === 0) { - return null; - } - - return { - source, - commandText, - sourceContext: { - channelName: typeof raw.source_channel === 'string' ? raw.source_channel : undefined, - permalink: typeof raw.source_url === 'string' ? raw.source_url : undefined, - }, - }; -} - -export function createWebhookRelay(deps: WebhookRelayDeps): WebhookRelay { - const { assistantService, router } = deps; - - return { - handleHubMessage(message: unknown): void { - try { - if (!isHubBroadcast(message)) { - return; - } - - if (!isWebhookCommandBroadcast(message)) { - return; - } - - const command = extractWebhookCommand(message.data); - if (!command) { - hubLogger.warn('[WebhookRelay] Could not extract command from broadcast data'); - return; - } - - hubLogger.info( - `[WebhookRelay] Received ${command.source} webhook: "${command.commandText.slice(0, 80)}"`, - ); - - // Emit event for renderer notification - router.emit(WEBHOOK_EVENTS.COMMAND.RECEIVED, { - source: command.source, - commandText: command.commandText, - sourceContext: { - channelName: command.sourceContext.channelName ?? '', - permalink: command.sourceContext.permalink ?? '', - }, - timestamp: new Date().toISOString(), - }); - - // Fire-and-forget: process the command via assistant service - assistantService.sendCommand(command.commandText); - } catch (error) { - const message2 = error instanceof Error ? error.message : 'Unknown error'; - hubLogger.error('[WebhookRelay] Error handling hub message:', message2); - } - }, - }; -} diff --git a/src/main/features/ideas/ideas-handlers.ts b/src/main/features/ideas/ideas-handlers.ts index c83b4364..a8ffc9bd 100644 --- a/src/main/features/ideas/ideas-handlers.ts +++ b/src/main/features/ideas/ideas-handlers.ts @@ -2,7 +2,7 @@ * Ideas IPC handlers */ -import { IDEAS } from '@shared/ipc/misc/ideas.channels'; +import { IDEAS } from '@shared/ipc/ideas'; import type { IdeasService } from "./ideas-service"; import type { IpcRouter } from '../../ipc/router'; diff --git a/src/main/features/ideas/ideas-service.ts b/src/main/features/ideas/ideas-service.ts index eef9f1b9..5e00f11f 100644 --- a/src/main/features/ideas/ideas-service.ts +++ b/src/main/features/ideas/ideas-service.ts @@ -10,7 +10,7 @@ import { join } from 'node:path'; import { and, desc, eq } from 'drizzle-orm'; -import { IDEAS_EVENTS } from '@shared/ipc/misc/ideas.channels'; +import { IDEAS_EVENTS } from '@shared/ipc/ideas'; import { generateId } from '@shared/lib/id'; import type { Idea, IdeaCategory, IdeaStatus } from '@shared/types'; diff --git a/src/main/features/insights/insights-handlers.ts b/src/main/features/insights/insights-handlers.ts index e8b7232f..4cdff2b7 100644 --- a/src/main/features/insights/insights-handlers.ts +++ b/src/main/features/insights/insights-handlers.ts @@ -2,7 +2,7 @@ * Insights IPC handlers */ -import { INSIGHTS } from '@shared/ipc/misc/insights.channels'; +import { INSIGHTS } from '@shared/ipc/insights'; import type { InsightsService } from "./insights-service"; import type { IpcRouter } from '../../ipc/router'; diff --git a/src/main/features/insights/insights-service.ts b/src/main/features/insights/insights-service.ts index f67a23ce..0c85dde7 100644 --- a/src/main/features/insights/insights-service.ts +++ b/src/main/features/insights/insights-service.ts @@ -15,7 +15,7 @@ import type { ProgressTask } from '@shared/types/progress'; import type { BusSessionManager } from '../../bus/session-manager'; import type { ProgressService } from '../progress/progress-service'; -import type { ProjectService } from '../project/project-service'; +import type { ProjectService } from '../projects/project-service'; import type { QaRunner } from '../qa/qa-types'; export interface InsightsService { diff --git a/src/main/features/integrations/calendar.ts b/src/main/features/integrations/calendar.ts index 21b16f58..ab5e39fa 100644 --- a/src/main/features/integrations/calendar.ts +++ b/src/main/features/integrations/calendar.ts @@ -5,7 +5,7 @@ * Maps raw API responses to the IPC contract shapes. */ -import { CALENDAR } from '@shared/ipc/misc/calendar.channels'; +import { CALENDAR } from '@shared/ipc/calendar'; import { createCalendarClient } from '../../mcp-servers/calendar/calendar-client'; diff --git a/src/main/features/integrations/github-integration.ts b/src/main/features/integrations/github-integration.ts deleted file mode 100644 index 0d8b8c28..00000000 --- a/src/main/features/integrations/github-integration.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * GitHub Integration — re-export from github service and handlers. - * - * Service wraps the gh CLI client for GitHub API access. - * Maps raw API responses to the shared GitHubPullRequest / GitHubIssue / GitHubNotification types. - */ - -import type { PrDiffFile } from '@shared/ipc/github'; -import { GITHUB, GITHUB_EVENTS } from '@shared/ipc/github/channels'; -import type { GitHubIssue, GitHubNotification, GitHubPullRequest } from '@shared/types'; - -import type { IpcRouter } from '../../ipc/router'; -import type { - GitHubAuthStatus, - GitHubClient, - GitHubRepo, -} from '../../mcp-servers/github/github-client'; -import type { Issue, Notification, PullRequest } from '../../mcp-servers/github/types'; - -// ── Interface ───────────────────────────────────────────────── - -export interface GitHubService { - getAuthStatus: () => Promise; - - getRepos: (params?: { limit?: number }) => Promise; - - listPrs: (params: { - owner: string; - repo: string; - state?: 'open' | 'closed' | 'all'; - }) => Promise; - - getPr: (params: { owner: string; repo: string; number: number }) => Promise; - - getPrFiles: (params: { owner: string; repo: string; number: number }) => Promise; - - listIssues: (params: { - owner: string; - repo: string; - state?: 'open' | 'closed' | 'all'; - }) => Promise; - - createIssue: (params: { - owner: string; - repo: string; - title: string; - body?: string; - labels?: string[]; - assignees?: string[]; - }) => Promise; - - getNotifications: (params?: { all?: boolean }) => Promise; -} - -// ── Mappers ─────────────────────────────────────────────────── - -function mapPr(raw: PullRequest): GitHubPullRequest { - return { - id: raw.id, - number: raw.number, - title: raw.title, - body: raw.body ?? '', - state: raw.state, - merged: raw.merged, - draft: raw.draft, - author: raw.user.login, - authorAvatar: raw.user.avatar_url, - url: raw.html_url, - createdAt: raw.created_at, - updatedAt: raw.updated_at, - headBranch: raw.head.ref, - baseBranch: raw.base.ref, - labels: raw.labels.map((l) => ({ name: l.name, color: l.color })), - reviewers: raw.requested_reviewers.map((r) => r.login), - comments: raw.review_comments, - additions: raw.additions, - deletions: raw.deletions, - changedFiles: raw.changed_files, - }; -} - -function mapIssue(raw: Issue): GitHubIssue { - return { - id: raw.id, - number: raw.number, - title: raw.title, - body: raw.body ?? '', - state: raw.state, - author: raw.user.login, - authorAvatar: raw.user.avatar_url, - url: raw.html_url, - labels: raw.labels.map((l) => ({ name: l.name, color: l.color })), - assignees: raw.assignees.map((a) => a.login), - comments: raw.comments, - createdAt: raw.created_at, - updatedAt: raw.updated_at, - }; -} - -function mapNotification(raw: Notification): GitHubNotification { - return { - id: raw.id, - unread: raw.unread, - reason: raw.reason, - title: raw.subject.title, - type: raw.subject.type, - repoName: raw.repository.full_name, - updatedAt: raw.updated_at, - }; -} - -// ── Factory ─────────────────────────────────────────────────── - -export function createGitHubService(deps: { - client: GitHubClient; - router: IpcRouter; -}): GitHubService { - const { client, router } = deps; - - function emitUpdated(type: 'pr' | 'issue' | 'notification', owner: string, repo: string): void { - router.emit(GITHUB_EVENTS.DATA.UPDATED, { type, owner, repo }); - } - - return { - async getAuthStatus() { - return await client.getAuthStatus(); - }, - - async getRepos(params) { - return await client.getRepos(params); - }, - - async listPrs(params) { - const raw = await client.listPrs(params); - return raw.map(mapPr); - }, - - async getPr(params) { - const raw = await client.getPr(params); - return mapPr(raw); - }, - - async getPrFiles(params) { - return await client.getPrFiles(params); - }, - - async listIssues(params) { - const raw = await client.listIssues(params); - return raw.map(mapIssue); - }, - - async createIssue(params) { - const raw = await client.createIssue(params); - emitUpdated('issue', params.owner, params.repo); - return mapIssue(raw); - }, - - async getNotifications(params) { - const raw = await client.getNotifications(params); - return raw.map(mapNotification); - }, - }; -} - -// ── Handlers ────────────────────────────────────────────────── - -export function registerGitHubHandlers(router: IpcRouter, service: GitHubService): void { - router.handle(GITHUB.GET['AUTH-STATUS'], async () => { - return await service.getAuthStatus(); - }); - - router.handle(GITHUB.LIST.REPOS, async (params) => { - return await service.getRepos(params); - }); - - router.handle(GITHUB.LIST.PRS, async (params) => { - return await service.listPrs(params); - }); - - router.handle(GITHUB.GET.PR, async (params) => { - return await service.getPr(params); - }); - - router.handle(GITHUB.GET.PR_FILES, async (params) => { - return await service.getPrFiles(params); - }); - - router.handle(GITHUB.LIST.ISSUES, async (params) => { - return await service.listIssues(params); - }); - - router.handle(GITHUB.CREATE.ISSUE, async (params) => { - return await service.createIssue(params); - }); - - router.handle(GITHUB.GET.NOTIFICATIONS, async (params) => { - return await service.getNotifications(params); - }); -} diff --git a/src/main/features/integrations/integrations-handlers.ts b/src/main/features/integrations/integrations-handlers.ts index fb1d9139..490106a8 100644 --- a/src/main/features/integrations/integrations-handlers.ts +++ b/src/main/features/integrations/integrations-handlers.ts @@ -1,14 +1,13 @@ /** - * Integrations IPC Handlers — Registers all integration domain handlers. + * Integrations IPC Handlers — Registers remaining integration domain handlers. * - * Delegates to: email, notifications, spotify, github, calendar sub-modules. + * Delegates to: email, notifications, calendar sub-modules. */ +import { registerEmailHandlers } from '../email/email-handlers'; +import { registerNotificationHandlers } from '../notifications/notification-handlers'; + import { registerCalendarHandlers } from './calendar'; -import { registerEmailHandlers } from './email/email-handlers'; -import { registerGitHubHandlers } from './github-integration'; -import { registerNotificationHandlers } from './notifications/notification-handlers'; -import { registerSpotifyHandlers } from './spotify'; import type { IntegrationsService } from './integrations-service'; import type { IpcRouter } from '../../ipc/router'; @@ -19,7 +18,5 @@ export function registerIntegrationsHandlers( ): void { registerCalendarHandlers(router, service.calendar); registerEmailHandlers(router, service.email); - registerGitHubHandlers(router, service.github); registerNotificationHandlers(router, service.notifications); - registerSpotifyHandlers(router, service.spotify); } diff --git a/src/main/features/integrations/integrations-service.ts b/src/main/features/integrations/integrations-service.ts index 5d419ea2..e958d7f9 100644 --- a/src/main/features/integrations/integrations-service.ts +++ b/src/main/features/integrations/integrations-service.ts @@ -1,33 +1,29 @@ /** - * Integrations Service — Unified factory for all integration sub-domains. + * Integrations Service — Unified factory for remaining integration sub-domains. * - * Consolidates: email, notifications, spotify, github, calendar - * into a single service registry entry. + * Consolidates: email, notifications, calendar + * into a single service registry entry. Spotify and GitHub have been extracted + * to their own top-level feature folders (`src/main/features/spotify/`, + * `src/main/features/github/`). */ +import { createEmailService } from '../email/email-service'; +import { createGitHubWatcher, createNotificationManager, createSlackWatcher } from '../notifications'; + import { createCalendarService } from './calendar'; -import { createEmailService } from './email/email-service'; -import { createGitHubService } from './github-integration'; -import { createGitHubWatcher, createNotificationManager, createSlackWatcher } from './notifications'; -import { createSpotifyService } from './spotify'; import type { CalendarService } from './calendar'; -import type { EmailService } from './email/email-service'; -import type { GitHubService } from './github-integration'; -import type { NotificationManager } from './notifications'; -import type { SpotifyService } from './spotify'; import type { OAuthManager } from '../../auth/oauth-manager'; import type { AdcDatabase } from '../../db'; import type { IpcRouter } from '../../ipc/router'; -import type { GitHubClient } from '../../mcp-servers/github/github-client'; +import type { EmailService } from '../email/email-service'; +import type { NotificationManager } from '../notifications'; // ── Interface ───────────────────────────────────────────────── export interface IntegrationsService { email: EmailService; notifications: NotificationManager; - spotify: SpotifyService; - github: GitHubService; calendar: CalendarService; } @@ -38,13 +34,12 @@ export interface IntegrationsServiceDeps { dataDir: string; router: IpcRouter; oauthManager: OAuthManager; - githubCliClient: GitHubClient; } // ── Factory ─────────────────────────────────────────────────── export function createIntegrationsService(deps: IntegrationsServiceDeps): IntegrationsService { - const { db, dataDir, router, oauthManager, githubCliClient } = deps; + const { db, dataDir, router, oauthManager } = deps; const email = createEmailService({ db, dataDir, router }); @@ -68,9 +63,7 @@ export function createIntegrationsService(deps: IntegrationsServiceDeps): Integr return mgr; })(); - const spotify = createSpotifyService({ oauthManager }); - const github = createGitHubService({ client: githubCliClient, router }); const calendar = createCalendarService({ oauthManager }); - return { email, notifications, spotify, github, calendar }; + return { email, notifications, calendar }; } diff --git a/src/main/features/integrations/schema.ts b/src/main/features/integrations/schema.ts index e5f7f48d..b82b3466 100644 --- a/src/main/features/integrations/schema.ts +++ b/src/main/features/integrations/schema.ts @@ -2,5 +2,5 @@ * Integrations Schema Barrel — re-exports all integration domain tables. */ -export { emailQueue } from './email/schema'; -export { notifications } from './notifications/schema'; +export { emailQueue } from '../email/schema'; +export { notifications } from '../notifications/schema'; diff --git a/src/main/features/integrations/spotify.ts b/src/main/features/integrations/spotify.ts deleted file mode 100644 index 63f8ef5d..00000000 --- a/src/main/features/integrations/spotify.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Spotify Integration — service and IPC handlers. - * - * Service wraps the Spotify client with OAuth token management. - * Maps raw API responses to the IPC contract shapes. - */ - -import { SPOTIFY } from '@shared/ipc/spotify/channels'; - -import { createSpotifyClient } from '../../mcp-servers/spotify/spotify-client'; - -import type { OAuthManager } from '../../auth/oauth-manager'; -import type { IpcRouter } from '../../ipc/router'; - -// ── Interface ───────────────────────────────────────────────── - -export interface SpotifyService { - getPlayback: () => Promise<{ - isPlaying: boolean; - track?: string; - artist?: string; - album?: string; - albumArt?: string; - progressMs?: number; - durationMs?: number; - device?: string; - volume?: number; - } | null>; - - play: (params: { uri?: string }) => Promise<{ success: boolean }>; - pause: () => Promise<{ success: boolean }>; - next: () => Promise<{ success: boolean }>; - previous: () => Promise<{ success: boolean }>; - search: (params: { query: string; limit?: number }) => Promise< - Array<{ - name: string; - artist: string; - album: string; - uri: string; - durationMs: number; - }> - >; - setVolume: (params: { volumePercent: number }) => Promise<{ success: boolean }>; - addToQueue: (params: { uri: string }) => Promise<{ success: boolean }>; -} - -// ── Factory ─────────────────────────────────────────────────── - -const SPOTIFY_PROVIDER = 'spotify'; - -export function createSpotifyService(deps: { oauthManager: OAuthManager }): SpotifyService { - const { oauthManager } = deps; - - async function getClient() { - const token = await oauthManager.getAccessToken(SPOTIFY_PROVIDER); - return createSpotifyClient(token); - } - - return { - async getPlayback() { - const client = await getClient(); - const state = await client.getPlaybackState(); - if (!state) { - return null; - } - return { - isPlaying: state.is_playing, - track: state.item?.name, - artist: state.item?.artists.map((a) => a.name).join(', '), - album: state.item?.album.name, - albumArt: state.item?.album.images[0]?.url, - progressMs: state.progress_ms ?? undefined, - durationMs: state.item?.duration_ms, - device: state.device.name, - volume: state.device.volume_percent ?? undefined, - }; - }, - - async play(params) { - const client = await getClient(); - return await client.play({ uri: params.uri }); - }, - - async pause() { - const client = await getClient(); - return await client.pause(); - }, - - async next() { - const client = await getClient(); - return await client.next(); - }, - - async previous() { - const client = await getClient(); - return await client.previous(); - }, - - async search(params) { - const client = await getClient(); - const tracks = await client.search(params); - return tracks.map((t) => ({ - name: t.name, - artist: t.artists.map((a) => a.name).join(', '), - album: t.album.name, - uri: t.uri, - durationMs: t.duration_ms, - })); - }, - - async setVolume(params) { - const client = await getClient(); - return await client.setVolume(params); - }, - - async addToQueue(params) { - const client = await getClient(); - return await client.addToQueue(params); - }, - }; -} - -// ── Handlers ────────────────────────────────────────────────── - -export function registerSpotifyHandlers(router: IpcRouter, service: SpotifyService): void { - router.handle(SPOTIFY.GET.PLAYBACK, async () => { - return await service.getPlayback(); - }); - - router.handle(SPOTIFY.PLAY.TRACK, async (params) => { - return await service.play(params); - }); - - router.handle(SPOTIFY.PAUSE.TRACK, async () => { - return await service.pause(); - }); - - router.handle(SPOTIFY.SKIP.NEXT, async () => { - return await service.next(); - }); - - router.handle(SPOTIFY.SKIP.PREVIOUS, async () => { - return await service.previous(); - }); - - router.handle(SPOTIFY.SEARCH.TRACKS, async (params) => { - return await service.search(params); - }); - - router.handle(SPOTIFY.SET.VOLUME, async (params) => { - return await service.setVolume(params); - }); - - router.handle(SPOTIFY.ADD['TO-QUEUE'], async (params) => { - return await service.addToQueue(params); - }); -} diff --git a/src/main/features/mcp/mcp-handlers.ts b/src/main/features/mcp/mcp-handlers.ts index 4c1dc10f..0b6154ea 100644 --- a/src/main/features/mcp/mcp-handlers.ts +++ b/src/main/features/mcp/mcp-handlers.ts @@ -4,7 +4,7 @@ * Exposes MCP tool calls to the renderer process. */ -import { MCP } from '@shared/ipc/misc/mcp.channels'; +import { MCP } from '@shared/ipc/mcp'; import type { IpcRouter } from '../../ipc/router'; import type { McpManager } from '../../mcp/mcp-manager'; diff --git a/src/main/features/merge/merge-handlers.ts b/src/main/features/merge/merge-handlers.ts index 920e1e04..1ab5b450 100644 --- a/src/main/features/merge/merge-handlers.ts +++ b/src/main/features/merge/merge-handlers.ts @@ -4,7 +4,7 @@ * All merge operations are async (git commands). */ -import { MERGE } from '@shared/ipc/misc/merge.channels'; +import { MERGE } from '@shared/ipc/merge'; import type { MergeService } from "./merge-service"; import type { IpcRouter } from '../../ipc/router'; diff --git a/src/main/features/milestones/milestones-handlers.ts b/src/main/features/milestones/milestones-handlers.ts deleted file mode 100644 index 3eef3713..00000000 --- a/src/main/features/milestones/milestones-handlers.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Milestones IPC handlers - */ - -import { MILESTONES } from '@shared/ipc/misc/milestones.channels'; - -import type { MilestonesService } from "./milestones-service"; -import type { IpcRouter } from '../../ipc/router'; - -export function registerMilestonesHandlers(router: IpcRouter, service: MilestonesService): void { - router.handle(MILESTONES.LIST.ALL, (filters) => Promise.resolve(service.listMilestones(filters))); - - router.handle(MILESTONES.CREATE.MILESTONE, (data) => Promise.resolve(service.createMilestone(data))); - - router.handle(MILESTONES.UPDATE.MILESTONE, ({ id, ...updates }) => - Promise.resolve(service.updateMilestone(id, updates)), - ); - - router.handle(MILESTONES.DELETE.MILESTONE, ({ id }) => Promise.resolve(service.deleteMilestone(id))); - - router.handle(MILESTONES.ADD.TASK, ({ milestoneId, title }) => - Promise.resolve(service.addTask(milestoneId, title)), - ); - - router.handle(MILESTONES.TOGGLE.TASK, ({ milestoneId, taskId }) => - Promise.resolve(service.toggleTask(milestoneId, taskId)), - ); -} diff --git a/src/main/features/milestones/milestones-service.ts b/src/main/features/milestones/milestones-service.ts deleted file mode 100644 index 794916a6..00000000 --- a/src/main/features/milestones/milestones-service.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Milestones Service — SQLite-backed roadmap milestones - * - * Milestones are stored in the `milestones` SQLite table. - * One-time migration from milestones.json on first access. - */ - -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { asc, eq } from 'drizzle-orm'; - -import { MILESTONES_EVENTS } from '@shared/ipc/misc/milestones.channels'; -import { generateId } from '@shared/lib/id'; -import type { Milestone, MilestoneStatus } from '@shared/types'; - -import { milestones } from '../../db/schema'; -import { createScopedLogger } from '../../lib/logger'; - -import type { AdcDatabase } from '../../db'; -import type { IpcRouter } from '../../ipc/router'; - -const logger = createScopedLogger('milestones-service'); - -export interface MilestonesService { - listMilestones: (filters: { projectId?: string }) => Milestone[]; - createMilestone: (data: { - id?: string; - title: string; - description: string; - targetDate: string; - projectId?: string; - }) => Milestone; - updateMilestone: ( - id: string, - updates: { - title?: string; - description?: string; - targetDate?: string; - status?: MilestoneStatus; - tasks?: Array<{ id: string; title: string; completed: boolean }>; - }, - ) => Milestone; - deleteMilestone: (id: string) => { success: boolean }; - addTask: (milestoneId: string, title: string) => Milestone; - toggleTask: (milestoneId: string, taskId: string) => Milestone; -} - -function migrateFromJson(db: AdcDatabase, dataDir: string): void { - const existing = db.select().from(milestones).limit(1).all(); - if (existing.length > 0) return; - - const jsonPath = join(dataDir, 'milestones.json'); - if (!existsSync(jsonPath)) return; - - try { - const raw = readFileSync(jsonPath, 'utf-8'); - const parsed = JSON.parse(raw) as { milestones?: Milestone[] }; - const items = Array.isArray(parsed.milestones) ? parsed.milestones : []; - - for (const item of items) { - db.insert(milestones).values({ - id: item.id, - title: item.title, - description: item.description, - targetDate: item.targetDate, - status: item.status, - tasks: item.tasks, - projectId: item.projectId ?? null, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - }).run(); - } - logger.info(`Migrated ${String(items.length)} milestones from JSON to SQLite`); - } catch (err) { - logger.error('Failed to migrate milestones from JSON:', err); - } -} - -function toMilestone(row: typeof milestones.$inferSelect): Milestone { - return { - id: row.id, - title: row.title, - description: row.description, - targetDate: row.targetDate, - status: row.status as MilestoneStatus, - tasks: row.tasks, - projectId: row.projectId ?? undefined, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -export function createMilestonesService(deps: { - db: AdcDatabase; - router: IpcRouter; - dataDir: string; -}): MilestonesService { - const { db, router, dataDir } = deps; - - migrateFromJson(db, dataDir); - - function emitChanged(milestoneId: string): void { - router.emit(MILESTONES_EVENTS.MILESTONE.CHANGED, { milestoneId }); - } - - function findMilestone(id: string): typeof milestones.$inferSelect { - const rows = db.select().from(milestones).where(eq(milestones.id, id)).all(); - const row = rows.at(0); - if (row === undefined) { - throw new Error(`Milestone not found: ${id}`); - } - return row; - } - - return { - listMilestones(filters) { - let rows = db.select().from(milestones) - .orderBy(asc(milestones.targetDate)) - .all(); - - if (filters.projectId) { - rows = rows.filter((r) => r.projectId === filters.projectId); - } - - return rows.map(toMilestone); - }, - - createMilestone(data) { - const now = new Date().toISOString(); - const record = { - id: data.id ?? generateId(), - title: data.title, - description: data.description, - targetDate: data.targetDate, - status: 'planned' as const, - tasks: [] as Array<{ id: string; title: string; completed: boolean }>, - projectId: data.projectId ?? null, - createdAt: now, - updatedAt: now, - }; - db.insert(milestones).values(record).run(); - emitChanged(record.id); - return toMilestone(record); - }, - - updateMilestone(id, updates) { - const existing = findMilestone(id); - const updated = { - ...existing, - ...(updates.title === undefined ? {} : { title: updates.title }), - ...(updates.description === undefined ? {} : { description: updates.description }), - ...(updates.targetDate === undefined ? {} : { targetDate: updates.targetDate }), - ...(updates.status === undefined ? {} : { status: updates.status }), - ...(updates.tasks === undefined ? {} : { tasks: updates.tasks }), - updatedAt: new Date().toISOString(), - }; - db.update(milestones).set(updated).where(eq(milestones.id, id)).run(); - emitChanged(id); - return toMilestone(updated); - }, - - deleteMilestone(id) { - const result = db.delete(milestones).where(eq(milestones.id, id)).run(); - if (result.changes === 0) { - throw new Error(`Milestone not found: ${id}`); - } - emitChanged(id); - return { success: true }; - }, - - addTask(milestoneId, title) { - const existing = findMilestone(milestoneId); - const task = { id: generateId(), title, completed: false }; - const updatedTasks = [...existing.tasks, task]; - const now = new Date().toISOString(); - db.update(milestones) - .set({ tasks: updatedTasks, updatedAt: now }) - .where(eq(milestones.id, milestoneId)) - .run(); - emitChanged(milestoneId); - return toMilestone({ ...existing, tasks: updatedTasks, updatedAt: now }); - }, - - toggleTask(milestoneId, taskId) { - const existing = findMilestone(milestoneId); - const updatedTasks = existing.tasks.map((t) => - t.id === taskId ? { ...t, completed: !t.completed } : t, - ); - const now = new Date().toISOString(); - db.update(milestones) - .set({ tasks: updatedTasks, updatedAt: now }) - .where(eq(milestones.id, milestoneId)) - .run(); - emitChanged(milestoneId); - return toMilestone({ ...existing, tasks: updatedTasks, updatedAt: now }); - }, - }; -} diff --git a/src/main/features/milestones/schema.ts b/src/main/features/milestones/schema.ts deleted file mode 100644 index 5a054946..00000000 --- a/src/main/features/milestones/schema.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'; - -export const milestones = sqliteTable('milestones', { - id: text('id').primaryKey(), - title: text('title').notNull(), - description: text('description').notNull(), - targetDate: text('target_date').notNull(), - status: text('status').notNull(), // 'planned' | 'in-progress' | 'completed' - tasks: text('tasks', { mode: 'json' }).$type>().notNull(), - projectId: text('project_id'), - createdAt: text('created_at').notNull(), - updatedAt: text('updated_at').notNull(), -}, (table) => [ - index('idx_milestones_project_id').on(table.projectId), -]); diff --git a/src/main/features/notes/notes-handlers.ts b/src/main/features/notes/notes-handlers.ts index d71e013f..1e5f4e32 100644 --- a/src/main/features/notes/notes-handlers.ts +++ b/src/main/features/notes/notes-handlers.ts @@ -2,7 +2,7 @@ * Notes IPC handlers */ -import { NOTES } from '@shared/ipc/misc/notes.channels'; +import { NOTES } from '@shared/ipc/notes'; import type { NotesService } from "./notes-service"; import type { IpcRouter } from '../../ipc/router'; diff --git a/src/main/features/notes/notes-service.ts b/src/main/features/notes/notes-service.ts index 281bc495..b90a81f3 100644 --- a/src/main/features/notes/notes-service.ts +++ b/src/main/features/notes/notes-service.ts @@ -10,7 +10,7 @@ import { join } from 'node:path'; import { desc, eq, like, or } from 'drizzle-orm'; -import { NOTES_EVENTS } from '@shared/ipc/misc/notes.channels'; +import { NOTES_EVENTS } from '@shared/ipc/notes'; import { generateId } from '@shared/lib/id'; import type { Note } from '@shared/types'; diff --git a/src/main/features/integrations/notifications/github-watcher.ts b/src/main/features/notifications/github-watcher.ts similarity index 98% rename from src/main/features/integrations/notifications/github-watcher.ts rename to src/main/features/notifications/github-watcher.ts index 6a3b99e3..58ed69f9 100644 --- a/src/main/features/integrations/notifications/github-watcher.ts +++ b/src/main/features/notifications/github-watcher.ts @@ -22,11 +22,11 @@ import type { import { watcherLogger } from '@main/lib/logger'; -import { createGitHubCliClient } from '../../../mcp-servers/github/github-client'; +import { createGitHubCliClient } from '../../mcp-servers/github/github-client'; import type { NotificationManager, NotificationWatcher } from './notification-watcher'; -import type { IpcRouter } from '../../../ipc/router'; -import type { Notification as GitHubApiNotification } from '../../../mcp-servers/github/types'; +import type { IpcRouter } from '../../ipc/router'; +import type { Notification as GitHubApiNotification } from '../../mcp-servers/github/types'; // ── Types ──────────────────────────────────────────────────── diff --git a/src/main/features/integrations/notifications/index.ts b/src/main/features/notifications/index.ts similarity index 100% rename from src/main/features/integrations/notifications/index.ts rename to src/main/features/notifications/index.ts diff --git a/src/main/features/integrations/notifications/notification-filter.ts b/src/main/features/notifications/notification-filter.ts similarity index 100% rename from src/main/features/integrations/notifications/notification-filter.ts rename to src/main/features/notifications/notification-filter.ts diff --git a/src/main/features/integrations/notifications/notification-handlers.ts b/src/main/features/notifications/notification-handlers.ts similarity index 96% rename from src/main/features/integrations/notifications/notification-handlers.ts rename to src/main/features/notifications/notification-handlers.ts index 9478f6c1..47827b2f 100644 --- a/src/main/features/integrations/notifications/notification-handlers.ts +++ b/src/main/features/notifications/notification-handlers.ts @@ -7,7 +7,7 @@ import { NOTIFICATIONS } from '@shared/ipc/notifications/channels'; import type { NotificationManager } from "."; -import type { IpcRouter } from '../../../ipc/router'; +import type { IpcRouter } from '../../ipc/router'; export function registerNotificationHandlers( router: IpcRouter, diff --git a/src/main/features/integrations/notifications/notification-manager.ts b/src/main/features/notifications/notification-manager.ts similarity index 98% rename from src/main/features/integrations/notifications/notification-manager.ts rename to src/main/features/notifications/notification-manager.ts index 7d59fbdd..fd204598 100644 --- a/src/main/features/integrations/notifications/notification-manager.ts +++ b/src/main/features/notifications/notification-manager.ts @@ -22,8 +22,8 @@ import { matchesFilter } from './notification-filter'; import { createNotificationStore } from './notification-store'; import type { NotificationConfigUpdate } from './notification-filter'; -import type { AdcDatabase } from '../../../db'; -import type { IpcRouter } from '../../../ipc/router'; +import type { AdcDatabase } from '../../db'; +import type { IpcRouter } from '../../ipc/router'; // ── Types ──────────────────────────────────────────────────── diff --git a/src/main/features/integrations/notifications/notification-store.ts b/src/main/features/notifications/notification-store.ts similarity index 98% rename from src/main/features/integrations/notifications/notification-store.ts rename to src/main/features/notifications/notification-store.ts index f9a2642b..dc5a8823 100644 --- a/src/main/features/integrations/notifications/notification-store.ts +++ b/src/main/features/notifications/notification-store.ts @@ -19,10 +19,10 @@ import { DEFAULT_SLACK_WATCHER_CONFIG, } from '@shared/types'; -import { notifications, settingsKv } from '../../../db/schema'; -import { createScopedLogger } from '../../../lib/logger'; +import { notifications, settingsKv } from '../../db/schema'; +import { createScopedLogger } from '../../lib/logger'; -import type { AdcDatabase } from '../../../db'; +import type { AdcDatabase } from '../../db'; // ── Constants ──────────────────────────────────────────────── diff --git a/src/main/features/integrations/notifications/notification-watcher.ts b/src/main/features/notifications/notification-watcher.ts similarity index 100% rename from src/main/features/integrations/notifications/notification-watcher.ts rename to src/main/features/notifications/notification-watcher.ts diff --git a/src/main/features/integrations/notifications/schema.ts b/src/main/features/notifications/schema.ts similarity index 100% rename from src/main/features/integrations/notifications/schema.ts rename to src/main/features/notifications/schema.ts diff --git a/src/main/features/integrations/notifications/slack-watcher.ts b/src/main/features/notifications/slack-watcher.ts similarity index 97% rename from src/main/features/integrations/notifications/slack-watcher.ts rename to src/main/features/notifications/slack-watcher.ts index 186ac390..e72d4c43 100644 --- a/src/main/features/integrations/notifications/slack-watcher.ts +++ b/src/main/features/notifications/slack-watcher.ts @@ -22,12 +22,12 @@ import type { import { watcherLogger } from '@main/lib/logger'; -import { createSlackClient } from '../../../mcp-servers/slack/slack-client'; +import { createSlackClient } from '../../mcp-servers/slack/slack-client'; import type { NotificationManager, NotificationWatcher } from './notification-watcher'; -import type { OAuthManager } from '../../../auth/oauth-manager'; -import type { IpcRouter } from '../../../ipc/router'; -import type { SlackMessage } from '../../../mcp-servers/slack/types'; +import type { OAuthManager } from '../../auth/oauth-manager'; +import type { IpcRouter } from '../../ipc/router'; +import type { SlackMessage } from '../../mcp-servers/slack/types'; // ── Types ──────────────────────────────────────────────────── diff --git a/src/main/features/peers/gc-watermark.ts b/src/main/features/peers/gc-watermark.ts new file mode 100644 index 00000000..15395255 --- /dev/null +++ b/src/main/features/peers/gc-watermark.ts @@ -0,0 +1,41 @@ +import { hlcWallCounterPrefix } from '@shared/replication/hlc'; + +/** + * Per-peer observation row used to compute the GC watermark. + */ +export interface ObservedPeer { + peerId: string; + revokedAt: number | null; + lastSeenHlc: string | null; +} + +/** + * Compute the op-log GC watermark from per-peer observations. + * + * Returns the lex-min `wall.counter` prefix across all *active* (non-revoked) + * peers, or `null` if any active peer has never been seen (in which case GC + * is unsafe — a still-pending peer might miss ops we delete). + * + * The peerIdShort suffix is stripped before comparing because two peers at the + * same wall+counter but different shorts would otherwise yield different + * mins, and the resulting GC `DELETE FROM op_log WHERE hlc < watermark` would + * delete ops authored by peers whose suffix is lex-greater than the watermark + * suffix — those ops are not yet known to that peer. + * + * Audit reference: tmp/audit/03-replication.md C4 + C5. + */ +export function gcWatermarkFromObserved( + observed: readonly ObservedPeer[], +): string | null { + const active = observed.filter((p) => p.revokedAt === null); + if (active.length === 0) return null; + const seen: string[] = []; + for (const p of active) { + if (p.lastSeenHlc === null) { + // At least one active peer has never been seen — refuse to GC. + return null; + } + seen.push(hlcWallCounterPrefix(p.lastSeenHlc)); + } + return seen.reduce((min, h) => (h < min ? h : min)); +} diff --git a/src/main/features/peers/hello-verify.ts b/src/main/features/peers/hello-verify.ts new file mode 100644 index 00000000..36f1d06a --- /dev/null +++ b/src/main/features/peers/hello-verify.ts @@ -0,0 +1,101 @@ +import { createPublicKey, verify as edVerify } from 'node:crypto'; + +import type { PeerStore } from './peer-store'; + +/** + * Signed HELLO frame payload — sent by the dialer immediately after WS open + * and verified by the inbound `wss` against `peerStore.getByPeerId(peerId).pubkey`. + * + * The signature is computed over the byte sequence: + * + * nonce_bytes (raw, base64-decoded) || schemaHash_utf8 || peerId_utf8 + * + * `helloPayloadBytes` is the SINGLE source of truth for that ordering — both + * the signer and verifier go through it. Do not duplicate the concatenation. + */ +export interface HelloPayload { + peerId: string; + /** ADC schema hash (utf8 string, hex). */ + schemaHash: string; + /** Random nonce, base64 (32 bytes recommended). */ + nonce: string; + /** Ed25519 signature, base64. */ + sig: string; +} + +export type HelloVerifyResult = + | { ok: true } + | { ok: false; reason: 'unknown_peer' | 'bad_signature' | 'revoked' }; + +// Ed25519 SubjectPublicKeyInfo (SPKI) prefix for a 32-byte raw public key: +// RFC 8410 § 7. Mirrors the private-side prefix used in `peer-identity.ts`. +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); + +function createPublicKeyFromRawEd25519(rawBytes: Buffer): ReturnType { + if (rawBytes.length !== 32) { + throw new Error(`peers.helloVerify: expected 32-byte Ed25519 pubkey, got ${String(rawBytes.length)}`); + } + const spki = Buffer.concat([ED25519_SPKI_PREFIX, rawBytes]); + return createPublicKey({ key: spki, format: 'der', type: 'spki' }); +} + +/** + * Build the byte buffer that gets signed/verified. + * Ordering is fixed: nonce_bytes || schemaHash_utf8 || peerId_utf8. + */ +export function helloPayloadBytes(p: { peerId: string; schemaHash: string; nonce: string }): Buffer { + return Buffer.concat([ + Buffer.from(p.nonce, 'base64'), + Buffer.from(p.schemaHash, 'utf8'), + Buffer.from(p.peerId, 'utf8'), + ]); +} + +/** + * Verify a signed HELLO payload against the stored Ed25519 pubkey for `peerId`. + * Returns `{ ok: true }` on success or a structured failure reason otherwise. + * + * `peerStore` is a `Pick` to keep the helper pure + * and trivially testable with an in-memory stub. + */ +export function verifyHelloSignature( + payload: HelloPayload, + peerStore: Pick, +): HelloVerifyResult { + const peer = peerStore.getByPeerId(payload.peerId); + if (!peer) return { ok: false, reason: 'unknown_peer' }; + if (peer.revokedAt !== null) return { ok: false, reason: 'revoked' }; + + const pub = Buffer.from(peer.pubkey, 'base64'); + let keyObject: ReturnType; + try { + keyObject = createPublicKeyFromRawEd25519(pub); + } catch { + return { ok: false, reason: 'bad_signature' }; + } + + let ok = false; + try { + ok = edVerify( + null, + helloPayloadBytes(payload), + keyObject, + Buffer.from(payload.sig, 'base64'), + ); + } catch { + return { ok: false, reason: 'bad_signature' }; + } + return ok ? { ok: true } : { ok: false, reason: 'bad_signature' }; +} + +/** + * Sign a HELLO payload with the caller-provided Ed25519 sign function (typically + * `selfIdentity.sign` from `peer-identity.ts`). Returns the base64-encoded sig. + */ +export function signHelloPayload( + args: { peerId: string; schemaHash: string; nonce: string }, + sign: (msg: Uint8Array) => Uint8Array, +): string { + const sig = sign(helloPayloadBytes(args)); + return Buffer.from(sig).toString('base64'); +} diff --git a/src/main/features/peers/lww-merge.ts b/src/main/features/peers/lww-merge.ts new file mode 100644 index 00000000..065754fa --- /dev/null +++ b/src/main/features/peers/lww-merge.ts @@ -0,0 +1,73 @@ +import { compareHlc } from '@shared/replication/hlc'; +import { TOMBSTONE_COLUMN, type Op } from '@shared/replication/op-types'; + +export type RowMetaState = Record; + +export interface RowMetaUpdate { + columnName: string; + hlc: string; + originPeerId: string; +} + +export interface MergeResult { + /** Column → new value. Apply these as a SQL UPDATE (or INSERT for op_type=insert). */ + columnsToApply: Record; + /** Rows to upsert into `row_meta`. */ + rowMetaUpdates: RowMetaUpdate[]; + /** True if the op is a delete that wins over current state. Caller should physically delete the row. */ + tombstone: boolean; + /** True if update wins over an existing tombstone — caller must clear the tombstone row_meta. */ + resurrectTombstone: boolean; +} + +function incomingWins(meta: RowMetaState, column: string, incomingHlc: string): boolean { + const local = meta[column]; + if (!local) return true; + return compareHlc(incomingHlc, local.hlc) > 0; +} + +export function mergeOp(meta: RowMetaState, op: Op): MergeResult { + const result: MergeResult = { + columnsToApply: {}, + rowMetaUpdates: [], + tombstone: false, + resurrectTombstone: false, + }; + + const tombstoneMeta = meta[TOMBSTONE_COLUMN]; + + if (op.opType === 'delete') { + if (incomingWins(meta, TOMBSTONE_COLUMN, op.hlc)) { + result.tombstone = true; + result.rowMetaUpdates.push({ + columnName: TOMBSTONE_COLUMN, + hlc: op.hlc, + originPeerId: op.originPeerId, + }); + } + return result; + } + + // insert or update: check tombstone first + if (tombstoneMeta) { + if (compareHlc(op.hlc, tombstoneMeta.hlc) <= 0) { + // Tombstone still wins — discard this op entirely. + return result; + } + // Resurrection: op hlc beats tombstone hlc. + result.resurrectTombstone = true; + } + + for (const [column, cv] of Object.entries(op.payload)) { + if (incomingWins(meta, column, cv.hlc)) { + result.columnsToApply[column] = cv.value; + result.rowMetaUpdates.push({ + columnName: column, + hlc: cv.hlc, + originPeerId: op.originPeerId, + }); + } + } + + return result; +} diff --git a/src/main/features/peers/op-log.ts b/src/main/features/peers/op-log.ts new file mode 100644 index 00000000..4ceee61d --- /dev/null +++ b/src/main/features/peers/op-log.ts @@ -0,0 +1,65 @@ +import { and, asc, eq, gt, lt } from 'drizzle-orm'; + +import type { Op } from '@shared/replication/op-types'; + +import type { AdcDatabase } from '@main/db'; +import { opLog as opLogTable } from '@main/features/peers/schema'; + + +export interface OpLogService { + append: (op: Op) => void; + readSince: (originPeerId: string, sinceHlc: string | null) => Op[]; + /** Delete op_log rows whose hlc is strictly less than the watermark. + * Returns the number of rows deleted. */ + gc: (watermarkHlc: string) => { deleted: number }; +} + +export function createOpLogService(db: AdcDatabase): OpLogService { + return { + append(op) { + db.insert(opLogTable) + .values({ + hlc: op.hlc, + originPeerId: op.originPeerId, + tableName: op.tableName, + pk: op.pk, + opType: op.opType, + payload: JSON.stringify(op.payload), + appliedAt: Date.now(), + }) + .onConflictDoNothing() + .run(); + }, + + readSince(originPeerId, sinceHlc) { + const whereClause = + sinceHlc === null + ? eq(opLogTable.originPeerId, originPeerId) + : and(eq(opLogTable.originPeerId, originPeerId), gt(opLogTable.hlc, sinceHlc)); + + const rows = db + .select() + .from(opLogTable) + .where(whereClause) + .orderBy(asc(opLogTable.hlc)) + .all(); + + return rows.map((r) => ({ + hlc: r.hlc, + originPeerId: r.originPeerId, + tableName: r.tableName as Op['tableName'], + pk: r.pk, + opType: r.opType, + payload: JSON.parse(r.payload) as Op['payload'], + })); + }, + + gc(watermarkHlc) { + const result = db + .delete(opLogTable) + .where(lt(opLogTable.hlc, watermarkHlc)) + .run() as { changes?: number }; + return { deleted: result.changes ?? 0 }; + }, + }; +} diff --git a/src/main/features/peers/outbound-dialer.ts b/src/main/features/peers/outbound-dialer.ts new file mode 100644 index 00000000..7458db9a --- /dev/null +++ b/src/main/features/peers/outbound-dialer.ts @@ -0,0 +1,167 @@ +/** + * OutboundDialer — state machine that drives the outbound peer-sync WebSocket + * with exponential backoff + jitter, a max-backoff cap, single-flight guard, + * a cancellable pending timer, and a terminal `permanently_failed` state for + * cases the caller declares non-recoverable (e.g. TLS fingerprint mismatch). + * + * Audit refs: 02-transport.md C1 (no backoff/jitter/cap, leaks timers, no dedup) + * and 02-transport.md C5 (permanent-fail on fingerprint mismatch). + */ + +import { + WS_RECONNECT_BASE_MS, + WS_RECONNECT_JITTER, + WS_RECONNECT_MAX_MS, +} from './peer-constants'; + +export type DialResult = 'OK' | 'FAIL' | 'PERMANENT_FAIL'; + +export type DialerState = + | 'idle' + | 'connecting' + | 'open' + | 'backoff' + | 'permanently_failed' + | 'closed'; + +export interface OutboundDialerOpts { + /** + * Caller-supplied dial routine. Must resolve to: + * - `'OK'` on a successful connection (resets the attempts counter) + * - `'FAIL'` on a transient error (schedules backoff) + * - `'PERMANENT_FAIL'` on a non-recoverable error (terminates the dialer) + * + * Must not throw — wrap in try/catch and convert to `'FAIL'` if needed. + */ + attemptDial: () => Promise; + /** Base delay in ms for exponential backoff. Default 500. */ + baseMs?: number; + /** Maximum backoff delay in ms. Default 30_000. */ + maxBackoffMs?: number; + /** Symmetric jitter ratio applied to each delay (0..1). Default 0.25. */ + jitterRatio?: number; + /** Optional state-change observer. */ + onState?: (s: DialerState) => void; + /** RNG override for deterministic tests. Default Math.random. */ + random?: () => number; +} + +export interface OutboundDialer { + /** + * Begin (or resume from `backoff`) the dial loop. No-op if already + * connecting / open / permanently_failed / closed. + */ + start: () => void; + /** Cancel any pending timer and transition to `closed`. Idempotent. */ + close: () => void; + /** Current state. */ + state: () => DialerState; +} + +export function createOutboundDialer(opts: OutboundDialerOpts): OutboundDialer { + const { + attemptDial, + baseMs = WS_RECONNECT_BASE_MS, + maxBackoffMs = WS_RECONNECT_MAX_MS, + jitterRatio = WS_RECONNECT_JITTER, + onState, + random = Math.random, + } = opts; + + let current: DialerState = 'idle'; + let attempts = 0; + let pending: ReturnType | null = null; + + function setState(next: DialerState): void { + if (current === next) return; + current = next; + onState?.(next); + } + + function clearPending(): void { + if (pending !== null) { + clearTimeout(pending); + pending = null; + } + } + + function computeDelay(): number { + // attempts is 1-based at this point (incremented in runDial before calling) + const exponent = Math.max(0, attempts - 1); + const raw = baseMs * Math.pow(2, exponent); + const capped = Math.min(maxBackoffMs, raw); + if (jitterRatio <= 0) return capped; + // symmetric jitter: capped * (1 - r) .. capped * (1 + r) + const span = capped * jitterRatio; + const offset = (random() * 2 - 1) * span; + return Math.max(0, capped + offset); + } + + function scheduleBackoff(): void { + setState('backoff'); + const delay = computeDelay(); + const handle = setTimeout(() => { + pending = null; + // Race-safe: ignore the fired timer if dialer was closed or permanently failed + // while waiting. + if (current === 'closed' || current === 'permanently_failed') return; + runDial(); + }, delay); + if (typeof handle.unref === 'function') { + handle.unref(); + } + pending = handle; + } + + function runDial(): void { + // single-flight guard — only run when allowed + if (current !== 'idle' && current !== 'backoff') return; + setState('connecting'); + attempts += 1; + void attemptDial().then( + (result) => { + // Result may arrive after close() / permanent-fail — drop it. + if (current === 'closed' || current === 'permanently_failed') { + return null; + } + if (result === 'OK') { + attempts = 0; + setState('open'); + return null; + } + if (result === 'PERMANENT_FAIL') { + clearPending(); + setState('permanently_failed'); + return null; + } + // 'FAIL' → backoff + scheduleBackoff(); + return null; + }, + () => { + // Defensive: attemptDial threw despite the contract. Treat as FAIL. + if (current === 'closed' || current === 'permanently_failed') { + return null; + } + scheduleBackoff(); + return null; + }, + ); + } + + return { + start(): void { + if (current !== 'idle' && current !== 'backoff') return; + clearPending(); + runDial(); + }, + close(): void { + if (current === 'closed') return; + clearPending(); + setState('closed'); + }, + state(): DialerState { + return current; + }, + }; +} diff --git a/src/main/features/peers/pair-server.ts b/src/main/features/peers/pair-server.ts new file mode 100644 index 00000000..e1e6ec4d --- /dev/null +++ b/src/main/features/peers/pair-server.ts @@ -0,0 +1,300 @@ +import { createServer, type Server } from 'node:https'; + +import { serviceLogger } from '@main/lib/logger'; + +import { + LOOPBACK_HOST, + PAIR_BODY_MAX_BYTES, + PAIR_BODY_READ_TIMEOUT_MS, + PAIR_HEADERS_TIMEOUT_MS, + PAIR_KEEPALIVE_TIMEOUT_MS, + PAIR_REQUEST_TIMEOUT_MS, +} from './peer-constants'; +import { createIpRateLimiter, type IpRateLimiter } from './rate-limiter'; + +import type { PeerPairing } from './peer-pairing'; +import type { PeerStore } from './peer-store'; +import type { PeerTlsMaterial } from './peer-tls'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +// ─── Local constants (only those not protocol-shared) ─────────────────────── + +/** TLS floor — Node's TLSv1.3 default for Ed25519 is fine, but we pin the floor. */ +const TLS_MIN_VERSION = 'TLSv1.2' as const; +/** Default per-IP rate-limit capacity for /pair/init + /pair/confirm. */ +const DEFAULT_RL_CAPACITY = 5; +/** Default refill: 1 token per minute. */ +const DEFAULT_RL_REFILL_PER_MS = 1 / 60_000; + +export interface PairInitRequestBody { + peerId: string; + pubkey: string; + fingerprint: string; + displayName?: string; +} + +export interface PairServerDeps { + tls: PeerTlsMaterial; + pairing: PeerPairing; + peerStore: PeerStore; + selfIdentity: { peerId: string; pubkey: string }; + selfFingerprint: string; + listenPort: number; // 0 = OS-assigned + host?: string; + onPinIssued?: (info: { sessionId: string; pin: string; initiatorPeerId: string; initiatorDisplayName?: string }) => void; + now?: () => number; + /** + * Pre-existing https.Server to attach pair routes onto. When provided, the + * pair-server skips server creation and listening — caller owns the lifecycle. + */ + existingServer?: Server; + /** Optional rate limiter override (test injection). */ + rateLimiter?: IpRateLimiter; +} + +export type PairRequestHandler = (req: IncomingMessage, res: ServerResponse) => void; + +export interface PairRoutesDeps { + pairing: PeerPairing; + peerStore: PeerStore; + selfIdentity: { peerId: string; pubkey: string }; + selfFingerprint: string; + onPinIssued?: (info: { sessionId: string; pin: string; initiatorPeerId: string; initiatorDisplayName?: string }) => void; + now?: () => number; + /** Per-IP rate limiter applied before any work happens. Defaults to 5/min/IP. */ + rateLimiter?: IpRateLimiter; +} + +/** Returns a request handler that responds to /pair/init + /pair/confirm only. */ +export function createPairRoutes(deps: PairRoutesDeps): PairRequestHandler { + const { pairing, peerStore, selfIdentity, selfFingerprint, onPinIssued } = deps; + const now = deps.now ?? Date.now; + const rateLimiter = + deps.rateLimiter ?? + createIpRateLimiter({ + capacity: DEFAULT_RL_CAPACITY, + refillPerMs: DEFAULT_RL_REFILL_PER_MS, + }); + + return (req, res) => { + void handle(req, res).catch((err: unknown) => { + serviceLogger.error({ err, url: req.url }, 'peers.pairRoutes handler threw'); + if (!res.headersSent) sendJson(res, 500, { error: 'internal_error' }); + }); + }; + + async function handle(req: IncomingMessage, res: ServerResponse): Promise { + const url = req.url ?? ''; + const method = req.method ?? 'GET'; + + if (url !== '/pair/init' && url !== '/pair/confirm') { + sendJson(res, 404, { error: 'not_found' }); + return; + } + if (method !== 'POST') { + res.setHeader('allow', 'POST'); + sendJson(res, 405, { error: 'method_not_allowed' }); + return; + } + + const ip = req.socket.remoteAddress ?? 'unknown'; + if (!rateLimiter.consume(ip)) { + serviceLogger.warn({ ip, url }, 'peers.pairRoutes rate-limited request'); + sendJson(res, 429, { error: 'rate_limited' }); + return; + } + + // Slow-loris guard — if the client stops sending body bytes, drop the socket. + req.setTimeout(PAIR_BODY_READ_TIMEOUT_MS, () => { + req.destroy(); + }); + + let body: unknown; + try { + body = await readJsonBody(req); + } catch (err) { + const reason = (err as Error).message === 'body_too_large' ? 'body_too_large' : 'invalid_json'; + sendJson(res, 400, { error: reason }); + return; + } + + if (url === '/pair/init') { + if (!isInitBody(body)) { + sendJson(res, 400, { error: 'invalid_body' }); + return; + } + const result = pairing.initPair({ + peerId: body.peerId, + pubkey: body.pubkey, + fingerprint: body.fingerprint, + displayName: body.displayName, + }); + onPinIssued?.({ + sessionId: result.sessionId, + pin: result.pin, + initiatorPeerId: body.peerId, + initiatorDisplayName: body.displayName, + }); + sendJson(res, 200, { sessionId: result.sessionId, challenge: result.challenge }); + return; + } + + if (!isConfirmBody(body)) { + sendJson(res, 400, { error: 'invalid_body' }); + return; + } + const outcome = pairing.confirmPair(body.sessionId, body.pinHmac); + if (!outcome.ok) { + sendJson(res, 401, { error: outcome.reason }); + return; + } + + const { initiator } = outcome; + if (!initiator.fingerprint) { + sendJson(res, 400, { error: 'missing_fingerprint' }); + return; + } + peerStore.upsert({ + peerId: initiator.peerId, + pubkey: initiator.pubkey, + displayName: initiator.displayName ?? null, + certFingerprint: initiator.fingerprint, + pairedAt: now(), + }); + + sendJson(res, 200, { + peerId: selfIdentity.peerId, + pubkey: selfIdentity.pubkey, + fingerprint: selfFingerprint, + }); + } +} + +export interface PairServer { + port: () => number; + url: () => string; + close: () => Promise; +} + +function readJsonBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + let settled = false; + const settleErr = (err: Error): void => { + if (settled) return; + settled = true; + reject(err); + }; + const settleOk = (value: unknown): void => { + if (settled) return; + settled = true; + resolve(value); + }; + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > PAIR_BODY_MAX_BYTES) { + settleErr(new Error('body_too_large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + if (settled) return; + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) { + settleOk(null); + return; + } + try { + settleOk(JSON.parse(raw)); + } catch { + settleErr(new Error('invalid_json')); + } + }); + req.on('error', settleErr); + }); +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + const payload = Buffer.from(JSON.stringify(body), 'utf8'); + res.writeHead(status, { + 'content-type': 'application/json', + 'content-length': String(payload.length), + }); + res.end(payload); +} + +function isInitBody(value: unknown): value is PairInitRequestBody { + if (value === null || typeof value !== 'object') return false; + const v = value as Record; + if (typeof v.peerId !== 'string' || v.peerId.length === 0) return false; + if (typeof v.pubkey !== 'string' || v.pubkey.length === 0) return false; + if (typeof v.fingerprint !== 'string' || v.fingerprint.length === 0) return false; + if (v.displayName !== undefined && typeof v.displayName !== 'string') return false; + return true; +} + +interface ConfirmBody { + sessionId: string; + pinHmac: string; +} + +function isConfirmBody(value: unknown): value is ConfirmBody { + if (value === null || typeof value !== 'object') return false; + const v = value as Record; + return typeof v.sessionId === 'string' && typeof v.pinHmac === 'string'; +} + +export async function createPairServer(deps: PairServerDeps): Promise { + const { tls, existingServer } = deps; + const host = deps.host ?? LOOPBACK_HOST; + const ownsServer = !existingServer; + + const server: Server = + existingServer ?? + createServer({ + cert: tls.cert, + key: tls.key, + minVersion: TLS_MIN_VERSION, + }); + if (ownsServer) { + // Slow-loris hardening — see audit 01-security finding "no requestTimeout/headersTimeout". + server.headersTimeout = PAIR_HEADERS_TIMEOUT_MS; + server.requestTimeout = PAIR_REQUEST_TIMEOUT_MS; + server.keepAliveTimeout = PAIR_KEEPALIVE_TIMEOUT_MS; + } + const handler = createPairRoutes(deps); + server.on('request', handler); + + if (ownsServer) { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(deps.listenPort, host, () => { + server.removeListener('error', reject); + resolve(); + }); + }); + } + + const addr = server.address() as AddressInfo; + const { port: actualPort } = addr; + + return { + port: () => actualPort, + url: () => `https://${host}:${String(actualPort)}`, + async close() { + server.removeListener('request', handler); + if (!ownsServer) return; + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + server.closeAllConnections(); + }); + }, + }; +} diff --git a/src/main/features/peers/peer-config.ts b/src/main/features/peers/peer-config.ts new file mode 100644 index 00000000..7c3efdec --- /dev/null +++ b/src/main/features/peers/peer-config.ts @@ -0,0 +1,36 @@ +export interface PeerConfig { + /** This peer's listen port. */ + listenPort: number; + /** Remote peer address to connect to (ws:// or wss://host:port). Empty = no manual override. */ + remoteUrl: string; + /** This peer's short id (8 hex chars). Empty when identity-derived at boot. */ + peerIdShort: string; + /** This peer's full id (64 hex chars). Empty when identity-derived at boot. */ + peerIdFull: string; + /** When true, advertise + browse over mDNS. Default true. */ + preferMdns: boolean; + /** When true, expose /pair/* endpoints. Default true. */ + pairingEnabled: boolean; + /** Optional human-readable name for mDNS TXT records. */ + displayName?: string; +} + +function parseBool(value: string | undefined, fallback: boolean): boolean { + if (value === undefined) return fallback; + if (value === '1' || value.toLowerCase() === 'true') return true; + if (value === '0' || value.toLowerCase() === 'false') return false; + return fallback; +} + +export function loadPeerConfig(): PeerConfig { + const parsedPort = Number(process.env.ADC_PEER_PORT ?? '0'); + return { + listenPort: Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : 0, + remoteUrl: process.env.ADC_PEER_REMOTE ?? '', + peerIdShort: process.env.ADC_PEER_ID_SHORT ?? '', + peerIdFull: process.env.ADC_PEER_ID_FULL ?? '', + preferMdns: parseBool(process.env.ADC_PEER_PREFER_MDNS, true), + pairingEnabled: parseBool(process.env.ADC_PEER_PAIRING_ENABLED, true), + displayName: process.env.ADC_PEER_DISPLAY_NAME, + }; +} diff --git a/src/main/features/peers/peer-constants.ts b/src/main/features/peers/peer-constants.ts new file mode 100644 index 00000000..f037d36b --- /dev/null +++ b/src/main/features/peers/peer-constants.ts @@ -0,0 +1,42 @@ +/** + * Protocol-level + runtime constants shared across the peers module. + * Keep this file dependency-free. + */ + +// WebSocket close codes (audit 02/H2) +export const WS_CLOSE_CODES = { + SCHEMA_MISMATCH: 4001, + FINGERPRINT_MISMATCH: 4002, + MALFORMED_FRAME: 4003, + UNTRUSTED: 4004, +} as const; + +export const WS_RECONNECT_BASE_MS = 500; +export const WS_RECONNECT_MAX_MS = 30_000; +export const WS_RECONNECT_JITTER = 0.25; +export const MAX_INBOUND_SOCKETS = 64; + +// mDNS +export const MDNS_SERVICE_TYPE = 'adc-peer'; +export const MDNS_PROTOCOL = 'tcp'; + +// Peer identity +export const PEER_ID_SHORT_LEN = 8; + +// Network +export const LOOPBACK_HOST = '127.0.0.1'; + +// Pairing server +export const PAIR_BODY_MAX_BYTES = 16 * 1024; +export const PAIR_REQUEST_TIMEOUT_MS = 15_000; +export const PAIR_HEADERS_TIMEOUT_MS = 10_000; +export const PAIR_KEEPALIVE_TIMEOUT_MS = 5_000; +export const PAIR_BODY_READ_TIMEOUT_MS = 5_000; + +// Pairing sessions +export const SESSION_TTL_MS = 5 * 60 * 1000; +export const SESSION_MAX_ATTEMPTS = 3; +export const SESSION_SOFT_LIMIT = 100; + +// GC +export const GC_INTERVAL_MS = 24 * 60 * 60 * 1000; diff --git a/src/main/features/peers/peer-http.ts b/src/main/features/peers/peer-http.ts new file mode 100644 index 00000000..8bf98140 --- /dev/null +++ b/src/main/features/peers/peer-http.ts @@ -0,0 +1,98 @@ +/** + * Peer HTTPS client — TLS-pinned POST helper. + * + * Extracted from `peers-service.ts` (audit M2) so the fingerprint-pinned + * request path is unit-testable without standing up a real TLS socket. + * + * Pinning model: `pinnedCheckServerIdentity` runs inside the TLS stack + * before any application data is exchanged, so a fingerprint mismatch + * surfaces as a request `'error'` (not a post-`'end'` check). + */ + +import { Agent as HttpsAgent, request as httpsRequest } from 'node:https'; + +import { pinnedCheckServerIdentity } from './peer-tls-pin'; + +import type { ClientRequest, IncomingMessage, RequestOptions } from 'node:http'; + +export interface PostJsonPinnedOpts { + /** + * Injection seam for tests — defaults to `node:https`.request. + * Tests pass a fake that simulates response/error events without + * standing up a real TLS socket. + */ + requestImpl?: ( + options: RequestOptions, + cb: (res: IncomingMessage) => void, + ) => ClientRequest; +} + +export async function postJsonPinned( + url: string, + fingerprintHex: string, + body: unknown, + opts: PostJsonPinnedOpts = {}, +): Promise { + const reqFn = opts.requestImpl ?? httpsRequest; + const u = new URL(url); + // Pin the peer's leaf cert at TLS handshake time. With this in place we can + // (and must) keep `rejectUnauthorized: true` — fingerprint mismatch surfaces + // as a handshake error on the request, not a post-`'end'` check. + const agent = new HttpsAgent({ + rejectUnauthorized: true, + checkServerIdentity: pinnedCheckServerIdentity(fingerprintHex), + }); + const payload = Buffer.from(JSON.stringify(body), 'utf8'); + return await new Promise((resolve, reject) => { + let settled = false; + const settle = (fn: () => void): void => { + if (settled) return; + settled = true; + fn(); + }; + const req = reqFn( + { + host: u.hostname, + port: u.port === '' ? 443 : Number(u.port), + path: u.pathname + u.search, + method: 'POST', + agent, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': String(payload.length), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + const status = res.statusCode ?? 0; + if (status < 200 || status >= 300) { + const statusLabel = status === 0 ? 'unknown' : String(status); + settle(() => { + reject(new Error(`postJsonPinned failed: HTTP ${statusLabel}: ${raw}`)); + }); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + settle(() => { reject(new Error('invalid_json_response')); }); + return; + } + settle(() => { resolve(parsed as T); }); + }); + res.on('error', (err: Error) => { + settle(() => { reject(err); }); + }); + }, + ); + req.on('error', (err: Error) => { + settle(() => { reject(err); }); + }); + req.write(payload); + req.end(); + }); +} diff --git a/src/main/features/peers/peer-identity.ts b/src/main/features/peers/peer-identity.ts new file mode 100644 index 00000000..ee0a39d3 --- /dev/null +++ b/src/main/features/peers/peer-identity.ts @@ -0,0 +1,96 @@ +import { createHash, createPrivateKey, generateKeyPairSync, sign as edSign } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { safeStorage } from 'electron'; + +import { serviceLogger } from '@main/lib/logger'; + +export interface PeerIdentity { + peerIdFull: string; + peerIdShort: string; + pubkey: string; + sign: (message: Uint8Array) => Uint8Array; +} + +export interface IdentityOpts { + allowPlaintext?: boolean; +} + +const IDENTITY_FILENAME = 'peer-identity.json'; + +interface StoredIdentity { + pubkey: string; + privkey: string; + useSafeStorage: boolean; +} + +export function getOrCreatePeerIdentity( + dataDir: string, + opts: IdentityOpts = {}, +): PeerIdentity { + mkdirSync(dataDir, { recursive: true }); + const path = join(dataDir, IDENTITY_FILENAME); + + if (existsSync(path)) { + const stored = JSON.parse(readFileSync(path, 'utf8')) as StoredIdentity; + return materialize(stored); + } + + const canEncrypt = safeStorage.isEncryptionAvailable(); + if (!canEncrypt && !opts.allowPlaintext) { + // Refuse to leak entropy on the failure path — throw before generating the keypair. + throw new Error( + 'peer-identity: safeStorage unavailable. Set ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY=1 to opt in.', + ); + } + if (!canEncrypt && opts.allowPlaintext === true) { + serviceLogger.warn( + { dataDir }, + 'peers.peerIdentity writing private key in plaintext (safeStorage unavailable, opt-in via ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY)', + ); + } + + const { privateKey, publicKey } = generateKeyPairSync('ed25519', {}); + // Ed25519 SPKI is 44 bytes DER, raw key is the last 32. + const pubBytes = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32); + // Ed25519 PKCS#8 is 48 bytes DER, raw private seed is the last 32. + const privBytes = privateKey.export({ type: 'pkcs8', format: 'der' }).subarray(-32); + + const pubkey = pubBytes.toString('base64'); + const privkeyPlain = privBytes.toString('base64'); + const privkeyStored = canEncrypt + ? safeStorage.encryptString(privkeyPlain).toString('base64') + : privkeyPlain; + + const stored: StoredIdentity = { + pubkey, + privkey: privkeyStored, + useSafeStorage: canEncrypt, + }; + writeFileSync(path, JSON.stringify(stored, null, 2), { mode: 0o600, encoding: 'utf8' }); + return materialize(stored); +} + +// Ed25519 PKCS#8 prefix for a 32-byte seed: RFC 8410 § 7 +const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex'); + +function materialize(stored: StoredIdentity): PeerIdentity { + const pubBytes = Buffer.from(stored.pubkey, 'base64'); + const peerIdFull = createHash('sha256').update(pubBytes).digest('hex'); + + const privBytesPlain = stored.useSafeStorage + ? safeStorage.decryptString(Buffer.from(stored.privkey, 'base64')) + : stored.privkey; + const privBytes = Buffer.from(privBytesPlain, 'base64'); + + const pkcs8 = Buffer.concat([ED25519_PKCS8_PREFIX, privBytes]); + const keyObject = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' }); + + return { + peerIdFull, + peerIdShort: peerIdFull.slice(0, 8), + pubkey: stored.pubkey, + sign: (message: Uint8Array) => edSign(null, message, keyObject), + }; +} diff --git a/src/main/features/peers/peer-mdns.ts b/src/main/features/peers/peer-mdns.ts new file mode 100644 index 00000000..9590c8e7 --- /dev/null +++ b/src/main/features/peers/peer-mdns.ts @@ -0,0 +1,151 @@ +import { EventEmitter } from 'node:events'; + +import { Bonjour } from 'bonjour-service'; + +import { + MDNS_PROTOCOL, + MDNS_SERVICE_TYPE, + PEER_ID_SHORT_LEN, +} from './peer-constants'; + +export interface PeerAdvertisement { + peerId: string; + fingerprint: string; + displayName?: string; + host: string; + port: number; + lastSeenAt: number; +} + +export interface PeerMdnsOpts { + selfPeerId: string; + fingerprint: string; + port: number; + displayName?: string; + /** Override bonjour factory for tests. */ + bonjour?: () => Bonjour; + /** Override Date.now for tests. */ + now?: () => number; +} + +export interface PeerMdns { + start: () => Promise; + stop: () => Promise; + getSnapshot: () => PeerAdvertisement[]; + onChange: (handler: (peers: PeerAdvertisement[]) => void) => () => void; +} + +interface UpService { + name?: string; + port?: number; + addresses?: string[]; + txt?: Record; +} + +/** + * Create an mDNS advertiser + browser for `_adc-peer._tcp.local`. + * + * TXT record: + * - peerId: full SHA-256 of pubkey (64 hex chars) + * - fp: TLS cert SHA-256 fingerprint (hex) + * - name: optional display name + * - v: protocol version ("1") + * + * Filters out our own ad by `peerId`. Emits 'change' on up/down. + */ +export function createPeerMdns(opts: PeerMdnsOpts): PeerMdns { + const byPeerId = new Map(); + const emitter = new EventEmitter(); + const now = opts.now ?? Date.now; + let bonjour: Bonjour | null = null; + let browser: ReturnType | null = null; + let published: { stop: () => void } | null = null; + + function snapshot(): PeerAdvertisement[] { + return [...byPeerId.values()]; + } + + function fireChange(): void { + emitter.emit('change', snapshot()); + } + + function handleUp(svc: UpService): void { + const txt = svc.txt ?? {}; + const { peerId, fp } = txt; + if (!peerId || !fp) return; + if (peerId === opts.selfPeerId) return; + + const addresses = Array.isArray(svc.addresses) ? svc.addresses : []; + const host = addresses[0] ?? ''; + if (!host) return; + + const ad: PeerAdvertisement = { + peerId, + fingerprint: fp, + displayName: txt.name, + host, + port: typeof svc.port === 'number' ? svc.port : 0, + lastSeenAt: now(), + }; + byPeerId.set(peerId, ad); + fireChange(); + } + + function handleDown(svc: UpService): void { + const peerId = svc.txt?.peerId; + if (!peerId) return; + if (byPeerId.delete(peerId)) fireChange(); + } + + return { + start() { + bonjour = (opts.bonjour ?? (() => new Bonjour()))(); + + const txt: Record = { + peerId: opts.selfPeerId, + fp: opts.fingerprint, + v: '1', + }; + if (opts.displayName) txt.name = opts.displayName; + + published = bonjour.publish({ + name: `adc-peer-${opts.selfPeerId.slice(0, PEER_ID_SHORT_LEN)}`, + type: MDNS_SERVICE_TYPE, + protocol: MDNS_PROTOCOL, + port: opts.port, + txt, + }) as unknown as { stop: () => void }; + + browser = bonjour.find({ type: MDNS_SERVICE_TYPE, protocol: MDNS_PROTOCOL }); + browser.on('up', (svc: UpService) => { handleUp(svc); }); + browser.on('down', (svc: UpService) => { handleDown(svc); }); + return Promise.resolve(); + }, + + stop() { + if (browser) { + try { browser.stop(); } catch { /* ignore */ } + browser = null; + } + if (published) { + try { published.stop(); } catch { /* ignore */ } + published = null; + } + if (bonjour) { + try { bonjour.destroy(); } catch { /* ignore */ } + bonjour = null; + } + byPeerId.clear(); + return Promise.resolve(); + }, + + getSnapshot() { + return snapshot(); + }, + + onChange(handler) { + emitter.on('change', handler); + return () => { emitter.off('change', handler); }; + }, + }; +} diff --git a/src/main/features/peers/peer-pairing.ts b/src/main/features/peers/peer-pairing.ts new file mode 100644 index 00000000..4c9e9dbf --- /dev/null +++ b/src/main/features/peers/peer-pairing.ts @@ -0,0 +1,153 @@ +import { createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto'; + +import { + SESSION_MAX_ATTEMPTS, + SESSION_SOFT_LIMIT, + SESSION_TTL_MS, +} from './peer-constants'; + +export interface PairInitArgs { + peerId: string; + pubkey: string; + fingerprint?: string; + displayName?: string; +} + +export interface PairInitResult { + sessionId: string; + pin: string; + challenge: string; +} + +export type PairConfirmResult = + | { ok: true; initiator: { peerId: string; pubkey: string; fingerprint?: string; displayName?: string } } + | { ok: false; reason: 'wrong_pin' | 'expired' | 'locked_out' | 'unknown_session' }; + +export interface PeerPairing { + initPair: (initiator: PairInitArgs) => PairInitResult; + confirmPair: (sessionId: string, pinHmac: string) => PairConfirmResult; + computePinHmac: (pin: string, challenge: string) => string; + /** Live (non-expired) session count — used by pair-server tests + telemetry. */ + activeSessionCount: () => number; +} + +export interface PeerPairingOpts { + now?: () => number; + rng?: () => Buffer; + pinRng?: () => string; + sessionTtlMs?: number; + maxAttempts?: number; + /** + * Hard cap on the live-session map. Expired entries are swept on every + * `initPair` call; once the cap is reached new sessions still insert + * (sweep happens first), but operators should monitor `activeSessionCount`. + */ + maxActiveSessions?: number; +} + +interface StoredSession { + challenge: string; + expectedHmac: string; + initiator: PairInitArgs; + createdAt: number; + attemptsRemaining: number; +} + +export function createPeerPairing(opts: PeerPairingOpts = {}): PeerPairing { + const now = opts.now ?? Date.now; + const rng = opts.rng ?? (() => randomBytes(32)); + const pinRng = opts.pinRng ?? (() => randomInt(0, 1_000_000).toString().padStart(6, '0')); + const sessionTtlMs = opts.sessionTtlMs ?? SESSION_TTL_MS; + const maxAttempts = opts.maxAttempts ?? SESSION_MAX_ATTEMPTS; + const maxActiveSessions = opts.maxActiveSessions ?? SESSION_SOFT_LIMIT; + + const sessions = new Map(); + + function sweepExpired(currentMs: number): void { + for (const [id, session] of sessions) { + if (currentMs - session.createdAt > sessionTtlMs) { + sessions.delete(id); + } + } + // Soft cap: if still over the limit after sweep, evict the oldest entries. + // Map iteration order is insertion order, so the first N keys are oldest. + if (sessions.size >= maxActiveSessions) { + const overflow = sessions.size - maxActiveSessions + 1; + let removed = 0; + for (const id of sessions.keys()) { + if (removed >= overflow) break; + sessions.delete(id); + removed += 1; + } + } + } + + function computeHmac(pin: string, challenge: string): string { + return createHmac('sha256', Buffer.from(challenge, 'base64')).update(pin).digest('base64'); + } + + function constantTimeEq(a: string, b: string): boolean { + let bufA: Buffer; + let bufB: Buffer; + try { + bufA = Buffer.from(a, 'base64'); + bufB = Buffer.from(b, 'base64'); + } catch { + return false; + } + if (bufA.length !== bufB.length) return false; + if (bufA.length === 0) return false; + return timingSafeEqual(bufA, bufB); + } + + return { + initPair(initiator) { + const currentMs = now(); + // sweep expired entries first so the live map stays bounded even + // under steady churn — see audit 01-security: "unbounded session map". + sweepExpired(currentMs); + const sessionId = randomUUID(); + const challenge = rng().toString('base64'); + const pin = pinRng(); + const expectedHmac = computeHmac(pin, challenge); + sessions.set(sessionId, { + challenge, + expectedHmac, + initiator, + createdAt: currentMs, + attemptsRemaining: maxAttempts, + }); + return { sessionId, pin, challenge }; + }, + + confirmPair(sessionId, pinHmac) { + const session = sessions.get(sessionId); + if (!session) return { ok: false, reason: 'unknown_session' }; + + if (now() - session.createdAt > sessionTtlMs) { + sessions.delete(sessionId); + return { ok: false, reason: 'expired' }; + } + + if (constantTimeEq(pinHmac, session.expectedHmac)) { + sessions.delete(sessionId); + return { ok: true, initiator: session.initiator }; + } + + session.attemptsRemaining -= 1; + if (session.attemptsRemaining <= 0) { + sessions.delete(sessionId); + return { ok: false, reason: 'locked_out' }; + } + return { ok: false, reason: 'wrong_pin' }; + }, + + computePinHmac(pin, challenge) { + return computeHmac(pin, challenge); + }, + + activeSessionCount() { + return sessions.size; + }, + }; +} diff --git a/src/main/features/peers/peer-server.ts b/src/main/features/peers/peer-server.ts new file mode 100644 index 00000000..c1fe306d --- /dev/null +++ b/src/main/features/peers/peer-server.ts @@ -0,0 +1,122 @@ +import { createServer as createHttpsServer, type Server as HttpsServer } from 'node:https'; + +import type { AdcDatabase } from '@main/db'; +import type { ReplicationEngine } from '@main/features/peers/replication-engine'; +import { serviceLogger } from '@main/lib/logger'; + +import { createPairServer, type PairServer } from './pair-server'; +import { + createWsTransport, + type WsTransport, + type WsTransportRemotePeer, + type WsTransportSelfIdentity, +} from './ws-transport'; + +import type { PeerPairing } from './peer-pairing'; +import type { PeerStore } from './peer-store'; +import type { PeerTlsMaterial } from './peer-tls'; +import type { AddressInfo } from 'node:net'; + +/** + * Unified peer server: one TLS https.Server hosts both the /pair/* HTTP + * endpoints and the WebSocket sync transport. mDNS therefore advertises a + * single port and remote peers connect via wss://host:port for sync and + * https://host:port/pair/* for pairing. + */ +export interface PeerServerDeps { + db: AdcDatabase; + engine: ReplicationEngine; + tls: PeerTlsMaterial; + selfIdentity: { peerId: string; pubkey: string; sign: (msg: Uint8Array) => Uint8Array }; + /** + * Paired-peer store, owned by `peers-service` and passed through (audit M6). + * Used by `ws-transport` to authenticate inbound HELLO signatures. + */ + peerStore: PeerStore; + /** + * Pairing helper, owned by `peers-service` and passed through (audit M6). + */ + pairing: PeerPairing; + listenPort: number; // 0 = OS-assigned + host?: string; + schemaHash: string; + remoteUrl?: string; + remotePeer?: WsTransportRemotePeer; + onPinIssued?: (info: { sessionId: string; pin: string; initiatorPeerId: string; initiatorDisplayName?: string }) => void; + /** + * Notified once an inbound peer's HELLO signature verifies — surfaced from + * `ws-transport` so the service can update presence state. + */ + onConnected?: (info: { peerId: string }) => void; +} + +export interface PeerServer { + port: () => number; + pair: PairServer; + ws: WsTransport; + close: () => Promise; +} + +export async function createPeerServer(deps: PeerServerDeps): Promise { + const host = deps.host ?? '127.0.0.1'; + const httpsServer: HttpsServer = createHttpsServer({ cert: deps.tls.cert, key: deps.tls.key }); + + await new Promise((resolve, reject) => { + httpsServer.once('error', reject); + httpsServer.listen(deps.listenPort, host, () => { + httpsServer.removeListener('error', reject); + resolve(); + }); + }); + + const addr = httpsServer.address() as AddressInfo; + const { port } = addr; + + // Audit M6: peerStore + pairing are owned by `peers-service` and passed + // in here, not re-constructed. + const { pairing, peerStore } = deps; + + const pair = await createPairServer({ + tls: deps.tls, + pairing, + peerStore, + selfIdentity: { peerId: deps.selfIdentity.peerId, pubkey: deps.selfIdentity.pubkey }, + selfFingerprint: deps.tls.fingerprint, + listenPort: port, + host, + onPinIssued: deps.onPinIssued, + existingServer: httpsServer, + }); + + const wsSelfIdentity: WsTransportSelfIdentity = { + peerId: deps.selfIdentity.peerId, + sign: deps.selfIdentity.sign, + }; + const ws = await createWsTransport({ + engine: deps.engine, + listenPort: port, + remoteUrl: deps.remoteUrl ?? '', + schemaHash: deps.schemaHash, + remotePeer: deps.remotePeer, + existingHttpsServer: httpsServer, + peerStore, + selfIdentity: wsSelfIdentity, + onConnected: deps.onConnected, + }); + + serviceLogger.info({ port, peerId: deps.selfIdentity.peerId }, 'peers.peerServer listening'); + + return { + port: () => port, + pair, + ws, + async close() { + await pair.close(); + await ws.close(); + await new Promise((resolve) => { + httpsServer.close(() => { resolve(); }); + httpsServer.closeAllConnections(); + }); + }, + }; +} diff --git a/src/main/features/peers/peer-store.ts b/src/main/features/peers/peer-store.ts new file mode 100644 index 00000000..3690c01f --- /dev/null +++ b/src/main/features/peers/peer-store.ts @@ -0,0 +1,137 @@ +import { eq, isNull, sql } from 'drizzle-orm'; + +import type { PairedPeer } from '@shared/ipc/peers'; + +import type { AdcDatabase } from '@main/db'; + +import { peerState } from './schema'; + +export type { PairedPeer }; + +type UpsertInput = Pick & { + displayName?: string | null; + lastSeenHlc?: string | null; + lastConnectedAt?: number | null; + revokedAt?: number | null; +}; + +export interface PeerStore { + upsert: (entry: UpsertInput) => void; + getByPeerId: (peerId: string) => PairedPeer | null; + listAll: () => PairedPeer[]; + listActive: () => PairedPeer[]; + revoke: (peerId: string, atMs: number) => void; + updateLastSeenHlc: (peerId: string, hlc: string) => void; + updateLastConnectedAt: (peerId: string, atMs: number) => void; + /** + * Atomically advance `last_seen_hlc` for a peer to `max(current, hlc)` and + * stamp `last_connected_at` to now. Used by the replication engine on every + * applied remote op so the GC watermark has accurate per-peer frontiers. + * Audit reference: tmp/audit/03-replication.md C5. + * + * Note: peer_state rows only exist for paired peers. If the row does not yet + * exist (e.g., op arrives before pairing completes), this is a no-op. + */ + recordObserved: (peerId: string, hlc: string) => void; +} + +function rowToPeer(r: typeof peerState.$inferSelect): PairedPeer { + return { + peerId: r.peerId, + displayName: r.displayName, + pubkey: r.pubkey, + certFingerprint: r.certFingerprint, + lastSeenHlc: r.lastSeenHlc, + pairedAt: r.pairedAt, + lastConnectedAt: r.lastConnectedAt, + revokedAt: r.revokedAt, + }; +} + +export function createPeerStore(db: AdcDatabase): PeerStore { + return { + upsert(entry) { + db.insert(peerState) + .values({ + peerId: entry.peerId, + displayName: entry.displayName ?? null, + pubkey: entry.pubkey, + certFingerprint: entry.certFingerprint, + lastSeenHlc: entry.lastSeenHlc ?? null, + pairedAt: entry.pairedAt, + lastConnectedAt: entry.lastConnectedAt ?? null, + revokedAt: entry.revokedAt ?? null, + }) + .onConflictDoUpdate({ + target: peerState.peerId, + set: { + displayName: entry.displayName ?? null, + pubkey: entry.pubkey, + certFingerprint: entry.certFingerprint, + // Audit 01/Medium: clear revokedAt on re-pair so previously-revoked + // peers can be re-paired without silently remaining revoked. + revokedAt: entry.revokedAt ?? null, + }, + }) + .run(); + }, + + getByPeerId(peerId) { + const row = db + .select() + .from(peerState) + .where(eq(peerState.peerId, peerId)) + .get(); + return row ? rowToPeer(row) : null; + }, + + listAll() { + return db.select().from(peerState).all().map(rowToPeer); + }, + + listActive() { + return db + .select() + .from(peerState) + .where(isNull(peerState.revokedAt)) + .all() + .map(rowToPeer); + }, + + revoke(peerId, atMs) { + db.update(peerState) + .set({ revokedAt: atMs }) + .where(eq(peerState.peerId, peerId)) + .run(); + }, + + updateLastSeenHlc(peerId, hlc) { + db.update(peerState) + .set({ lastSeenHlc: hlc }) + .where(eq(peerState.peerId, peerId)) + .run(); + }, + + updateLastConnectedAt(peerId, atMs) { + db.update(peerState) + .set({ lastConnectedAt: atMs }) + .where(eq(peerState.peerId, peerId)) + .run(); + }, + + recordObserved(peerId, hlc) { + // UPDATE-only: peer_state rows have NOT NULL pubkey/certFingerprint/pairedAt + // that can only come from the pairing handshake, so we cannot insert a + // fresh row here. Ops arriving before pairing completes are silently + // dropped on the floor for watermark purposes — pairing always runs + // before replication begins, so this is sound. + db.update(peerState) + .set({ + lastSeenHlc: sql`CASE WHEN ${peerState.lastSeenHlc} IS NULL OR ${hlc} > ${peerState.lastSeenHlc} THEN ${hlc} ELSE ${peerState.lastSeenHlc} END`, + lastConnectedAt: sql`${Date.now()}`, + }) + .where(eq(peerState.peerId, peerId)) + .run(); + }, + }; +} diff --git a/src/main/features/peers/peer-tls-pin.ts b/src/main/features/peers/peer-tls-pin.ts new file mode 100644 index 00000000..a239f27d --- /dev/null +++ b/src/main/features/peers/peer-tls-pin.ts @@ -0,0 +1,30 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; + +import type { PeerCertificate } from 'node:tls'; + +/** + * Returns a `checkServerIdentity` callback for Node TLS / `ws` that pins the + * peer's leaf certificate to the given SHA-256 fingerprint (hex). + * + * The callback returns `undefined` on match (handshake proceeds) and an + * `Error` on mismatch (handshake aborts before any application data is + * exchanged). This is the correct hook for pinning: it runs inside the TLS + * stack, before the socket emits `'open'` / `'secureConnect'`. + */ +export function pinnedCheckServerIdentity(expectedFingerprintHex: string) { + const expected = Buffer.from(expectedFingerprintHex, 'hex'); + return (_host: string, cert: PeerCertificate): Error | undefined => { + const { raw } = cert as { raw?: Buffer }; + if (!raw || raw.length === 0) { + return new Error('peer fingerprint mismatch (no cert)'); + } + const actual = createHash('sha256').update(raw).digest(); + if ( + actual.length !== expected.length || + !timingSafeEqual(actual, expected) + ) { + return new Error('peer fingerprint mismatch'); + } + return undefined; + }; +} diff --git a/src/main/features/peers/peer-tls.ts b/src/main/features/peers/peer-tls.ts new file mode 100644 index 00000000..71338cdd --- /dev/null +++ b/src/main/features/peers/peer-tls.ts @@ -0,0 +1,84 @@ +import 'reflect-metadata'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; + +import { Crypto } from '@peculiar/webcrypto'; +import * as x509 from '@peculiar/x509'; + +const webcrypto = new Crypto(); +x509.cryptoProvider.set(webcrypto); + +export interface PeerTlsMaterial { + cert: string; + key: string; + fingerprint: string; +} + +const CERT_VALIDITY_DAYS = 365 * 10; +const CERT_FILE = 'peer-tls.cert.pem'; +const KEY_FILE = 'peer-tls.key.pem'; + +export function computeCertFingerprint(pem: string): string { + const der = Buffer.from( + pem.replaceAll(/-----(BEGIN|END) CERTIFICATE-----/g, '').replaceAll(/\s+/g, ''), + 'base64', + ); + return createHash('sha256').update(der).digest('hex'); +} + +function pemFromDer(label: string, der: Buffer): string { + const b64 = der.toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''; + return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`; +} + +async function generateCert(peerId: string): Promise { + const keyPair = await webcrypto.subtle.generateKey( + { name: 'Ed25519' }, + true, + ['sign', 'verify'], + ); + const now = new Date(); + const notAfter = new Date(now.getTime() + CERT_VALIDITY_DAYS * 86_400_000); + + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: '01', + name: `CN=${peerId}`, + notBefore: now, + notAfter, + signingAlgorithm: { name: 'Ed25519' }, + keys: keyPair, + extensions: [ + new x509.BasicConstraintsExtension(true, undefined, true), + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyCertSign, + ), + ], + }); + + const pkcs8 = await webcrypto.subtle.exportKey('pkcs8', keyPair.privateKey); + const key = pemFromDer('PRIVATE KEY', Buffer.from(pkcs8)); + const certPem = x509.PemConverter.encode(cert.rawData, 'CERTIFICATE'); + return { cert: certPem, key, fingerprint: computeCertFingerprint(certPem) }; +} + +export async function resolvePeerTls(dataDir: string, peerId: string): Promise { + mkdirSync(dataDir, { recursive: true }); + const certPath = join(dataDir, CERT_FILE); + const keyPath = join(dataDir, KEY_FILE); + + if (existsSync(certPath) && existsSync(keyPath)) { + const cert = readFileSync(certPath, 'utf8'); + const key = readFileSync(keyPath, 'utf8'); + return { cert, key, fingerprint: computeCertFingerprint(cert) }; + } + + const material = await generateCert(peerId); + writeFileSync(certPath, material.cert, { mode: 0o600 }); + writeFileSync(keyPath, material.key, { mode: 0o600 }); + if (process.platform !== 'win32') { + chmodSync(certPath, 0o600); + chmodSync(keyPath, 0o600); + } + return material; +} diff --git a/src/main/features/peers/peers-handlers.ts b/src/main/features/peers/peers-handlers.ts new file mode 100644 index 00000000..4561b4a6 --- /dev/null +++ b/src/main/features/peers/peers-handlers.ts @@ -0,0 +1,78 @@ +/** + * Peers IPC handlers — thin bridge from PEERS.* invoke channels and + * PEERS_EVENTS.* event channels to the PeersService. + * + * NOTE: `service` here may be the awaitable Proxy wrapper from + * `peers-service-async.ts`. Every method call returns a Promise even when + * the underlying service method is statically typed synchronous (e.g. + * `listPaired`, `listDiscovered`, `getIdentity`, `revoke`). The + * `validatedHandle` helper normalizes both shapes — its `fn` is async, so + * sync return values are auto-promised; awaitable-wrapper return values + * are transparently chained. + * + * Event-subscription return values (the unsub function) are wrapped in a + * Promise by the wrapper; we discard them here since the registry has no + * per-handler teardown path. + */ + +import { PEERS, PEERS_EVENTS, peersInvoke } from '@shared/ipc/peers'; + +import { validatedHandle } from './validated-handle'; + +import type { PeersService } from './peers-service'; +import type { IpcRouter } from '../../ipc/router'; + +export function registerPeersHandlers(router: IpcRouter, service: PeersService): void { + router.handle( + PEERS.LIST.PAIRED, + validatedHandle(peersInvoke, PEERS.LIST.PAIRED, () => Promise.resolve(service.listPaired())), + ); + + router.handle( + PEERS.LIST.DISCOVERED, + validatedHandle(peersInvoke, PEERS.LIST.DISCOVERED, () => + Promise.resolve(service.listDiscovered()), + ), + ); + + router.handle( + PEERS.IDENTITY.GET, + validatedHandle(peersInvoke, PEERS.IDENTITY.GET, () => Promise.resolve(service.getIdentity())), + ); + + router.handle( + PEERS.PAIR.INIT, + validatedHandle(peersInvoke, PEERS.PAIR.INIT, (input) => service.pairInit(input)), + ); + + router.handle( + PEERS.PAIR.CONFIRM, + validatedHandle(peersInvoke, PEERS.PAIR.CONFIRM, (input) => service.pairConfirm(input)), + ); + + router.handle( + PEERS.REVOKE.PEER, + validatedHandle(peersInvoke, PEERS.REVOKE.PEER, (input) => + Promise.resolve(service.revoke(input.peerId)), + ), + ); + + // Forward service events → IPC events. With the awaitable wrapper, the + // `onX(...)` calls return Promise<() => void>; the unsub is discarded + // because the registry never tears handlers down individually. + void service.onPinIssued((info) => { + router.emit(PEERS_EVENTS.PIN.ISSUED, { + sessionId: info.sessionId, + pin: info.pin, + initiatorPeerId: info.initiatorPeerId, + initiatorDisplayName: info.initiatorDisplayName ?? null, + issuedAt: info.issuedAt, + }); + }); + void service.onDiscoveryChanged((peers) => { + router.emit(PEERS_EVENTS.DISCOVERY.CHANGED, { peers }); + }); + void service.onTrustChanged((event) => { + router.emit(PEERS_EVENTS.TRUST.CHANGED, event); + }); +} diff --git a/src/main/features/peers/peers-service-async.ts b/src/main/features/peers/peers-service-async.ts new file mode 100644 index 00000000..a656ee41 --- /dev/null +++ b/src/main/features/peers/peers-service-async.ts @@ -0,0 +1,39 @@ +/** + * Awaitable PeersService wrapper. + * + * Wraps `Promise` in a Proxy whose every method returns a + * Promise that awaits the underlying service before delegating. This + * eliminates the bootstrap race in `service-registry.ts` where IPC calls + * can land while the async `createPeersService` is still in flight. + * + * Type-level note: the Proxy widens every method's return type to a + * Promise, but we cast back to `PeersService` so handler/test code is + * source-compatible. Consumers MUST `await` returned values — IPC + * handlers already do this transparently because `router.handle` + * callbacks are async. + * + * Event-subscription methods (`onPinIssued`, `onDiscoveryChanged`, + * `onTrustChanged`) also return Promises here. The unsubscribe function + * is therefore wrapped in a Promise — callers must await before invoking + * the unsub. Subscribers attached before the inner promise resolves are + * registered against the real service the moment it becomes available; + * this is safe because event fan-out is bounded by current state at + * subscription time and the registry replays nothing on subscribe. + */ + +import type { PeersService } from './peers-service'; + +export function wrapAsyncPeersService(promise: Promise): PeersService { + return new Proxy({} as PeersService, { + get(_t, prop) { + return (...args: unknown[]) => + promise.then((svc) => { + const fn = (svc as unknown as Record)[prop]; + if (typeof fn !== 'function') { + throw new Error(`PeersService.${String(prop)} is not a function`); + } + return (fn as (...a: unknown[]) => unknown).apply(svc, args); + }); + }, + }); +} diff --git a/src/main/features/peers/peers-service.ts b/src/main/features/peers/peers-service.ts new file mode 100644 index 00000000..5d74088c --- /dev/null +++ b/src/main/features/peers/peers-service.ts @@ -0,0 +1,356 @@ +import type { AdcDatabase } from '@main/db'; +import { serviceLogger } from '@main/lib/logger'; + +import { gcWatermarkFromObserved } from './gc-watermark'; +import { GC_INTERVAL_MS, LOOPBACK_HOST } from './peer-constants'; +import { postJsonPinned } from './peer-http'; +import { getOrCreatePeerIdentity, type PeerIdentity } from './peer-identity'; +import { createPeerMdns, type PeerAdvertisement, type PeerMdns } from './peer-mdns'; +import { createPeerPairing } from './peer-pairing'; +import { createPeerServer, type PeerServer } from './peer-server'; +import { createPeerStore, type PairedPeer, type PeerStore } from './peer-store'; +import { resolvePeerTls, type PeerTlsMaterial } from './peer-tls'; + +import type { ReplicationEngine } from './replication-engine'; + +export interface PeersServiceDeps { + db: AdcDatabase; + dataDir: string; + engine: ReplicationEngine; + listenPort: number; + schemaHash: string; + preferMdns: boolean; + displayName?: string | null; + /** Pre-resolved identity from the registry. When omitted, falls back to + * `getOrCreatePeerIdentity(dataDir)` for back-compat with existing call + * sites and tests. */ + identity?: PeerIdentity; + /** Pre-constructed peer store from the registry. When omitted, falls back + * to `createPeerStore(db)`. */ + peerStore?: PeerStore; +} + +export interface PeersServiceSelfIdentity { + peerId: string; + pubkey: string; + fingerprint: string; + displayName: string | null; +} + +export interface DiscoveredPeerWithPaired extends Omit { + displayName: string | null; + isPaired: boolean; +} + +export interface PinIssuedInfo { + sessionId: string; + pin: string; + initiatorPeerId: string; + initiatorDisplayName?: string | null; + issuedAt: number; +} + +export interface TrustChangedEvent { + peerId: string; + action: 'added' | 'revoked' | 'updated'; +} + +export interface PairInitInput { + host: string; + port: number; + fingerprint: string; + displayName?: string | null; +} + +export interface PairInitOutput { + sessionId: string; + challenge: string; +} + +export interface PairConfirmInput { + host: string; + port: number; + fingerprint: string; + sessionId: string; + challenge: string; + pin: string; + displayName?: string | null; +} + +export interface PairConfirmOutput { + peerId: string; + pubkey: string; + fingerprint: string; +} + +export interface PeersService { + getIdentity: () => PeersServiceSelfIdentity; + getListenPort: () => number; + listPaired: () => PairedPeer[]; + listDiscovered: () => DiscoveredPeerWithPaired[]; + pairInit: (input: PairInitInput) => Promise; + pairConfirm: (input: PairConfirmInput) => Promise; + revoke: (peerId: string) => { revoked: boolean }; + onPinIssued: (handler: (info: PinIssuedInfo) => void) => () => void; + onDiscoveryChanged: (handler: (peers: DiscoveredPeerWithPaired[]) => void) => () => void; + onTrustChanged: (handler: (event: TrustChangedEvent) => void) => () => void; + dispose: () => Promise; +} + +// internal: exported for tests — keep usage limited to peers-service.ts itself +// and `tests/unit/peers/safe-fan-out.test.ts`. Audit M4: dedupes the +// fan-out boilerplate in pin/discovery/trust handler iteration. +export function safeFanOut( + handlers: Set<(value: T) => void>, + value: T, + label: string, +): void { + for (const h of handlers) { + try { + h(value); + } catch (err) { + serviceLogger.warn({ err }, `peers.peersService ${label} handler threw`); + } + } +} + +export async function createPeersService(deps: PeersServiceDeps): Promise { + const identity = deps.identity ?? getOrCreatePeerIdentity(deps.dataDir, { + allowPlaintext: process.env.ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY === '1', + }); + const tls: PeerTlsMaterial = await resolvePeerTls(deps.dataDir, identity.peerIdFull); + const peerStore: PeerStore = deps.peerStore ?? createPeerStore(deps.db); + const pairingHelper = createPeerPairing(); + + const pinHandlers = new Set<(info: PinIssuedInfo) => void>(); + const discoveryHandlers = new Set<(peers: DiscoveredPeerWithPaired[]) => void>(); + const trustHandlers = new Set<(event: TrustChangedEvent) => void>(); + + const displayName = deps.displayName ?? null; + + const server: PeerServer = await createPeerServer({ + db: deps.db, + engine: deps.engine, + tls, + selfIdentity: { + peerId: identity.peerIdFull, + pubkey: identity.pubkey, + sign: identity.sign, + }, + // Audit M6: pass through the singletons rather than letting peer-server + // construct duplicates. + peerStore, + pairing: pairingHelper, + listenPort: deps.listenPort, + host: LOOPBACK_HOST, + schemaHash: deps.schemaHash, + onConnected: (info) => { + // Inbound peer authenticated — bump lastConnectedAt for presence. + try { peerStore.updateLastConnectedAt(info.peerId, Date.now()); } + catch (err) { serviceLogger.warn({ err, peerId: info.peerId }, 'peers.peersService updateLastConnectedAt threw'); } + }, + onPinIssued: (info) => { + const enriched: PinIssuedInfo = { + sessionId: info.sessionId, + pin: info.pin, + initiatorPeerId: info.initiatorPeerId, + initiatorDisplayName: info.initiatorDisplayName ?? null, + issuedAt: Date.now(), + }; + safeFanOut(pinHandlers, enriched, 'onPinIssued'); + }, + }); + + const listenPort = server.port(); + + function enrichDiscovered(ads: PeerAdvertisement[]): DiscoveredPeerWithPaired[] { + return ads.map((ad) => ({ + ...ad, + // Normalize displayName to `string | null` to match DiscoveredPeerSchema + // (audit H4). PeerAdvertisement still uses `string | undefined` upstream. + displayName: ad.displayName ?? null, + isPaired: peerStore.getByPeerId(ad.peerId) !== null, + })); + } + + function fireDiscoveryChanged(ads: PeerAdvertisement[]): void { + const enriched = enrichDiscovered(ads); + safeFanOut(discoveryHandlers, enriched, 'onDiscoveryChanged'); + } + + function fireTrustChanged(event: TrustChangedEvent): void { + safeFanOut(trustHandlers, event, 'onTrustChanged'); + } + + function computeGcWatermark(): string | null { + return gcWatermarkFromObserved( + peerStore.listAll().map((p) => ({ + peerId: p.peerId, + revokedAt: p.revokedAt, + lastSeenHlc: p.lastSeenHlc, + })), + ); + } + + // Audit M3: dedupe initial-call vs periodic-call GC bodies. + function runGcTick(): void { + try { + const watermark = computeGcWatermark(); + if (watermark === null) return; + const result = deps.engine.gcOpLog(watermark); + if (result.deleted > 0) { + serviceLogger.info( + { deleted: result.deleted, watermarkHlc: watermark }, + 'peers.opLog.gc', + ); + } + } catch (err) { + serviceLogger.warn({ err }, 'peers.peersService gc tick threw'); + } + } + + let initialGcTimer: ReturnType | null = null; + let gcInterval: ReturnType | null = null; + + function startGc(): void { + initialGcTimer = setTimeout(runGcTick, 0); + initialGcTimer.unref(); + + gcInterval = setInterval(runGcTick, GC_INTERVAL_MS); + gcInterval.unref(); + } + + let mdns: PeerMdns | null = null; + let mdnsUnsub: (() => void) | null = null; + if (deps.preferMdns) { + mdns = createPeerMdns({ + selfPeerId: identity.peerIdFull, + fingerprint: tls.fingerprint, + port: listenPort, + displayName: displayName ?? undefined, + }); + mdnsUnsub = mdns.onChange((ads) => { fireDiscoveryChanged(ads); }); + try { + await mdns.start(); + } catch (err) { + serviceLogger.warn({ err }, 'peers.peersService mdns.start failed'); + } + } + + startGc(); + + return { + getIdentity() { + return { + peerId: identity.peerIdFull, + pubkey: identity.pubkey, + fingerprint: tls.fingerprint, + displayName, + }; + }, + + getListenPort() { + return listenPort; + }, + + listPaired() { + return peerStore.listAll(); + }, + + listDiscovered() { + return enrichDiscovered(mdns?.getSnapshot() ?? []); + }, + + async pairInit(input) { + const url = `https://${input.host}:${String(input.port)}/pair/init`; + const body = { + peerId: identity.peerIdFull, + pubkey: identity.pubkey, + fingerprint: tls.fingerprint, + displayName: input.displayName ?? displayName ?? undefined, + }; + const res = await postJsonPinned<{ sessionId: string; challenge: string }>( + url, + input.fingerprint, + body, + ); + return { sessionId: res.sessionId, challenge: res.challenge }; + }, + + async pairConfirm(input) { + const url = `https://${input.host}:${String(input.port)}/pair/confirm`; + const pinHmac = pairingHelper.computePinHmac(input.pin, input.challenge); + const res = await postJsonPinned<{ peerId: string; pubkey: string; fingerprint: string }>( + url, + input.fingerprint, + { + sessionId: input.sessionId, + pinHmac, + }, + ); + + peerStore.upsert({ + peerId: res.peerId, + pubkey: res.pubkey, + certFingerprint: res.fingerprint, + displayName: input.displayName ?? null, + pairedAt: Date.now(), + }); + fireTrustChanged({ peerId: res.peerId, action: 'added' }); + + return { peerId: res.peerId, pubkey: res.pubkey, fingerprint: res.fingerprint }; + }, + + revoke(peerId) { + const existing = peerStore.getByPeerId(peerId); + if (existing === null) return { revoked: false }; + if (existing.revokedAt !== null) return { revoked: false }; + peerStore.revoke(peerId, Date.now()); + fireTrustChanged({ peerId, action: 'revoked' }); + return { revoked: true }; + }, + + onPinIssued(handler) { + pinHandlers.add(handler); + return () => { pinHandlers.delete(handler); }; + }, + + onDiscoveryChanged(handler) { + discoveryHandlers.add(handler); + return () => { discoveryHandlers.delete(handler); }; + }, + + onTrustChanged(handler) { + trustHandlers.add(handler); + return () => { trustHandlers.delete(handler); }; + }, + + async dispose() { + if (initialGcTimer !== null) { + clearTimeout(initialGcTimer); + initialGcTimer = null; + } + if (gcInterval !== null) { + clearInterval(gcInterval); + gcInterval = null; + } + const localUnsub = mdnsUnsub; + const localMdns = mdns; + mdnsUnsub = null; + mdns = null; + if (localUnsub) { + try { localUnsub(); } + catch (err) { serviceLogger.warn({ err }, 'peers.peersService mdns unsub threw'); } + } + if (localMdns) { + try { await localMdns.stop(); } + catch (err) { serviceLogger.warn({ err }, 'peers.peersService mdns.stop threw'); } + } + try { await server.close(); } + catch (err) { serviceLogger.warn({ err }, 'peers.peersService server.close threw'); } + pinHandlers.clear(); + discoveryHandlers.clear(); + trustHandlers.clear(); + }, + }; +} diff --git a/src/main/features/peers/rate-limiter.ts b/src/main/features/peers/rate-limiter.ts new file mode 100644 index 00000000..0b0e1aa7 --- /dev/null +++ b/src/main/features/peers/rate-limiter.ts @@ -0,0 +1,78 @@ +/** + * Token-bucket per-IP rate limiter. + * + * Pure function — no timers. Tokens refill on read, capped at `capacity`. + * Buckets unused beyond `cleanupAfterMs` (default: 10× `capacity / refillPerMs`) + * are evicted opportunistically to bound memory. + * + * Used by pair-server to throttle `/pair/init` + `/pair/confirm` POSTs. + */ + +export interface IpRateLimiterOpts { + /** Maximum tokens a single IP can hold. */ + capacity: number; + /** Tokens refilled per millisecond (e.g. 1/60_000 = one token per minute). */ + refillPerMs: number; + /** Clock injection (test-only). Defaults to Date.now. */ + now?: () => number; + /** How long an idle bucket lingers before eviction. Defaults to 10× full-refill window. */ + cleanupAfterMs?: number; +} + +export interface IpRateLimiter { + /** Returns true if a token was consumed; false when the bucket is empty. */ + consume: (ip: string) => boolean; +} + +interface Bucket { + tokens: number; + lastMs: number; +} + +export function createIpRateLimiter(opts: IpRateLimiterOpts): IpRateLimiter { + const { capacity, refillPerMs } = opts; + if (capacity <= 0) throw new Error('capacity must be > 0'); + if (refillPerMs <= 0) throw new Error('refillPerMs must be > 0'); + const now = opts.now ?? Date.now; + // default cleanup window = 10× the time it takes to fully refill an empty bucket + const cleanupAfterMs = opts.cleanupAfterMs ?? (capacity / refillPerMs) * 10; + + const buckets = new Map(); + let lastCleanupMs = now(); + + function maybeCleanup(currentMs: number): void { + // amortized eviction — only sweep when we've drifted at least one cleanup window + if (currentMs - lastCleanupMs < cleanupAfterMs) return; + lastCleanupMs = currentMs; + for (const [ip, bucket] of buckets) { + if (currentMs - bucket.lastMs > cleanupAfterMs) { + buckets.delete(ip); + } + } + } + + return { + consume(ip: string): boolean { + const currentMs = now(); + maybeCleanup(currentMs); + + let bucket = buckets.get(ip); + if (bucket) { + const elapsed = currentMs - bucket.lastMs; + if (elapsed > 0) { + bucket.tokens = Math.min(capacity, bucket.tokens + elapsed * refillPerMs); + bucket.lastMs = currentMs; + } + } else { + bucket = { tokens: capacity, lastMs: currentMs }; + buckets.set(ip, bucket); + } + + if (bucket.tokens >= 1) { + bucket.tokens -= 1; + return true; + } + return false; + }, + }; +} diff --git a/src/main/features/peers/replication-engine.ts b/src/main/features/peers/replication-engine.ts new file mode 100644 index 00000000..46edc6b6 --- /dev/null +++ b/src/main/features/peers/replication-engine.ts @@ -0,0 +1,405 @@ +import { and, eq, sql } from 'drizzle-orm'; + +import { nextHlc, parseHlc, receiveHlc } from '@shared/replication/hlc'; +import { TOMBSTONE_COLUMN, type Op } from '@shared/replication/op-types'; +import { + isSyncTable, + SYNC_TABLE_DEFS, + SYNC_TABLE_PK, + type SyncTable, +} from '@shared/replication/sync-tables'; + +import type { AdcDatabase } from '@main/db'; +import { mergeOp, type RowMetaState } from '@main/features/peers/lww-merge'; +import { createOpLogService, type OpLogService } from '@main/features/peers/op-log'; +import { createPeerStore, type PeerStore } from '@main/features/peers/peer-store'; +import { rowMeta as rowMetaTable } from '@main/features/peers/schema'; +import { serviceLogger } from '@main/lib/logger'; + + +const COLUMN_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export interface ReplicationEngineDeps { + db: AdcDatabase; + peerIdShort: string; + peerIdFull: string; + clock?: () => number; + /** Optional override; when omitted the engine creates one internally. */ + peerStore?: PeerStore; +} + +interface OpLogMaxRow { m: string | null } + +/** + * Read `MAX(hlc)` from `op_log` and return the parsed parts, or `null` if + * the table is empty. Exported for unit testing without a real DB. + * Audit reference: tmp/audit/03-replication.md C6. + */ +export function seedLastHlcFromDb( + client: { prepare: (sql: string) => { get: () => unknown } }, +): string | null { + const row = client.prepare('SELECT MAX(hlc) AS m FROM op_log').get() as OpLogMaxRow | undefined; + if (row?.m === null || row?.m === undefined) return null; + // Validate parseability — a malformed row would explode `nextHlc` later. + parseHlc(row.m); + return row.m; +} + +/** + * Write a set of columns into a sync table via raw SQL. Validates each column + * name against `COLUMN_NAME_RE` (identifier syntax) and against + * `SYNC_TABLE_DEFS[tableName].columns` (closed allowlist). Throws if either + * check fails. + * + * Exported for unit testing without a real DB. Audit reference: + * tmp/audit/03-replication.md H8. + */ +export function applyColumnsToUserTableWith( + client: SqliteClient, + tableName: SyncTable, + pk: string, + columns: Record, + opType: 'insert' | 'update', +): void { + if (Object.keys(columns).length === 0) return; + const allowed = SYNC_TABLE_DEFS[tableName].columns; + for (const col of Object.keys(columns)) { + if (!COLUMN_NAME_RE.test(col)) { + throw new Error(`invalid column name in remote op: ${col}`); + } + if (!allowed.has(col)) { + throw new Error(`peers: column ${col} not in allowlist for ${tableName}`); + } + } + const cols = Object.keys(columns); + const placeholders = cols.map(() => '?').join(', '); + const values = cols.map((c) => columns[c]); + const pkCol = SYNC_TABLE_PK[tableName]; + + if (opType === 'insert') { + client + .prepare( + `INSERT INTO ${tableName} (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders}) + ON CONFLICT("${pkCol}") DO UPDATE SET ${cols.map((c) => `"${c}"=excluded."${c}"`).join(', ')}`, + ) + .run(...values); + } else { + client + .prepare(`UPDATE ${tableName} SET ${cols.map((c) => `"${c}"=?`).join(', ')} WHERE "${pkCol}"=?`) + .run(...values, pk); + } +} + +export interface RecordLocalWriteArgs { + tableName: SyncTable; + pk: string; + opType: 'insert' | 'update' | 'delete'; + columns: Record; +} + +export interface ReplicationEngine { + recordLocalWrite: (args: RecordLocalWriteArgs) => Op; + applyRemoteOp: (op: Op) => void; + getLastHlc: () => string | null; + getLocalPeerId: () => string; + onLocalOp: (listener: (op: Op) => void) => () => void; + gcOpLog: (watermarkHlc: string) => { deleted: number }; +} + +interface SqliteRunResult { + changes?: number; +} + +interface SqliteClient { + prepare: (q: string) => { + run: (...a: unknown[]) => SqliteRunResult | undefined; + get: (...a: unknown[]) => unknown; + }; +} + +/** Helper bundle that `applyDedupedOpInTx` calls into. Closures capture the + * drizzle `db` so the helper itself stays free of ORM types and can be + * exercised by pure unit tests with stubs. */ +export interface ApplyDedupedOpHelpers { + loadRowMeta: (tableName: string, pk: string) => RowMetaState; + mergeOp: typeof mergeOp; + applyColumnsToUserTable: ( + tableName: SyncTable, + pk: string, + columns: Record, + opType: 'insert' | 'update', + ) => void; + deleteFromUserTable: (tableName: SyncTable, pk: string) => void; + upsertRowMeta: ( + tableName: string, + pk: string, + updates: Array<{ columnName: string; hlc: string; originPeerId: string }>, + ) => void; + /** Delete *all* row_meta rows for (tableName, pk). Used after a winning tombstone. */ + deleteRowMetaForPk: (tableName: string, pk: string) => void; + /** Delete only the `__deleted__` row_meta row for (tableName, pk). Used on resurrection. */ + clearTombstoneRowMeta: (tableName: string, pk: string) => void; +} + +export interface ApplyDedupedOpDeps { + client: SqliteClient; + peerStore: Pick; + helpers: ApplyDedupedOpHelpers; +} + +/** + * Apply a single remote op inside an already-open transaction. + * + * Step 1: insert the op into `op_log` first with `ON CONFLICT DO NOTHING`. If + * `changes === 0` the op was already delivered — return early without + * touching `row_meta`, the user table, or the per-peer frontier. + * Step 2: run the LWW merge, mutate the user table + `row_meta`. + * Step 3: persist the per-peer frontier so the GC watermark has accurate + * `last_seen_hlc`. + * + * Audit reference: tmp/audit/03-replication.md C3 (dedup must run first to + * avoid wasted merge work on duplicate delivery). + */ +export function applyDedupedOpInTx(deps: ApplyDedupedOpDeps, op: Op): boolean { + const { client, peerStore, helpers } = deps; + + const insertResult = client + .prepare( + 'INSERT INTO op_log (hlc, origin_peer_id, table_name, pk, op_type, payload, applied_at) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(origin_peer_id, hlc) DO NOTHING', + ) + .run( + op.hlc, + op.originPeerId, + op.tableName, + op.pk, + op.opType, + JSON.stringify(op.payload), + Date.now(), + ); + + const changes = insertResult?.changes ?? 0; + if (changes === 0) { + // Duplicate delivery — skip all merge work. + return false; + } + + const meta = helpers.loadRowMeta(op.tableName, op.pk); + const result = helpers.mergeOp(meta, op); + + if (result.tombstone) { + helpers.deleteFromUserTable(op.tableName, op.pk); + helpers.deleteRowMetaForPk(op.tableName, op.pk); + } + + if (result.resurrectTombstone) { + helpers.clearTombstoneRowMeta(op.tableName, op.pk); + } + + if (Object.keys(result.columnsToApply).length > 0) { + helpers.applyColumnsToUserTable( + op.tableName, + op.pk, + result.columnsToApply, + op.opType === 'insert' ? 'insert' : 'update', + ); + } + + helpers.upsertRowMeta(op.tableName, op.pk, result.rowMetaUpdates); + + // Persist the per-peer frontier so the GC watermark has accurate + // `last_seen_hlc`. Audit reference: tmp/audit/03-replication.md C5. + peerStore.recordObserved(op.originPeerId, op.hlc); + + return true; +} + +function $client(db: AdcDatabase): SqliteClient { + return (db as unknown as { $client: SqliteClient }).$client; +} + +export function createReplicationEngine(deps: ReplicationEngineDeps): ReplicationEngine { + const { db, peerIdShort, peerIdFull } = deps; + const clock = deps.clock ?? (() => Date.now()); + const opLog: OpLogService = createOpLogService(db); + const peerStore: PeerStore = deps.peerStore ?? createPeerStore(db); + + // Seed lastHlc from op_log so HLC monotonicity survives process restart. + // Audit reference: tmp/audit/03-replication.md C6. + let lastHlc: string | null = (() => { + try { + return seedLastHlcFromDb($client(db)); + } catch (err) { + serviceLogger.warn({ err }, 'peers.replication.seedLastHlc threw — starting from null'); + return null; + } + })(); + const localOpListeners = new Set<(op: Op) => void>(); + + function loadRowMeta(tableName: string, pk: string): RowMetaState { + const rows = db + .select() + .from(rowMetaTable) + .where(and(eq(rowMetaTable.tableName, tableName), eq(rowMetaTable.pk, pk))) + .all(); + const state: RowMetaState = {}; + for (const r of rows) { + state[r.columnName] = { hlc: r.hlc, originPeerId: r.originPeerId }; + } + return state; + } + + function upsertRowMeta( + tableName: string, + pk: string, + updates: Array<{ columnName: string; hlc: string; originPeerId: string }>, + ): void { + for (const u of updates) { + db.insert(rowMetaTable) + .values({ + tableName, + pk, + columnName: u.columnName, + hlc: u.hlc, + originPeerId: u.originPeerId, + }) + .onConflictDoUpdate({ + target: [rowMetaTable.tableName, rowMetaTable.pk, rowMetaTable.columnName], + set: { hlc: u.hlc, originPeerId: u.originPeerId }, + where: sql`${rowMetaTable.hlc} < ${u.hlc}`, + }) + .run(); + } + } + + function applyColumnsToUserTable( + tableName: SyncTable, + pk: string, + columns: Record, + opType: 'insert' | 'update', + ): void { + applyColumnsToUserTableWith($client(db), tableName, pk, columns, opType); + } + + // tableName is a branded SyncTable (guarded by isSyncTable), so interpolating it + // and the PK column from SYNC_TABLE_PK is safe — both come from a closed allowlist. + function deleteFromUserTable(tableName: SyncTable, pk: string): void { + const pkCol = SYNC_TABLE_PK[tableName]; + $client(db).prepare(`DELETE FROM ${tableName} WHERE "${pkCol}"=?`).run(pk); + } + + return { + recordLocalWrite(args) { + if (!isSyncTable(args.tableName)) { + throw new Error(`recordLocalWrite called on non-sync table: ${String(args.tableName)}`); + } + + const hlc = nextHlc({ lastHlc, wallClockMs: clock(), peerIdShort }); + + const payload: Op['payload'] = {}; + if (args.opType !== 'delete') { + for (const [col, value] of Object.entries(args.columns)) { + payload[col] = { value, hlc }; + } + } + + const op: Op = { + hlc, + originPeerId: peerIdFull, + tableName: args.tableName, + pk: args.pk, + opType: args.opType, + payload, + }; + + db.transaction(() => { + opLog.append(op); + if (args.opType === 'delete') { + db.delete(rowMetaTable) + .where(and(eq(rowMetaTable.tableName, args.tableName), eq(rowMetaTable.pk, args.pk))) + .run(); + upsertRowMeta(args.tableName, args.pk, [ + { columnName: TOMBSTONE_COLUMN, hlc, originPeerId: peerIdFull }, + ]); + } else { + upsertRowMeta( + args.tableName, + args.pk, + Object.keys(args.columns).map((columnName) => ({ + columnName, + hlc, + originPeerId: peerIdFull, + })), + ); + } + lastHlc = hlc; // advance only on successful commit + }); + + for (const l of localOpListeners) { + try { + l(op); + } catch (err) { + serviceLogger.error({ err }, 'peers.replication.localOpListener threw'); + } + } + + return op; + }, + + applyRemoteOp(op) { + if (!isSyncTable(op.tableName)) return; + + lastHlc = receiveHlc(lastHlc, op.hlc); + + db.transaction(() => { + applyDedupedOpInTx( + { + client: $client(db), + peerStore, + helpers: { + loadRowMeta, + mergeOp, + applyColumnsToUserTable, + deleteFromUserTable, + upsertRowMeta, + deleteRowMetaForPk: (tableName, pk) => { + db.delete(rowMetaTable) + .where(and(eq(rowMetaTable.tableName, tableName), eq(rowMetaTable.pk, pk))) + .run(); + }, + clearTombstoneRowMeta: (tableName, pk) => { + db.delete(rowMetaTable) + .where( + and( + eq(rowMetaTable.tableName, tableName), + eq(rowMetaTable.pk, pk), + eq(rowMetaTable.columnName, TOMBSTONE_COLUMN), + ), + ) + .run(); + }, + }, + }, + op, + ); + }); + }, + + getLastHlc() { + return lastHlc; + }, + + getLocalPeerId() { + return peerIdFull; + }, + + onLocalOp(listener) { + localOpListeners.add(listener); + return () => localOpListeners.delete(listener); + }, + + gcOpLog(watermarkHlc) { + return opLog.gc(watermarkHlc); + }, + }; +} diff --git a/src/main/features/peers/schema.ts b/src/main/features/peers/schema.ts new file mode 100644 index 00000000..b7b58a1f --- /dev/null +++ b/src/main/features/peers/schema.ts @@ -0,0 +1,51 @@ +import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +export const opLog = sqliteTable( + 'op_log', + { + id: integer('id').primaryKey({ autoIncrement: true }), + hlc: text('hlc').notNull(), + originPeerId: text('origin_peer_id').notNull(), + tableName: text('table_name').notNull(), + pk: text('pk').notNull(), + opType: text('op_type', { enum: ['insert', 'update', 'delete'] }).notNull(), + payload: text('payload').notNull(), + appliedAt: integer('applied_at').notNull(), + }, + (t) => [ + uniqueIndex('op_log_dedup').on(t.originPeerId, t.hlc), + // Speeds up GC scans (`DELETE FROM op_log WHERE hlc < ?`). + // Audit reference: tmp/audit/03-replication.md H2. + index('op_log_by_hlc').on(t.hlc), + ], +); + +export const rowMeta = sqliteTable( + 'row_meta', + { + tableName: text('table_name').notNull(), + pk: text('pk').notNull(), + columnName: text('column_name').notNull(), + hlc: text('hlc').notNull(), + originPeerId: text('origin_peer_id').notNull(), + }, + (t) => [ + primaryKey({ columns: [t.tableName, t.pk, t.columnName] }), + index('row_meta_by_row').on(t.tableName, t.pk), + ], +); + +export const peerState = sqliteTable('peer_state', { + peerId: text('peer_id').primaryKey(), + displayName: text('display_name'), + pubkey: text('pubkey').notNull(), + certFingerprint: text('cert_fingerprint').notNull(), + lastSeenHlc: text('last_seen_hlc'), + pairedAt: integer('paired_at').notNull(), + lastConnectedAt: integer('last_connected_at'), + revokedAt: integer('revoked_at'), +}); + +export type OpLogRow = typeof opLog.$inferSelect; +export type RowMetaRow = typeof rowMeta.$inferSelect; +export type PeerStateRow = typeof peerState.$inferSelect; diff --git a/src/main/features/peers/validated-handle.ts b/src/main/features/peers/validated-handle.ts new file mode 100644 index 00000000..f416e705 --- /dev/null +++ b/src/main/features/peers/validated-handle.ts @@ -0,0 +1,27 @@ +import type { z } from 'zod'; + +/** + * Centralized handler factory: parses input via the channel's Zod schema, + * invokes the typed business function, and (in non-production) parses the + * output to catch service/contract drift early. + * + * Audit reference: tmp/audit/04-service-ipc.md H1, H2. + */ + +type SchemaMap = Record; + +export function validatedHandle( + map: M, + channel: C, + fn: (input: z.infer) => Promise>, +): (raw: unknown) => Promise> { + const { input, output } = map[channel]; + return async (raw) => { + const parsedIn = input.parse(raw) as z.infer; + const result = await fn(parsedIn); + if (process.env.NODE_ENV !== 'production') { + output.parse(result); + } + return result; + }; +} diff --git a/src/main/features/peers/wire-schema.ts b/src/main/features/peers/wire-schema.ts new file mode 100644 index 00000000..61a9993a --- /dev/null +++ b/src/main/features/peers/wire-schema.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +/** + * Wire-level schema for peer-to-peer WebSocket frames. + * + * Frames are JSON-encoded and parsed at a hostile trust boundary — + * any field a peer sends MUST be Zod-validated before use. `parseWireFrame` + * is the single entry point; on failure callers close the socket with + * WS code 4003 ('malformed frame'). See audit `tmp/audit/02-transport.md` H1. + */ + +export const HelloFrameSchema = z.object({ + type: z.literal('HELLO'), + peerId: z.string().min(1).max(128), + schemaHash: z.string().min(1).max(128), + nonce: z.string().min(1).max(256), + sig: z.string().min(1).max(256), +}); + +export const OpsFrameSchema = z.object({ + type: z.literal('OPS'), + ops: z.array(z.unknown()).max(1000), +}); + +export const PingFrameSchema = z.object({ + type: z.literal('PING'), +}); + +export const WireFrameSchema = z.discriminatedUnion('type', [ + HelloFrameSchema, + OpsFrameSchema, + PingFrameSchema, +]); + +export type HelloFrame = z.infer; +export type OpsFrame = z.infer; +export type PingFrame = z.infer; +export type WireFrame = z.infer; + +/** + * Parse and validate a raw WS frame. Returns a discriminated result so callers + * can distinguish JSON errors from schema errors and log the reason without + * leaking attacker-controlled content into structured logs. + */ +export function parseWireFrame( + raw: string, +): + | { ok: true; frame: WireFrame } + | { ok: false; error: string } { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return { ok: false, error: 'invalid json' }; + } + const result = WireFrameSchema.safeParse(json); + if (result.success) { + return { ok: true, frame: result.data }; + } + return { ok: false, error: result.error.message }; +} diff --git a/src/main/features/peers/ws-transport.ts b/src/main/features/peers/ws-transport.ts new file mode 100644 index 00000000..6a82221b --- /dev/null +++ b/src/main/features/peers/ws-transport.ts @@ -0,0 +1,445 @@ +import { randomBytes } from 'node:crypto'; +import { createServer as createHttpsServer, type Server as HttpsServer } from 'node:https'; + +import { WebSocket, WebSocketServer, type RawData, type ClientOptions } from 'ws'; + +import type { Op } from '@shared/replication/op-types'; + +import { + signHelloPayload, + verifyHelloSignature, +} from '@main/features/peers/hello-verify'; +import { + createOutboundDialer, + type DialResult, + type OutboundDialer, +} from '@main/features/peers/outbound-dialer'; +import { + LOOPBACK_HOST, + MAX_INBOUND_SOCKETS, + WS_CLOSE_CODES, +} from '@main/features/peers/peer-constants'; +import type { PeerStore } from '@main/features/peers/peer-store'; +import { pinnedCheckServerIdentity } from '@main/features/peers/peer-tls-pin'; +import type { ReplicationEngine } from '@main/features/peers/replication-engine'; +import { + parseWireFrame, + type HelloFrame, + type OpsFrame, + type WireFrame, +} from '@main/features/peers/wire-schema'; +import { serviceLogger } from '@main/lib/logger'; + + +export interface WsTransportTlsOpts { + cert: string; + key: string; +} + +export interface WsTransportRemotePeer { + peerId: string; + fingerprint: string; +} + +/** Self identity used to sign the outbound HELLO nonce. */ +export interface WsTransportSelfIdentity { + peerId: string; + sign: (msg: Uint8Array) => Uint8Array; +} + +export interface WsTransportDeps { + engine: ReplicationEngine; + listenPort: number; // 0 = OS-assigned + remoteUrl: string; // '' = don't connect out + schemaHash: string; + /** When provided, the inbound server runs over TLS using this material. */ + tls?: WsTransportTlsOpts; + /** When provided alongside a wss:// remoteUrl, the outbound socket pins this fingerprint. */ + remotePeer?: WsTransportRemotePeer; + /** + * Pre-existing https.Server to attach the WebSocketServer onto. When provided, + * ws-transport skips server creation/listening — caller owns the lifecycle. + * Mutually exclusive with `tls` (caller already configured TLS on the server). + */ + existingHttpsServer?: HttpsServer; + /** + * Paired-peer store, used to look up the Ed25519 pubkey for inbound HELLO + * signature verification (Task 6). The single instance is owned by + * `peers-service` and passed through `peer-server`. + * + * Optional ONLY for legacy integration tests that use plain `ws://` and + * do not exercise the auth path. Production callers (`peer-server`) always + * pass it; when absent inbound auth is skipped. + */ + peerStore?: PeerStore; + /** + * Self identity used to sign outbound HELLO frames. Required when + * `remoteUrl` is set; ignored otherwise. + * + * Optional ONLY for legacy integration tests; production callers always + * pass it. When absent, HELLO frames are sent with only `schemaHash`. + */ + selfIdentity?: WsTransportSelfIdentity; + /** + * Notified after an inbound peer's HELLO signature verifies. The service + * uses this to update presence state. + */ + onConnected?: (info: { peerId: string }) => void; +} + +export interface WsTransport { + listenPort: () => number; + isConnected: () => boolean; + close: () => Promise; +} + +/** + * Reserved close code for an outbound dialer rejecting a peer cert that + * fails the pinned fingerprint check. Pinning now runs at TLS-handshake + * time via `checkServerIdentity` (see `peer-tls-pin.ts`), so the dial path + * no longer emits this code itself — the WebSocket fails before `'open'` + * with a TLS error. Kept here for inbound code paths and documentation. + */ +void WS_CLOSE_CODES.FINGERPRINT_MISMATCH; + +function dataToString(data: RawData): string { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data).toString('utf8'); +} + +export async function createWsTransport(deps: WsTransportDeps): Promise { + const { + engine, + remoteUrl, + tls, + remotePeer, + existingHttpsServer, + peerStore, + selfIdentity, + onConnected, + } = deps; + + let outSocket: WebSocket | null = null; + const incomingSockets = new Set(); + + let ownedHttpsServer: HttpsServer | null = null; + let wss: WebSocketServer; + if (existingHttpsServer) { + wss = new WebSocketServer({ server: existingHttpsServer }); + } else if (tls) { + const server = createHttpsServer({ cert: tls.cert, key: tls.key }); + ownedHttpsServer = server; + wss = new WebSocketServer({ server }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(deps.listenPort, LOOPBACK_HOST, () => { + server.removeListener('error', reject); + resolve(); + }); + }); + } else { + wss = new WebSocketServer({ port: deps.listenPort, host: LOOPBACK_HOST }); + await new Promise((resolve) => { + wss.once('listening', () => { resolve(); }); + }); + } + // Audit C3: a single peer-side RST or stack-level error must not crash the + // agent host. Without this listener Node's EventEmitter throws when `wss` + // emits `'error'` (no listeners). + wss.on('error', (err) => { + serviceLogger.error({ err }, 'peers.wsTransport.wss error'); + }); + const addrSource = existingHttpsServer ?? ownedHttpsServer ?? wss; + const addr = addrSource.address(); + if (addr === null || typeof addr === 'string') { + throw new Error('WebSocketServer returned unexpected address'); + } + const actualPort = addr.port; + + function send(ws: WebSocket, frame: WireFrame): void { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(frame)); + } + } + + function broadcastOp(op: Op): void { + const frame: WireFrame = { type: 'OPS', ops: [op] }; + const str = JSON.stringify(frame); + if (outSocket?.readyState === WebSocket.OPEN) outSocket.send(str); + for (const ws of incomingSockets) { + if (ws.readyState === WebSocket.OPEN) ws.send(str); + } + } + + /** + * Authenticate an inbound HELLO frame against `peerStore`. Zod has already + * verified that `peerId`, `nonce`, and `sig` are non-empty strings. + * Returns true when the frame is accepted, false when the socket has been + * closed and the caller must abort. + */ + function authenticateInboundHello(ws: WebSocket, frame: HelloFrame): boolean { + if (!peerStore) return true; + const verifyResult = verifyHelloSignature( + { + peerId: frame.peerId, + schemaHash: frame.schemaHash, + nonce: frame.nonce, + sig: frame.sig, + }, + peerStore, + ); + if (!verifyResult.ok) { + serviceLogger.warn( + { peerId: frame.peerId, reason: verifyResult.reason }, + 'peers.wsTransport inbound HELLO auth failed', + ); + ws.close(WS_CLOSE_CODES.UNTRUSTED, 'untrusted'); + incomingSockets.delete(ws); + return false; + } + if (onConnected) { + try { onConnected({ peerId: frame.peerId }); } + catch (err) { + serviceLogger.warn({ err }, 'peers.wsTransport onConnected handler threw'); + } + } + return true; + } + + function handleHelloFrame( + ws: WebSocket, + frame: HelloFrame, + ctx: { isInbound: boolean }, + ): void { + if (frame.schemaHash !== deps.schemaHash) { + serviceLogger.warn( + { local: deps.schemaHash, remote: frame.schemaHash }, + 'peers.wsTransport schema mismatch — closing socket', + ); + ws.close(WS_CLOSE_CODES.SCHEMA_MISMATCH, 'schema mismatch'); + return; + } + if (ctx.isInbound) { + authenticateInboundHello(ws, frame); + } + } + + function handleOpsFrame(frame: OpsFrame): void { + // T9 will tighten per-op validation in `replication-engine.applyRemoteOp`, + // which already has a column-allowlist. For now we trust the structure + // Zod confirmed (array, length cap) and let the engine reject bad shapes. + for (const op of frame.ops as Op[]) { + try { + engine.applyRemoteOp(op); + } catch (err) { + serviceLogger.error({ err }, 'peers.wsTransport.applyRemoteOp threw'); + } + } + } + + function handleFrame(ws: WebSocket, raw: string, ctx: { isInbound: boolean }): void { + const parsed = parseWireFrame(raw); + if (!parsed.ok) { + serviceLogger.warn( + { reason: parsed.error }, + 'peers.wsTransport.malformedFrame', + ); + ws.close(WS_CLOSE_CODES.MALFORMED_FRAME, 'malformed frame'); + return; + } + const { frame } = parsed; + if (frame.type === 'HELLO') { + handleHelloFrame(ws, frame, ctx); + return; + } + if (frame.type === 'OPS') { + handleOpsFrame(frame); + } + // PING is a no-op in Phase 1 + } + + wss.on('connection', (ws) => { + // Audit M5 (Task 6): cap inbound concurrency. Reject overflow with + // WS close 1013 ('try again later'). + if (incomingSockets.size >= MAX_INBOUND_SOCKETS) { + serviceLogger.warn( + { current: incomingSockets.size, max: MAX_INBOUND_SOCKETS }, + 'peers.wsTransport inbound socket cap reached — rejecting', + ); + ws.close(1013, 'busy'); + return; + } + incomingSockets.add(ws); + // Audit C3: a peer-side RST after acceptance must not crash the agent + // host. Without an `'error'` listener the EventEmitter throws. + ws.on('error', (err) => { + serviceLogger.warn({ err }, 'peers.wsTransport.incoming error'); + }); + ws.on('message', (data: RawData) => { handleFrame(ws, dataToString(data), { isInbound: true }); }); + ws.on('close', () => incomingSockets.delete(ws)); + // Send our HELLO. In production (selfIdentity present) it is signed for + // wire-format symmetry. Legacy tests without selfIdentity emit placeholder + // nonce/sig values purely to satisfy the wire schema; the receiver in + // those tests has no peerStore, so signature verification is skipped. + if (selfIdentity) { + const nonce = randomBytes(32).toString('base64'); + const sig = signHelloPayload( + { peerId: selfIdentity.peerId, schemaHash: deps.schemaHash, nonce }, + selfIdentity.sign, + ); + send(ws, { + type: 'HELLO', + peerId: selfIdentity.peerId, + schemaHash: deps.schemaHash, + nonce, + sig, + }); + } else { + send(ws, { + type: 'HELLO', + peerId: 'legacy', + schemaHash: deps.schemaHash, + nonce: 'legacy', + sig: 'legacy', + }); + } + }); + + let shuttingDown = false; + let dialer: OutboundDialer | null = null; + + /** + * One dial attempt. Resolves with: + * - `'OK'` → WebSocket opened (HELLO sent). When this socket later + * `'close'`s, we re-arm the dialer to start the next attempt. + * - `'PERMANENT_FAIL'` → TLS fingerprint mismatch (peer-tls-pin returned + * an Error containing `'fingerprint mismatch'`). The dialer + * moves to `permanently_failed` and stops retrying. + * - `'FAIL'` → any other transient failure (close before open, network + * error, ECONNREFUSED). The dialer schedules the next attempt + * with exponential backoff + jitter. + */ + function attemptDial(): Promise { + if (!remoteUrl || shuttingDown) return Promise.resolve('FAIL'); + return new Promise((resolve) => { + const isWss = remoteUrl.startsWith('wss://'); + // Pin the remote leaf cert during the TLS handshake. With this hook a + // fingerprint mismatch fails the WebSocket before `'open'` fires, so + // there is no post-handshake fingerprint check below. + // + // The `ws` package types `checkServerIdentity` as + // `(name, cert: CertMeta) => boolean`, but at runtime Node passes the + // real `PeerCertificate` and accepts an `Error | undefined` return — + // matching `https.RequestOptions.checkServerIdentity`. We cast through + // `ClientOptions` to satisfy the looser package typing. + const wssOpts: ClientOptions = remotePeer + ? { + rejectUnauthorized: true, + checkServerIdentity: pinnedCheckServerIdentity( + remotePeer.fingerprint, + ) as unknown as ClientOptions['checkServerIdentity'], + } + : { rejectUnauthorized: true }; + const ws = isWss + ? new WebSocket(remoteUrl, wssOpts) + : new WebSocket(remoteUrl); + outSocket = ws; + + let settled = false; + let permanent = false; + + ws.on('open', () => { + if (selfIdentity) { + // Sign the outbound HELLO so the inbound peer can authenticate us + // (Task 6). Signature is over nonce_bytes || schemaHash_utf8 || peerId_utf8. + const nonce = randomBytes(32).toString('base64'); + const sig = signHelloPayload( + { peerId: selfIdentity.peerId, schemaHash: deps.schemaHash, nonce }, + selfIdentity.sign, + ); + send(ws, { + type: 'HELLO', + peerId: selfIdentity.peerId, + schemaHash: deps.schemaHash, + nonce, + sig, + }); + } else { + // Legacy test path: emit placeholder nonce/sig to satisfy the wire + // schema. Receiver with no peerStore skips signature verification. + send(ws, { + type: 'HELLO', + peerId: 'legacy', + schemaHash: deps.schemaHash, + nonce: 'legacy', + sig: 'legacy', + }); + } + if (!settled) { + settled = true; + resolve('OK'); + } + }); + ws.on('message', (data: RawData) => { + handleFrame(ws, dataToString(data), { isInbound: false }); + }); + ws.on('error', (err: Error & { code?: string }) => { + serviceLogger.warn({ err }, 'peers.wsTransport.dial error'); + const msg = err.message; + const code = err.code ?? ''; + if (msg.includes('fingerprint mismatch') || code === 'FINGERPRINT_MISMATCH') { + permanent = true; + } + }); + ws.on('close', () => { + const wasOutSocket = outSocket === ws; + if (wasOutSocket) outSocket = null; + if (!settled) { + // Closed before 'open' → transient or permanent failure. + settled = true; + resolve(permanent ? 'PERMANENT_FAIL' : 'FAIL'); + return; + } + // The socket previously emitted 'open' (we resolved 'OK' already). + // Treat the close as a request to re-arm the dialer for the next + // attempt — single-flight guard inside the dialer makes re-entrant + // start() calls a no-op when not in idle/backoff. + if (!shuttingDown) { + dialer?.start(); + } + }); + }); + } + + if (remoteUrl) { + dialer = createOutboundDialer({ attemptDial }); + dialer.start(); + } + + const unsubscribe = engine.onLocalOp((op) => broadcastOp(op)); + + return { + listenPort: () => actualPort, + isConnected: () => + outSocket?.readyState === WebSocket.OPEN || incomingSockets.size > 0, + async close() { + shuttingDown = true; + dialer?.close(); + unsubscribe(); + if (outSocket) outSocket.close(); + for (const ws of incomingSockets) ws.close(); + await new Promise((resolve) => { + wss.close(() => { resolve(); }); + }); + const server = ownedHttpsServer; + if (server) { + await new Promise((resolve) => { + server.close(() => { resolve(); }); + server.closeAllConnections(); + }); + } + }, + }; +} diff --git a/src/main/features/progress/progress-handlers.ts b/src/main/features/progress/progress-handlers.ts index 889aaf08..d1e90279 100644 --- a/src/main/features/progress/progress-handlers.ts +++ b/src/main/features/progress/progress-handlers.ts @@ -30,12 +30,12 @@ export function registerProgressHandlers( ): void { // ── Invoke Handlers ────────────────────────────────────────── - router.handle(PROGRESS.LIST.TASKS, () => progressService.listTasks()); + router.handle(PROGRESS.LIST.TASKS, ({ projectId }) => progressService.listTasks(projectId)); router.handle(PROGRESS.GET.TASK, ({ slug }) => progressService.getTask(slug)); - router.handle(PROGRESS.CREATE.TASK, ({ slug, title, description, priority, id }) => - progressService.createTask(slug, title, description, priority, id), + router.handle(PROGRESS.CREATE.TASK, ({ slug, title, description, priority, id, projectId }) => + progressService.createTask(slug, title, description, priority, id, projectId), ); router.handle(PROGRESS.UPDATE.TASK, ({ slug, updates }) => diff --git a/src/main/features/progress/progress-service.ts b/src/main/features/progress/progress-service.ts index 40570b55..c31d3979 100644 --- a/src/main/features/progress/progress-service.ts +++ b/src/main/features/progress/progress-service.ts @@ -14,13 +14,14 @@ import { watch } from 'node:fs'; import { access, mkdir, readFile, readdir, rename, rm, stat } from 'node:fs/promises'; import { join } from 'node:path'; -import { eq, ne } from 'drizzle-orm'; +import { and, eq, ne } from 'drizzle-orm'; import { generateId } from '@shared/lib/id'; import type { ProgressPriority, ProgressStatus, ProgressTask } from '@shared/types/progress'; import type { AdcDatabase } from '@main/db'; import { progressTasks } from '@main/db/schema'; +import type { ReplicationEngine } from '@main/features/peers/replication-engine'; import { serviceLogger } from '@main/lib/logger'; import { runLogCleanup as runLogCleanupFn } from './log-cleanup'; @@ -29,10 +30,47 @@ import { detectRootFile, readFrontmatter, writeFrontmatter } from './task-file-i import type { AgentManager } from '../../agent-host/agent-host-client'; import type { FSWatcher } from 'node:fs'; +// ─── Replication column-name mapping (camelCase TS → snake_case SQL) ─ + +const PROGRESS_TASK_COLUMN_MAP: Record = { + slug: 'slug', + id: 'id', + projectId: 'project_id', + title: 'title', + status: 'status', + priority: 'priority', + description: 'description', + jiraKey: 'jira_key', + jiraUrl: 'jira_url', + prUrl: 'pr_url', + prNumber: 'pr_number', + prStatus: 'pr_status', + lastSessionId: 'last_session_id', + lastAgentName: 'last_agent_name', + completedAt: 'completed_at', + archivedAt: 'archived_at', + teamName: 'team_name', + workflow: 'workflow', + workflowPhase: 'workflow_phase', + sessionHistory: 'session_history', + createdAt: 'created_at', + updatedAt: 'updated_at', +}; + +function toSnakeColumns(camelColumns: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(camelColumns)) { + if (Object.hasOwn(PROGRESS_TASK_COLUMN_MAP, k)) { + out[PROGRESS_TASK_COLUMN_MAP[k]] = v; + } + } + return out; +} + // ─── Service Interface ──────────────────────────────────────── export interface ProgressService { - listTasks: () => Promise; + listTasks: (projectId?: string) => Promise; getTask: (slug: string) => Promise; createTask: ( slug: string, @@ -40,6 +78,7 @@ export interface ProgressService { description: string, priority?: ProgressPriority, id?: string, + projectId?: string, ) => Promise; updateTask: ( slug: string, @@ -386,6 +425,7 @@ function rowToTask( return { id: row.id ?? row.slug, slug: row.slug, + projectId: row.projectId ?? undefined, rootFile: derived.rootFile, title: row.title, description: row.description ?? '', @@ -464,6 +504,7 @@ export function createProgressService( projectPath: string, agentManagerService: AgentManager, db: AdcDatabase, + replicationEngine?: ReplicationEngine, ): ProgressService { const progressDir = join(projectPath, 'progress'); const archivedDir = join(progressDir, 'archived'); @@ -726,11 +767,16 @@ export function createProgressService( // ─── Public API ─────────────────────────────────────────────── const service: ProgressService = { - async listTasks(): Promise { + async listTasks(projectId?: string): Promise { await init(); + const notArchived = ne(progressTasks.status, 'archived'); + const where = projectId === undefined + ? notArchived + : and(notArchived, eq(progressTasks.projectId, projectId)); + const rows = db.select().from(progressTasks) - .where(ne(progressTasks.status, 'archived')) + .where(where) .all(); const tasks: ProgressTask[] = []; @@ -820,6 +866,7 @@ export function createProgressService( description: string, priority: ProgressPriority = 'normal', id?: string, + projectId?: string, ): Promise { await init(); @@ -830,6 +877,7 @@ export function createProgressService( db.insert(progressTasks).values({ slug, id: resolvedId, + projectId: projectId ?? null, title, status: 'backlog', priority, @@ -838,6 +886,23 @@ export function createProgressService( updatedAt: now, }).run(); + replicationEngine?.recordLocalWrite({ + tableName: 'progress_tasks', + pk: slug, + opType: 'insert', + columns: toSnakeColumns({ + slug, + id: resolvedId, + projectId: projectId ?? null, + title, + status: 'backlog', + priority, + description: description || null, + createdAt: now, + updatedAt: now, + }), + }); + // 2. Create filesystem directory + markdown file const taskDir = join(progressDir, slug); await mkdir(taskDir, { recursive: true }); @@ -858,6 +923,7 @@ export function createProgressService( const task: ProgressTask = { id: resolvedId, slug, + projectId, rootFile: 'task.md', title, description, @@ -927,6 +993,13 @@ export function createProgressService( .where(eq(progressTasks.slug, slug)) .run(); + replicationEngine?.recordLocalWrite({ + tableName: 'progress_tasks', + pk: slug, + opType: 'update', + columns: toSnakeColumns(dbUpdates), + }); + // 2. Also rewrite frontmatter so filesystem stays in sync const taskDir = join(progressDir, slug); const rootFileName = await detectRootFile(taskDir); @@ -968,6 +1041,13 @@ export function createProgressService( .where(eq(progressTasks.slug, slug)) .run(); + replicationEngine?.recordLocalWrite({ + tableName: 'progress_tasks', + pk: slug, + opType: 'update', + columns: toSnakeColumns({ status: 'archived', archivedAt: now, updatedAt: now }), + }); + // 2. Stamp frontmatter and move directory await mkdir(archivedDir, { recursive: true }); const src = join(progressDir, slug); @@ -998,6 +1078,13 @@ export function createProgressService( .where(eq(progressTasks.slug, slug)) .run(); + replicationEngine?.recordLocalWrite({ + tableName: 'progress_tasks', + pk: slug, + opType: 'delete', + columns: {}, + }); + // 2. Remove filesystem directory const taskDir = join(progressDir, slug); await rm(taskDir, { recursive: true, force: true }); diff --git a/src/main/features/progress/schema.ts b/src/main/features/progress/schema.ts index a7e894d6..16af50df 100644 --- a/src/main/features/progress/schema.ts +++ b/src/main/features/progress/schema.ts @@ -3,6 +3,7 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; export const progressTasks = sqliteTable('progress_tasks', { slug: text('slug').primaryKey(), id: text('id'), + projectId: text('project_id'), title: text('title').notNull(), status: text('status').notNull(), priority: text('priority').notNull().default('medium'), @@ -24,4 +25,5 @@ export const progressTasks = sqliteTable('progress_tasks', { updatedAt: text('updated_at').notNull(), }, (table) => [ index('idx_progress_tasks_status').on(table.status), + index('idx_progress_tasks_project').on(table.projectId), ]); diff --git a/src/main/features/project/project-service.ts b/src/main/features/project/project-service.ts deleted file mode 100644 index 32f4f74e..00000000 --- a/src/main/features/project/project-service.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Project Service — Hub API proxy with local cache - * - * Projects are persisted via Hub API. A local in-memory cache allows - * synchronous path resolution for services that depend on getProjectPath(). - * The selectDirectory() and detectRepoStructure() remain local Electron operations. - */ - -import { existsSync, mkdirSync } from 'node:fs'; -import { basename, join } from 'node:path'; - -import { dialog } from 'electron'; - -import type { Project, SubProject } from '@shared/types'; - -import type { HubApiClient } from '../hub/hub-api-client'; - -// ─── Types ─────────────────────────────────────────────────── - -export interface ProjectAddInput { - path: string; - workspaceId?: string; - name?: string; - description?: string; - repoStructure?: Project['repoStructure']; - gitUrl?: string; - defaultBranch?: string; - subProjects?: Array<{ - name: string; - relativePath: string; - gitUrl?: string; - defaultBranch?: string; - }>; -} - -export interface ProjectUpdateInput { - projectId: string; - name?: string; - description?: string; - gitUrl?: string; - defaultBranch?: string; - workspaceId?: string; -} - -export interface ProjectService { - listProjects: (workspaceId?: string) => Promise; - addProject: (data: ProjectAddInput) => Promise; - removeProject: (projectId: string) => Promise<{ success: boolean }>; - updateProject: (data: ProjectUpdateInput) => Promise; - selectDirectory: () => Promise<{ path: string | null }>; - getSubProjects: (projectId: string) => Promise; - createSubProject: (data: { - projectId: string; - name: string; - relativePath: string; - gitUrl?: string; - defaultBranch?: string; - }) => Promise; - deleteSubProject: ( - projectId: string, - subProjectId: string, - ) => Promise<{ success: boolean }>; - - /** Initialize project-local directories (.adc/specs) */ - initializeProject: (projectId: string) => { success: boolean; error?: string }; - /** Resolve a project ID to its filesystem path (sync, for other services) */ - getProjectPath: (projectId: string) => string | undefined; - /** Sync list for legacy callers — returns cached data */ - listProjectsSync: () => Project[]; -} - -// ─── Factory ───────────────────────────────────────────────── - -export function createProjectService(deps: { - hubApiClient: HubApiClient; -}): ProjectService { - const { hubApiClient } = deps; - - // Local cache for sync access by dependent services - const projectCache = new Map(); - - /** Update the local cache from an array of projects */ - function updateCache(projects: Project[]): void { - projectCache.clear(); - for (const p of projects) { - cacheProject(p); - } - } - - /** Add or update a single project in the cache. - * Hub API returns `rootPath` while local code expects `path` — - * normalize here so getProjectPath() always works. */ - function cacheProject(project: Project): void { - const raw = project as unknown as Record; - const normalized: Project = { - ...project, - path: project.path || (raw.rootPath as string) || '', - }; - projectCache.set(normalized.id, normalized); - } - - return { - initializeProject(projectId) { - const projectPath = projectCache.get(projectId)?.path; - if (!projectPath) { - return { success: false, error: `Project ${projectId} not found` }; - } - - try { - const adcDir = join(projectPath, '.adc'); - const specsDir = join(adcDir, 'specs'); - - if (!existsSync(adcDir)) { - mkdirSync(adcDir, { recursive: true }); - } - if (!existsSync(specsDir)) { - mkdirSync(specsDir, { recursive: true }); - } - - return { success: true }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - return { success: false, error: message }; - } - }, - - getProjectPath(projectId) { - return projectCache.get(projectId)?.path; - }, - - listProjectsSync() { - return [...projectCache.values()]; - }, - - async listProjects(workspaceId) { - const endpoint = workspaceId - ? `/api/workspaces/${encodeURIComponent(workspaceId)}/projects` - : '/api/projects'; - - // Workspace endpoint returns { projects: [...] }, legacy returns raw array - const result = await hubApiClient.hubGet(endpoint); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? 'Failed to fetch projects'); - } - - const projects = Array.isArray(result.data) ? result.data : result.data.projects; - updateCache(projects); - return projects; - }, - - async addProject(data) { - const endpoint = data.workspaceId - ? `/api/workspaces/${encodeURIComponent(data.workspaceId)}/projects` - : '/api/projects'; - - const projectName = data.name ?? basename(data.path); - - const body = data.workspaceId - ? { - rootPath: data.path, - name: projectName, - description: data.description, - repoStructure: data.repoStructure ?? 'single', - gitUrl: data.gitUrl, - defaultBranch: data.defaultBranch, - subProjects: data.subProjects, - } - : { - path: data.path, - name: projectName, - description: data.description, - repoStructure: data.repoStructure, - gitUrl: data.gitUrl, - defaultBranch: data.defaultBranch, - subProjects: data.subProjects, - }; - - const result = await hubApiClient.hubPost(endpoint, body); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? 'Failed to add project'); - } - - cacheProject(result.data); - return result.data; - }, - - async removeProject(projectId) { - const result = await hubApiClient.hubDelete( - `/api/projects/${encodeURIComponent(projectId)}`, - ); - - if (!result.ok) { - throw new Error(result.error ?? `Failed to remove project ${projectId}`); - } - - projectCache.delete(projectId); - return { success: true }; - }, - - async updateProject(data) { - const { projectId, ...updates } = data; - const result = await hubApiClient.hubPatch( - `/api/projects/${encodeURIComponent(projectId)}`, - updates, - ); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? `Failed to update project ${projectId}`); - } - - cacheProject(result.data); - return result.data; - }, - - async selectDirectory() { - const result = await dialog.showOpenDialog({ - properties: ['openDirectory'], - title: 'Select Project Folder', - }); - return { path: result.filePaths[0] ?? null }; - }, - - async getSubProjects(projectId) { - const result = await hubApiClient.hubGet<{ subProjects: SubProject[] }>( - `/api/projects/${encodeURIComponent(projectId)}/sub-projects`, - ); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? `Failed to fetch sub-projects for ${projectId}`); - } - - return result.data.subProjects; - }, - - async createSubProject(data) { - const { projectId, ...body } = data; - const result = await hubApiClient.hubPost( - `/api/projects/${encodeURIComponent(projectId)}/sub-projects`, - body, - ); - - if (!result.ok || !result.data) { - throw new Error(result.error ?? `Failed to create sub-project for ${projectId}`); - } - - return result.data; - }, - - async deleteSubProject(projectId, subProjectId) { - const result = await hubApiClient.hubDelete( - `/api/projects/${encodeURIComponent(projectId)}/sub-projects/${encodeURIComponent(subProjectId)}`, - ); - - if (!result.ok) { - throw new Error( - result.error ?? `Failed to delete sub-project ${subProjectId}`, - ); - } - - return { success: true }; - }, - }; -} diff --git a/src/main/features/project/claudemd-generator.ts b/src/main/features/projects/claudemd-generator.ts similarity index 100% rename from src/main/features/project/claudemd-generator.ts rename to src/main/features/projects/claudemd-generator.ts diff --git a/src/main/features/project/codebase-analyzer.ts b/src/main/features/projects/codebase-analyzer.ts similarity index 100% rename from src/main/features/project/codebase-analyzer.ts rename to src/main/features/projects/codebase-analyzer.ts diff --git a/src/main/features/project/doc-detectors.ts b/src/main/features/projects/doc-detectors.ts similarity index 100% rename from src/main/features/project/doc-detectors.ts rename to src/main/features/projects/doc-detectors.ts diff --git a/src/main/features/project/doc-generator.ts b/src/main/features/projects/doc-generator.ts similarity index 100% rename from src/main/features/project/doc-generator.ts rename to src/main/features/projects/doc-generator.ts diff --git a/src/main/features/project/github-repo-creator.ts b/src/main/features/projects/github-repo-creator.ts similarity index 100% rename from src/main/features/project/github-repo-creator.ts rename to src/main/features/projects/github-repo-creator.ts diff --git a/src/main/features/project/gitignore-manager.ts b/src/main/features/projects/gitignore-manager.ts similarity index 100% rename from src/main/features/project/gitignore-manager.ts rename to src/main/features/projects/gitignore-manager.ts diff --git a/src/main/features/project/project-detector.ts b/src/main/features/projects/project-detector.ts similarity index 100% rename from src/main/features/project/project-detector.ts rename to src/main/features/projects/project-detector.ts diff --git a/src/main/features/project/project-handlers.ts b/src/main/features/projects/project-handlers.ts similarity index 100% rename from src/main/features/project/project-handlers.ts rename to src/main/features/projects/project-handlers.ts diff --git a/src/main/features/projects/project-service.ts b/src/main/features/projects/project-service.ts new file mode 100644 index 00000000..c4bec2c4 --- /dev/null +++ b/src/main/features/projects/project-service.ts @@ -0,0 +1,331 @@ +/** + * Project Service — local SQLite implementation + * + * Phase 5: Hub API proxy removed. Project list and sub-projects are + * persisted in the device-local SQLite database. A small in-memory + * cache is populated eagerly so dependent services can resolve + * `getProjectPath()` synchronously. + * + * Future Phase 6 may add `projects` / `sub_projects` to SYNC_TABLES + * for cross-device replication. + */ +/* eslint-disable @typescript-eslint/require-await -- public methods stay + async to preserve the existing ProjectService contract; callers await + them and Phase 6 may reintroduce real awaits for cross-device fan-out. */ + +import { existsSync, mkdirSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +import { dialog } from 'electron'; + +import { and, eq } from 'drizzle-orm'; + +import type { Project, SubProject } from '@shared/types'; + +import { serviceLogger } from '@main/lib/logger'; + +import { projects as projectsTable, subProjects as subProjectsTable } from './schema'; + +import type { ProjectRow, SubProjectRow } from './schema'; +import type { AdcDatabase } from '../../db'; + +// ─── Types ─────────────────────────────────────────────────── + +export interface ProjectAddInput { + path: string; + workspaceId?: string; + name?: string; + description?: string; + repoStructure?: Project['repoStructure']; + gitUrl?: string; + defaultBranch?: string; + subProjects?: Array<{ + name: string; + relativePath: string; + gitUrl?: string; + defaultBranch?: string; + }>; +} + +export interface ProjectUpdateInput { + projectId: string; + name?: string; + description?: string; + gitUrl?: string; + defaultBranch?: string; + workspaceId?: string; +} + +export interface ProjectService { + listProjects: (workspaceId?: string) => Promise; + addProject: (data: ProjectAddInput) => Promise; + removeProject: (projectId: string) => Promise<{ success: boolean }>; + updateProject: (data: ProjectUpdateInput) => Promise; + selectDirectory: () => Promise<{ path: string | null }>; + getSubProjects: (projectId: string) => Promise; + createSubProject: (data: { + projectId: string; + name: string; + relativePath: string; + gitUrl?: string; + defaultBranch?: string; + }) => Promise; + deleteSubProject: ( + projectId: string, + subProjectId: string, + ) => Promise<{ success: boolean }>; + + /** Initialize project-local directories (.adc/specs) */ + initializeProject: (projectId: string) => { success: boolean; error?: string }; + /** Resolve a project ID to its filesystem path (sync, for other services) */ + getProjectPath: (projectId: string) => string | undefined; + /** Sync list for legacy callers — returns cached data */ + listProjectsSync: () => Project[]; +} + +export interface CreateProjectServiceDeps { + db: AdcDatabase; +} + +// ─── Helpers ───────────────────────────────────────────────── + +function rowToProject(row: ProjectRow): Project { + // Stamp both `path` (local Project shape) and `rootPath` (Hub flavor) + // so callers and legacy serializations both keep working. + const project = { + id: row.id, + workspaceId: row.workspaceId ?? undefined, + name: row.name, + description: row.description ?? undefined, + path: row.rootPath, + rootPath: row.rootPath, + gitUrl: row.gitUrl ?? undefined, + repoStructure: row.repoStructure as Project['repoStructure'], + defaultBranch: row.defaultBranch, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + return project as Project; +} + +function rowToSubProject(row: SubProjectRow): SubProject { + return { + id: row.id, + projectId: row.projectId, + name: row.name, + relativePath: row.relativePath, + gitUrl: row.gitUrl ?? undefined, + defaultBranch: row.defaultBranch, + createdAt: row.createdAt, + }; +} + +// ─── Factory ───────────────────────────────────────────────── + +export function createProjectService(deps: CreateProjectServiceDeps): ProjectService { + const { db } = deps; + + // Local cache for sync access by dependent services + const projectCache = new Map(); + + /** Add or update a single project in the cache. + * Project rows store `rootPath` while local code expects `path` — + * normalize here so getProjectPath() always works. */ + function cacheProject(project: Project): void { + const raw = project as unknown as Record; + const normalized: Project = { + ...project, + path: project.path || (raw.rootPath as string) || '', + }; + projectCache.set(normalized.id, normalized); + } + + function updateCache(items: Project[]): void { + projectCache.clear(); + for (const p of items) cacheProject(p); + } + + // Eagerly hydrate the cache so getProjectPath() works before the + // first listProjects() call (services like worktreeService and + // runners depend on it during bootstrap). + try { + const initial = db.select().from(projectsTable).all().map(rowToProject); + updateCache(initial); + } catch (err) { + // Tolerate "no such table" only — tests that skip migrations hit this path. + // Anything else is a real bug we want to surface. + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('no such table')) { + serviceLogger.warn({ err }, 'project-service.cacheHydrate failed'); + } + } + + return { + initializeProject(projectId) { + const projectPath = projectCache.get(projectId)?.path; + if (!projectPath) { + return { success: false, error: `Project ${projectId} not found` }; + } + + try { + const adcDir = join(projectPath, '.adc'); + const specsDir = join(adcDir, 'specs'); + + if (!existsSync(adcDir)) { + mkdirSync(adcDir, { recursive: true }); + } + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + return { success: true }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: message }; + } + }, + + getProjectPath(projectId) { + return projectCache.get(projectId)?.path; + }, + + listProjectsSync() { + return [...projectCache.values()]; + }, + + async listProjects(workspaceId) { + const rows = workspaceId + ? db.select().from(projectsTable).where(eq(projectsTable.workspaceId, workspaceId)).all() + : db.select().from(projectsTable).all(); + const items = rows.map(rowToProject); + // Refresh the full cache so any orphaned entries are dropped. + const allRows = workspaceId ? db.select().from(projectsTable).all() : rows; + updateCache(allRows.map(rowToProject)); + return items; + }, + + async addProject(data) { + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + const projectName = data.name ?? basename(data.path); + + db.insert(projectsTable).values({ + id, + workspaceId: data.workspaceId ?? null, + name: projectName, + description: data.description ?? null, + rootPath: data.path, + gitUrl: data.gitUrl ?? null, + repoStructure: data.repoStructure ?? 'single', + defaultBranch: data.defaultBranch ?? 'main', + createdAt: now, + updatedAt: now, + }).run(); + + if (data.subProjects && data.subProjects.length > 0) { + for (const sub of data.subProjects) { + db.insert(subProjectsTable).values({ + id: crypto.randomUUID(), + projectId: id, + name: sub.name, + relativePath: sub.relativePath, + gitUrl: sub.gitUrl ?? null, + defaultBranch: sub.defaultBranch ?? 'main', + createdAt: now, + }).run(); + } + } + + const row = db.select().from(projectsTable).where(eq(projectsTable.id, id)).get(); + if (!row) throw new Error(`Failed to add project ${id}`); + const project = rowToProject(row); + cacheProject(project); + return project; + }, + + async removeProject(projectId) { + const existing = db + .select() + .from(projectsTable) + .where(eq(projectsTable.id, projectId)) + .get(); + if (!existing) throw new Error(`Project ${projectId} not found`); + + // Cascade by hand: delete sub_projects first, then the project. + db.delete(subProjectsTable).where(eq(subProjectsTable.projectId, projectId)).run(); + db.delete(projectsTable).where(eq(projectsTable.id, projectId)).run(); + projectCache.delete(projectId); + return { success: true }; + }, + + async updateProject(data) { + const { projectId, ...updates } = data; + + const existing = db + .select() + .from(projectsTable) + .where(eq(projectsTable.id, projectId)) + .get(); + if (!existing) throw new Error(`Project ${projectId} not found`); + + const patch: Partial = { + updatedAt: new Date().toISOString(), + }; + if (updates.name !== undefined) patch.name = updates.name; + if (updates.description !== undefined) patch.description = updates.description; + if (updates.gitUrl !== undefined) patch.gitUrl = updates.gitUrl; + if (updates.defaultBranch !== undefined) patch.defaultBranch = updates.defaultBranch; + if (updates.workspaceId !== undefined) patch.workspaceId = updates.workspaceId; + + db.update(projectsTable).set(patch).where(eq(projectsTable.id, projectId)).run(); + + const row = db.select().from(projectsTable).where(eq(projectsTable.id, projectId)).get(); + if (!row) throw new Error(`Project ${projectId} not found after update`); + const project = rowToProject(row); + cacheProject(project); + return project; + }, + + async selectDirectory() { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory'], + title: 'Select Project Folder', + }); + return { path: result.filePaths[0] ?? null }; + }, + + async getSubProjects(projectId) { + const rows = db + .select() + .from(subProjectsTable) + .where(eq(subProjectsTable.projectId, projectId)) + .all(); + return rows.map(rowToSubProject); + }, + + async createSubProject(data) { + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + db.insert(subProjectsTable).values({ + id, + projectId: data.projectId, + name: data.name, + relativePath: data.relativePath, + gitUrl: data.gitUrl ?? null, + defaultBranch: data.defaultBranch ?? 'main', + createdAt: now, + }).run(); + + const row = db.select().from(subProjectsTable).where(eq(subProjectsTable.id, id)).get(); + if (!row) throw new Error(`Failed to create sub-project ${id}`); + return rowToSubProject(row); + }, + + async deleteSubProject(projectId, subProjectId) { + db.delete(subProjectsTable) + .where(and(eq(subProjectsTable.id, subProjectId), eq(subProjectsTable.projectId, projectId))) + .run(); + return { success: true }; + }, + }; +} diff --git a/src/main/features/projects/schema.ts b/src/main/features/projects/schema.ts new file mode 100644 index 00000000..a22aebdb --- /dev/null +++ b/src/main/features/projects/schema.ts @@ -0,0 +1,31 @@ +import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const projects = sqliteTable('projects', { + id: text('id').primaryKey(), + workspaceId: text('workspace_id'), + name: text('name').notNull(), + description: text('description'), + rootPath: text('root_path').notNull(), + gitUrl: text('git_url'), + repoStructure: text('repo_structure').notNull().default('single'), + defaultBranch: text('default_branch').notNull().default('main'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), +}, (t) => [ + index('idx_projects_workspace').on(t.workspaceId), +]); + +export const subProjects = sqliteTable('sub_projects', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + name: text('name').notNull(), + relativePath: text('relative_path').notNull(), + gitUrl: text('git_url'), + defaultBranch: text('default_branch').notNull().default('main'), + createdAt: text('created_at').notNull(), +}, (t) => [ + index('idx_sub_projects_project').on(t.projectId), +]); + +export type ProjectRow = typeof projects.$inferSelect; +export type SubProjectRow = typeof subProjects.$inferSelect; diff --git a/src/main/features/project/setup-pipeline.ts b/src/main/features/projects/setup-pipeline.ts similarity index 100% rename from src/main/features/project/setup-pipeline.ts rename to src/main/features/projects/setup-pipeline.ts diff --git a/src/main/features/project/skills-resolver.ts b/src/main/features/projects/skills-resolver.ts similarity index 100% rename from src/main/features/project/skills-resolver.ts rename to src/main/features/projects/skills-resolver.ts diff --git a/src/main/features/qa/qa-runner.ts b/src/main/features/qa/qa-runner.ts index 6f096e54..76374c1d 100644 --- a/src/main/features/qa/qa-runner.ts +++ b/src/main/features/qa/qa-runner.ts @@ -25,7 +25,7 @@ import type { QaSession, QaSessionStatus, } from './qa-types'; -import type { NotificationManager } from '../integrations/notifications'; +import type { NotificationManager } from '../notifications'; export function createQaRunner( busSessionManager: BusSessionManager, diff --git a/src/main/features/qa/qa-trigger.ts b/src/main/features/qa/qa-trigger.ts index 7147c01f..2bf4b385 100644 --- a/src/main/features/qa/qa-trigger.ts +++ b/src/main/features/qa/qa-trigger.ts @@ -14,9 +14,9 @@ import type { SessionRecord } from '@main/bus/types'; import { serviceLogger } from '@main/lib/logger'; import type { QaContext, QaRunner } from './qa-types'; -import type { QaRecorderService } from './recorder'; import type { IpcRouter } from '../../ipc/router'; import type { ProgressService } from '../progress/progress-service'; +import type { TestSuiteService } from '../test-suite'; export interface QaTrigger { dispose: () => void; @@ -71,9 +71,9 @@ export function createQaTrigger(deps: { busSessionManager: BusSessionManager; progressService: ProgressService; router: IpcRouter; - qaRecorderService?: QaRecorderService; + testSuiteService?: TestSuiteService; }): QaTrigger { - const { qaRunner, busSessionManager, progressService, qaRecorderService } = deps; + const { qaRunner, busSessionManager, progressService, testSuiteService } = deps; const triggeredTasks = new Set(); function isQaAlreadyRunning(taskId: string): boolean { @@ -123,13 +123,14 @@ export function createQaTrigger(deps: { await qaRunner.startQuiet(taskId, context); // Fire recorded scripts for this project in parallel with agent QA - if (qaRecorderService) { - const scripts = qaRecorderService.scriptStore.listByProject(projectPath); + if (testSuiteService) { + const scripts = testSuiteService.scriptStore.listByProject(projectPath); for (const script of scripts) { if (!script.filePath) continue; try { - qaRecorderService.runner.run({ + testSuiteService.runner.run({ scriptId: script.id, + projectId: script.projectId, filePath: script.filePath, projectPath, triggeredBy: 'auto-trigger', diff --git a/src/main/features/qa/recorder/exporter.ts b/src/main/features/qa/recorder/exporter.ts deleted file mode 100644 index 62ac41a3..00000000 --- a/src/main/features/qa/recorder/exporter.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * QA Recorder Exporter — Generates .spec.ts files from recorded steps - * - * Converts step JSON into valid Playwright test files and writes them - * to the tests/e2e/recorded/ directory. - */ - -import { mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -export interface QaStep { - type: 'navigate' | 'click' | 'fill' | 'select' | 'press' | 'wait' | 'assert'; - url?: string; - selector?: string; - value?: string; - key?: string; - ms?: number; - expected?: string; -} - -export interface ExportedSpec { - filePath: string; - content: string; -} - -export interface QaExporter { - export: (params: { - scriptId: string; - scriptName: string; - baseUrl: string; - steps: unknown[]; - projectPath: string; - }) => ExportedSpec; -} - -function stepToCode(step: QaStep, indent = ' '): string { - switch (step.type) { - case 'navigate': - return `${indent}await page.goto(${JSON.stringify(step.url ?? '')});`; - case 'click': - return `${indent}await page.click(${JSON.stringify(step.selector ?? '')});`; - case 'fill': - return `${indent}await page.fill(${JSON.stringify(step.selector ?? '')}, ${JSON.stringify(step.value ?? '')});`; - case 'select': - return `${indent}await page.selectOption(${JSON.stringify(step.selector ?? '')}, ${JSON.stringify(step.value ?? '')});`; - case 'press': - return `${indent}await page.keyboard.press(${JSON.stringify(step.key ?? '')});`; - case 'wait': - return `${indent}await page.waitForTimeout(${step.ms ?? 0});`; - case 'assert': - return `${indent}await expect(page.locator(${JSON.stringify(step.selector ?? '')})).toHaveText(${JSON.stringify(step.expected ?? '')});`; - default: - return `${indent}// unknown step type: ${(step as { type: string }).type}`; - } -} - -function sanitizeIdentifier(name: string): string { - return name.replaceAll(/[^a-zA-Z0-9_]/g, '_').replace(/^[0-9]/, '_$&'); -} - -export function createExporter(): QaExporter { - return { - export({ scriptId, scriptName, baseUrl, steps, projectPath }) { - const outputDir = join(projectPath, 'tests', 'e2e', 'recorded'); - mkdirSync(outputDir, { recursive: true }); - - const safeName = sanitizeIdentifier(scriptName); - const fileName = `${safeName}.spec.ts`; - const filePath = join(outputDir, fileName); - - const typedSteps = steps as QaStep[]; - const stepLines = typedSteps.map((s) => stepToCode(s)).join('\n'); - - const baseUrlLine = baseUrl ? `\n await page.goto(${JSON.stringify(baseUrl)});\n` : ''; - - const content = [ - `import { expect, test } from '@playwright/test';`, - ``, - `// Generated from QA recorder script: ${scriptId}`, - `// Script name: ${scriptName}`, - ``, - `test(${JSON.stringify(scriptName)}, async ({ page }) => {${baseUrlLine}${stepLines ? `\n${stepLines}\n` : ''}});`, - ``, - ].join('\n'); - - writeFileSync(filePath, content, 'utf-8'); - - return { filePath, content }; - }, - }; -} diff --git a/src/main/features/qa/recorder/index.ts b/src/main/features/qa/recorder/index.ts deleted file mode 100644 index b99edc7a..00000000 --- a/src/main/features/qa/recorder/index.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * QA Recorder Service — Factory - * - * Composes script store, runner, and exporter into a single facade that - * satisfies the IPC handler interface expected by recorder-handlers.ts. - */ - -import type { QaRecorderStepSchema } from '@shared/ipc/qa-recorder/schemas'; - -import { createExporter } from './exporter'; -import { createRunner } from './runner'; -import { createScriptStore } from './script-store'; - -import type { QaExporter } from './exporter'; -import type { QaRunner, QaRunRecord, RunnerEventHandlers } from './runner'; -import type { ScriptStore, QaScript } from './script-store'; -import type { AdcDatabase } from '../../../db'; - -type QaRecorderStep = typeof QaRecorderStepSchema extends { _output: infer T } ? T : never; - -// ─── IPC-compatible run record (triggeredBy without 'auto-trigger') ── - -export interface QaRunIpcRecord { - id: string; - scriptId: string; - status: 'running' | 'passed' | 'failed' | 'cancelled'; - triggeredBy: 'manual' | 'scheduled' | 'ci'; - startedAt: string; - completedAt?: string; - outputLines: string[]; - screenshots: string[]; - error?: string; -} - -// ─── Run event listener type ────────────────────────────────── - -export interface QaRecorderRunEvent { - type: 'output' | 'screenshot' | 'complete'; - runId: string; - line?: string; - timestamp?: string; - screenshotPath?: string; - stepIndex?: number; - status?: QaRunRecord['status']; - report?: { - runId: string; - scriptId: string; - status: QaRunRecord['status']; - totalSteps: number; - passedSteps: number; - failedSteps: number; - duration: number; - screenshots: string[]; - outputLines: string[]; - startedAt: string; - completedAt?: string; - }; -} - -// ─── Facade interface (satisfies recorder-handlers.ts) ──────── - -export interface QaRecorderService { - // Sub-services (used by qa-trigger.ts and other internal consumers) - scriptStore: ScriptStore; - runner: QaRunner; - exporter: QaExporter; - - // Async facade methods (used by IPC handler layer) - listScripts: () => Promise; - getScript: (id: string) => Promise; - saveScript: (input: { - id?: string; - name: string; - description?: string; - steps: QaRecorderStep[]; - }) => Promise; - deleteScript: (id: string) => Promise<{ success: boolean }>; - runScript: (input: { - scriptId: string; - triggeredBy: 'manual' | 'scheduled' | 'ci'; - }) => Promise<{ runId: string }>; - getRun: (runId: string) => Promise; - listRuns: (input: { scriptId?: string }) => Promise; - exportFile: (input: { - runId: string; - format: 'json' | 'html' | 'csv'; - }) => Promise<{ filePath: string }>; - exportGithub: (input: { - runId: string; - owner: string; - repo: string; - }) => Promise<{ issueUrl: string }>; - onRunEvent: (listener: (event: QaRecorderRunEvent) => void) => void; -} - -export function createQaRecorderService(db: AdcDatabase): QaRecorderService { - const scriptStore = createScriptStore(db); - const runEventListeners: Array<(event: QaRecorderRunEvent) => void> = []; - - const sharedHandlers: RunnerEventHandlers = { - onLine(runId, line, timestamp) { - const event: QaRecorderRunEvent = { type: 'output', runId, line, timestamp }; - for (const listener of runEventListeners) listener(event); - }, - onComplete(runId, status, record) { - const startedMs = new Date(record.startedAt).getTime(); - const completedMs = record.completedAt ? new Date(record.completedAt).getTime() : Date.now(); - const event: QaRecorderRunEvent = { - type: 'complete', - runId, - status, - report: { - runId, - scriptId: record.scriptId, - status, - totalSteps: record.outputLines.length, - passedSteps: status === 'passed' ? record.outputLines.length : 0, - failedSteps: status === 'failed' ? record.outputLines.length : 0, - duration: completedMs - startedMs, - screenshots: record.screenshots, - outputLines: record.outputLines, - startedAt: record.startedAt, - completedAt: record.completedAt, - }, - }; - for (const listener of runEventListeners) listener(event); - }, - }; - - const runner = createRunner(db); - const exporter = createExporter(); - - return { - // Sub-services - scriptStore, - runner, - exporter, - - // Facade methods - listScripts: () => Promise.resolve(scriptStore.list()), - - getScript: (id) => Promise.resolve(scriptStore.get(id)), - - saveScript: (input) => Promise.resolve(scriptStore.save(input)), - - deleteScript: (id) => Promise.resolve(scriptStore.delete(id)), - - runScript({ scriptId, triggeredBy }) { - const script = scriptStore.get(scriptId); - if (!script) { - return Promise.reject(new Error(`Script not found: ${scriptId}`)); - } - if (!script.filePath) { - return Promise.reject(new Error(`Script ${scriptId} has no filePath — export it first`)); - } - const projectPath = script.projectId ?? ''; - const runId = runner.run({ - scriptId, - filePath: script.filePath, - projectPath, - triggeredBy, - handlers: sharedHandlers, - }); - return Promise.resolve({ runId }); - }, - - getRun: (runId) => Promise.resolve(runner.get(runId) as QaRunIpcRecord | null), - - listRuns: ({ scriptId }) => Promise.resolve(runner.list(scriptId) as QaRunIpcRecord[]), - - exportFile({ runId }) { - const run = runner.get(runId); - if (!run) return Promise.reject(new Error(`Run not found: ${runId}`)); - const script = scriptStore.get(run.scriptId); - if (!script) return Promise.reject(new Error(`Script not found for run ${runId}`)); - const projectPath = script.projectId ?? process.cwd(); - const result = exporter.export({ - scriptId: script.id, - scriptName: script.name, - baseUrl: '', - steps: script.steps, - projectPath, - }); - return Promise.resolve({ filePath: result.filePath }); - }, - - exportGithub() { - return Promise.reject(new Error('GitHub export not implemented — configure GitHub integration first')); - }, - - onRunEvent(listener) { - runEventListeners.push(listener); - }, - }; -} diff --git a/src/main/features/qa/recorder/recorder-handlers.ts b/src/main/features/qa/recorder/recorder-handlers.ts deleted file mode 100644 index b57a6996..00000000 --- a/src/main/features/qa/recorder/recorder-handlers.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * QA Recorder IPC Handlers - * - * Bridges qa-recorder service to the renderer via IPC. - * Thin handlers — all logic delegated to service. - */ - -import { QA_RECORDER, QA_RECORDER_EVENTS } from '@shared/ipc/qa-recorder/channels'; -import type { - QaRunReportSchema, - QaRunSchema, - QaRunStatusSchema, - QaScriptSchema, - QaRecorderStepSchema, -} from '@shared/ipc/qa-recorder/schemas'; - -import type { IpcRouter } from '../../../ipc/router'; - -// ─── Locally-inferred types from shared schemas ──────────────── -// These use `infer` to avoid importing zod directly in main/. - -type InferZodType = T['_output']; - -type QaScript = InferZodType; -type QaRun = InferZodType; -type QaRunStatus = InferZodType; -type QaRunReport = InferZodType; -type QaRecorderStep = InferZodType; - -// ─── Service Interface ───────────────────────────────────────── -// Defined locally until Task #34 merges. - -export interface QaRecorderRunEvent { - type: 'output' | 'screenshot' | 'complete'; - runId: string; - line?: string; - timestamp?: string; - screenshotPath?: string; - stepIndex?: number; - status?: QaRunStatus; - report?: QaRunReport; -} - -export interface QaRecorderService { - listScripts: () => Promise; - getScript: (id: string) => Promise; - saveScript: (input: { - id?: string; - name: string; - description?: string; - steps: QaRecorderStep[]; - }) => Promise; - deleteScript: (id: string) => Promise<{ success: boolean }>; - runScript: (input: { scriptId: string; triggeredBy: 'manual' | 'scheduled' | 'ci' }) => Promise<{ runId: string }>; - getRun: (runId: string) => Promise; - listRuns: (input: { scriptId?: string }) => Promise; - exportFile: (input: { runId: string; format: 'json' | 'html' | 'csv' }) => Promise<{ filePath: string }>; - exportGithub: (input: { runId: string; owner: string; repo: string }) => Promise<{ issueUrl: string }>; - onRunEvent: (listener: (event: QaRecorderRunEvent) => void) => void; -} - -// ─── Handler Registration ────────────────────────────────────── - -export function registerQaRecorderHandlers( - router: IpcRouter, - qaRecorderService: QaRecorderService, -): void { - // ── Event forwarding ────────────────────────────────────────── - - qaRecorderService.onRunEvent((event) => { - if (event.type === 'output' && event.line !== undefined && event.timestamp !== undefined) { - router.emit(QA_RECORDER_EVENTS.OUTPUT.LINE, { - runId: event.runId, - line: event.line, - timestamp: event.timestamp, - }); - } - - if ( - event.type === 'screenshot' && - event.screenshotPath !== undefined && - event.stepIndex !== undefined && - event.timestamp !== undefined - ) { - router.emit(QA_RECORDER_EVENTS.RUN.SCREENSHOT, { - runId: event.runId, - screenshotPath: event.screenshotPath, - stepIndex: event.stepIndex, - timestamp: event.timestamp, - }); - } - - if (event.type === 'complete' && event.status !== undefined && event.report !== undefined) { - router.emit(QA_RECORDER_EVENTS.RUN.COMPLETE, { - runId: event.runId, - status: event.status, - report: event.report, - }); - } - }); - - // ── Invoke handlers ─────────────────────────────────────────── - - router.handle(QA_RECORDER.LIST.SCRIPTS, () => - qaRecorderService.listScripts(), - ); - - router.handle(QA_RECORDER.GET.SCRIPT, ({ id }) => - qaRecorderService.getScript(id), - ); - - router.handle(QA_RECORDER.SAVE.SCRIPT, (input) => - qaRecorderService.saveScript(input), - ); - - router.handle(QA_RECORDER.DELETE.SCRIPT, ({ id }) => - qaRecorderService.deleteScript(id), - ); - - router.handle(QA_RECORDER.RUN.SCRIPT, (input) => - qaRecorderService.runScript(input), - ); - - router.handle(QA_RECORDER.GET.RUN, ({ runId }) => - qaRecorderService.getRun(runId), - ); - - router.handle(QA_RECORDER.LIST.RUNS, (input) => - qaRecorderService.listRuns(input), - ); - - router.handle(QA_RECORDER.EXPORT.FILE, (input) => - qaRecorderService.exportFile(input), - ); - - router.handle(QA_RECORDER.EXPORT.GITHUB, (input) => - qaRecorderService.exportGithub(input), - ); -} diff --git a/src/main/features/qa/recorder/runner.ts b/src/main/features/qa/recorder/runner.ts deleted file mode 100644 index 567fc4f0..00000000 --- a/src/main/features/qa/recorder/runner.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * QA Recorder Runner — Spawns Playwright via child_process.spawn - * - * Creates a QaRun record, spawns `npx playwright test` for the given - * script file, streams stdout/stderr via event callbacks, then updates - * the run record with final status and report. - */ - -import { spawn } from 'node:child_process'; - -import { eq } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; - -import { qaRuns } from '../../../db/schema'; - -import type { AdcDatabase } from '../../../db'; - -export interface QaRunRecord { - id: string; - scriptId: string; - status: 'running' | 'passed' | 'failed' | 'cancelled'; - triggeredBy: 'manual' | 'scheduled' | 'ci' | 'auto-trigger'; - startedAt: string; - completedAt?: string; - outputLines: string[]; - screenshots: string[]; - error?: string; -} - -export interface RunnerEventHandlers { - onLine?: (runId: string, line: string, timestamp: string) => void; - onComplete?: (runId: string, status: QaRunRecord['status'], record: QaRunRecord) => void; -} - -export interface QaRunner { - run: (params: { - scriptId: string; - filePath: string; - projectPath: string; - triggeredBy: 'manual' | 'scheduled' | 'ci' | 'auto-trigger'; - taskId?: string; - handlers?: RunnerEventHandlers; - }) => string; - get: (runId: string) => QaRunRecord | null; - list: (scriptId?: string) => QaRunRecord[]; - cancel: (runId: string) => void; -} - -function toRunRecord(row: typeof qaRuns.$inferSelect): QaRunRecord { - const report = row.report as { outputLines?: string[]; screenshots?: string[]; error?: string } | null; - return { - id: row.id, - scriptId: row.scriptId, - status: row.status as QaRunRecord['status'], - triggeredBy: row.triggeredBy as QaRunRecord['triggeredBy'], - startedAt: row.startedAt, - completedAt: row.completedAt ?? undefined, - outputLines: report?.outputLines ?? [], - screenshots: report?.screenshots ?? [], - error: report?.error, - }; -} - -export function createRunner(db: AdcDatabase): QaRunner { - const activeProcesses = new Map>(); - - return { - run({ scriptId, filePath, projectPath, triggeredBy, taskId, handlers }) { - const runId = nanoid(); - const now = new Date().toISOString(); - - db.insert(qaRuns).values({ - id: runId, - scriptId, - projectId: projectPath, - taskId: taskId ?? null, - sessionId: null, - status: 'running', - triggeredBy, - report: null, - startedAt: now, - completedAt: null, - }).run(); - - const outputLines: string[] = []; - const screenshots: string[] = []; - - const child = spawn('npx', ['playwright', 'test', filePath, '--reporter=line'], { - cwd: projectPath, - shell: false, - env: { ...process.env }, - }); - - activeProcesses.set(runId, child); - - function emitLine(line: string): void { - const timestamp = new Date().toISOString(); - outputLines.push(line); - handlers?.onLine?.(runId, line, timestamp); - } - - child.stdout.on('data', (chunk: Buffer) => { - const text = chunk.toString(); - for (const line of text.split('\n')) { - if (line.trim()) emitLine(line); - } - }); - - child.stderr.on('data', (chunk: Buffer) => { - const text = chunk.toString(); - for (const line of text.split('\n')) { - if (line.trim()) emitLine(line); - } - }); - - child.on('close', (code) => { - activeProcesses.delete(runId); - const completedAt = new Date().toISOString(); - const status: QaRunRecord['status'] = code === 0 ? 'passed' : 'failed'; - - const report = { outputLines, screenshots }; - db.update(qaRuns).set({ - status, - completedAt, - report, - }).where(eq(qaRuns.id, runId)).run(); - - const record: QaRunRecord = { - id: runId, - scriptId, - status, - triggeredBy, - startedAt: now, - completedAt, - outputLines, - screenshots, - }; - - handlers?.onComplete?.(runId, status, record); - }); - - child.on('error', (err) => { - activeProcesses.delete(runId); - const completedAt = new Date().toISOString(); - const report = { outputLines, screenshots, error: err.message }; - - db.update(qaRuns).set({ - status: 'failed', - completedAt, - report, - }).where(eq(qaRuns.id, runId)).run(); - - const record: QaRunRecord = { - id: runId, - scriptId, - status: 'failed', - triggeredBy, - startedAt: now, - completedAt, - outputLines, - screenshots, - error: err.message, - }; - - handlers?.onComplete?.(runId, 'failed', record); - }); - - return runId; - }, - - get(runId) { - const rows = db.select().from(qaRuns).where(eq(qaRuns.id, runId)).all(); - const row = rows.at(0); - return row ? toRunRecord(row) : null; - }, - - list(scriptId) { - const rows = scriptId - ? db.select().from(qaRuns).where(eq(qaRuns.scriptId, scriptId)).all() - : db.select().from(qaRuns).all(); - return rows.map(toRunRecord); - }, - - cancel(runId) { - const child = activeProcesses.get(runId); - if (child) { - child.kill(); - activeProcesses.delete(runId); - } - db.update(qaRuns).set({ - status: 'cancelled', - completedAt: new Date().toISOString(), - }).where(eq(qaRuns.id, runId)).run(); - }, - }; -} diff --git a/src/main/features/qa/recorder/schema.ts b/src/main/features/qa/recorder/schema.ts deleted file mode 100644 index a028b0e2..00000000 --- a/src/main/features/qa/recorder/schema.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'; - -export const qaScripts = sqliteTable('qa_scripts', { - id: text('id').primaryKey(), - projectId: text('project_id'), - name: text('name').notNull(), - baseUrl: text('base_url').notNull(), - steps: text('steps', { mode: 'json' }).$type().notNull(), - filePath: text('file_path'), - createdAt: text('created_at').notNull(), - updatedAt: text('updated_at').notNull(), -}, (table) => [ - index('idx_qa_scripts_project_id').on(table.projectId), -]); - -export const qaRuns = sqliteTable('qa_runs', { - id: text('id').primaryKey(), - scriptId: text('script_id').notNull(), - projectId: text('project_id').notNull(), - taskId: text('task_id'), - sessionId: text('session_id'), - status: text('status').notNull(), - triggeredBy: text('triggered_by').notNull(), - report: text('report', { mode: 'json' }).$type(), - startedAt: text('started_at').notNull(), - completedAt: text('completed_at'), -}, (table) => [ - index('idx_qa_runs_script_id').on(table.scriptId), - index('idx_qa_runs_project_id').on(table.projectId), - index('idx_qa_runs_status').on(table.status), -]); diff --git a/src/main/features/qa/recorder/script-store.ts b/src/main/features/qa/recorder/script-store.ts deleted file mode 100644 index 7d3db88a..00000000 --- a/src/main/features/qa/recorder/script-store.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * QA Recorder Script Store — Drizzle CRUD for qa_scripts table - */ - -import { eq } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; - -import type { QaRecorderStepSchema } from '@shared/ipc/qa-recorder/schemas'; - -import { qaScripts } from '../../../db/schema'; - -import type { AdcDatabase } from '../../../db'; - -type QaRecorderStep = typeof QaRecorderStepSchema extends { _output: infer T } ? T : never; - -export interface QaScript { - id: string; - name: string; - description?: string; - filePath?: string; - projectId?: string; - steps: QaRecorderStep[]; - createdAt: string; - updatedAt: string; -} - -export interface ScriptStore { - list: () => QaScript[]; - listByProject: (projectId: string) => QaScript[]; - get: (id: string) => QaScript | null; - save: (data: { - id?: string; - name: string; - description?: string; - steps: QaRecorderStep[]; - }) => QaScript; - delete: (id: string) => { success: boolean }; -} - -function toQaScript(row: typeof qaScripts.$inferSelect): QaScript { - return { - id: row.id, - name: row.name, - description: undefined, - filePath: row.filePath ?? undefined, - projectId: row.projectId ?? undefined, - steps: row.steps as QaRecorderStep[], - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -export function createScriptStore(db: AdcDatabase): ScriptStore { - return { - list() { - return db.select().from(qaScripts).all().map(toQaScript); - }, - - listByProject(projectId) { - return db.select().from(qaScripts).where(eq(qaScripts.projectId, projectId)).all().map(toQaScript); - }, - - get(id) { - const rows = db.select().from(qaScripts).where(eq(qaScripts.id, id)).all(); - const row = rows.at(0); - return row ? toQaScript(row) : null; - }, - - save(data) { - const now = new Date().toISOString(); - const existing = data.id - ? db.select().from(qaScripts).where(eq(qaScripts.id, data.id)).all().at(0) - : undefined; - - if (existing) { - const updated = { - ...existing, - name: data.name, - steps: data.steps, - updatedAt: now, - }; - db.update(qaScripts).set(updated).where(eq(qaScripts.id, existing.id)).run(); - return toQaScript(updated); - } - - const record = { - id: data.id ?? nanoid(), - name: data.name, - baseUrl: '', - steps: data.steps, - projectId: null, - filePath: null, - createdAt: now, - updatedAt: now, - }; - db.insert(qaScripts).values(record).run(); - return toQaScript(record); - }, - - delete(id) { - const result = db.delete(qaScripts).where(eq(qaScripts.id, id)).run(); - return { success: result.changes > 0 }; - }, - }; -} diff --git a/src/main/features/runners/health-check.ts b/src/main/features/runners/health-check.ts new file mode 100644 index 00000000..bad264f6 --- /dev/null +++ b/src/main/features/runners/health-check.ts @@ -0,0 +1,41 @@ +export interface HealthResult { + healthy: boolean; + statusCode?: number; + responseTimeMs?: number; + error?: string; +} + +export interface PollOptions { + url: string; + timeoutMs: number; + intervalMs?: number; + fetchFn?: typeof fetch; + signal?: AbortSignal; +} + +export async function pollUntilHealthy(opts: PollOptions): Promise { + const { url, timeoutMs, intervalMs = 500, fetchFn = fetch, signal } = opts; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (signal?.aborted) return { healthy: false, error: 'aborted' }; + + const started = Date.now(); + try { + const res = await fetchFn(url, { signal }); + if (res.status >= 200 && res.status < 400) { + return { healthy: true, statusCode: res.status, responseTimeMs: Date.now() - started }; + } + } catch { + // transient failure — keep polling + } + + await sleep(intervalMs); + } + + return { healthy: false, error: 'timeout' }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { setTimeout(resolve, ms); }); +} diff --git a/src/main/features/runners/index.ts b/src/main/features/runners/index.ts new file mode 100644 index 00000000..b10c08fc --- /dev/null +++ b/src/main/features/runners/index.ts @@ -0,0 +1,3 @@ +export { createRunnersService } from './runners-service'; +export type { RunnersService, RunnersServiceDeps } from './runners-service'; +export { registerRunnerHandlers } from './runners-handlers'; diff --git a/src/main/features/runners/process-supervisor.ts b/src/main/features/runners/process-supervisor.ts new file mode 100644 index 00000000..64db8e3f --- /dev/null +++ b/src/main/features/runners/process-supervisor.ts @@ -0,0 +1,74 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +export interface SpawnOptions { + id: string; + command: string; + cwd: string; + env: Record; +} + +export interface SpawnHandle { + id: string; + pid: number | undefined; +} + +export interface SupervisorEvents { + output: (payload: { id: string; stream: 'stdout' | 'stderr'; chunk: string }) => void; + exit: (payload: { id: string; code: number | null; signal: NodeJS.Signals | null }) => void; + error: (payload: { id: string; message: string }) => void; +} + +export class ProcessSupervisor extends EventEmitter { + private procs = new Map(); + + spawn(opts: SpawnOptions): SpawnHandle { + // `command` is a single shell-style string (supports quoting, pipes, etc.), + // so always run it through a shell. On Windows this is cmd.exe; elsewhere /bin/sh. + const proc = spawn(opts.command, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + this.procs.set(opts.id, proc); + + proc.stdout.on('data', (buf: Buffer) => { + this.emit('output', { id: opts.id, stream: 'stdout', chunk: buf.toString('utf8') }); + }); + proc.stderr.on('data', (buf: Buffer) => { + this.emit('output', { id: opts.id, stream: 'stderr', chunk: buf.toString('utf8') }); + }); + proc.on('error', (err) => { + this.emit('error', { id: opts.id, message: err.message }); + }); + proc.on('exit', (code, signal) => { + this.procs.delete(opts.id); + this.emit('exit', { id: opts.id, code, signal }); + }); + + return { id: opts.id, pid: proc.pid }; + } + + kill(id: string, signal: NodeJS.Signals = 'SIGTERM'): boolean { + const proc = this.procs.get(id); + if (!proc) return false; + try { + proc.kill(signal); + } catch { + return false; + } + return true; + } + + isAlive(id: string): boolean { + return this.procs.has(id); + } + + killAll(): void { + for (const id of this.procs.keys()) { + this.kill(id); + } + } +} diff --git a/src/main/features/runners/runners-handlers.ts b/src/main/features/runners/runners-handlers.ts new file mode 100644 index 00000000..94abdfba --- /dev/null +++ b/src/main/features/runners/runners-handlers.ts @@ -0,0 +1,29 @@ +import { RUNNERS } from '@shared/ipc/runners/channels'; + +import type { IpcRouter } from '@main/ipc/router'; + +import type { RunnersService } from './runners-service'; + +export function registerRunnerHandlers(router: IpcRouter, service: RunnersService): void { + router.handle(RUNNERS.PROFILE.LIST, ({ projectId }) => service.listProfiles(projectId) as never); + + router.handle(RUNNERS.PROFILE.SAVE, ({ profile }) => service.saveProfile(profile) as never); + + router.handle(RUNNERS.PROFILE.DELETE, ({ profileId }) => + service.deleteProfile(profileId) as never, + ); + + router.handle(RUNNERS.INSTANCE.LIST, ({ scope }) => service.listInstances(scope) as never); + + router.handle(RUNNERS.INSTANCE.START, ({ profileId, scope }) => + service.startInstance(profileId, scope) as never, + ); + + router.handle(RUNNERS.INSTANCE.STOP, ({ instanceId }) => + service.stopInstance(instanceId) as never, + ); + + router.handle(RUNNERS.INSTANCE.RESTART, ({ instanceId }) => + service.restartInstance(instanceId) as never, + ); +} diff --git a/src/main/features/runners/runners-service.ts b/src/main/features/runners/runners-service.ts new file mode 100644 index 00000000..1477f685 --- /dev/null +++ b/src/main/features/runners/runners-service.ts @@ -0,0 +1,348 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { and, eq } from 'drizzle-orm'; + +import { RUNNERS_EVENTS } from '@shared/ipc/runners/channels'; +import type { + RunnerInstance, + RunnerProfile, + RunnerStatus, + ScopeRef, +} from '@shared/ipc/runners/schemas'; +import { generateId } from '@shared/lib/id'; + +import type { AdcDatabase } from '@main/db'; +import type { IpcRouter } from '@main/ipc/router'; + +import { pollUntilHealthy } from './health-check'; +import { ProcessSupervisor } from './process-supervisor'; +import { runnerInstances, runnerProfiles } from './schema'; + +interface ProjectServiceLike { + getProjectPath: (projectId: string) => string | undefined; +} + +export interface RunnersServiceDeps { + db: AdcDatabase; + router: IpcRouter; + projectService: ProjectServiceLike; +} + +export interface RunnersService { + listProfiles: (projectId: string) => RunnerProfile[]; + saveProfile: (profile: RunnerProfile) => RunnerProfile; + deleteProfile: (profileId: string) => { success: boolean }; + + listInstances: (scope: ScopeRef) => RunnerInstance[]; + startInstance: (profileId: string, scope: ScopeRef) => RunnerInstance; + stopInstance: (instanceId: string) => { success: boolean }; + restartInstance: (instanceId: string) => RunnerInstance; + + dispose: () => void; +} + +export function createRunnersService(deps: RunnersServiceDeps): RunnersService { + const { db, router, projectService } = deps; + const supervisor = new ProcessSupervisor(); + const healthControllers = new Map(); + + supervisor.on('output', ({ id, stream, chunk }: { id: string; stream: 'stdout' | 'stderr'; chunk: string }) => { + router.emit(RUNNERS_EVENTS.INSTANCE.OUTPUT, { instanceId: id, stream, chunk }); + }); + supervisor.on('exit', ({ id, code }: { id: string; code: number | null }) => { + const instanceRow = db + .select() + .from(runnerInstances) + .where(eq(runnerInstances.id, id)) + .get(); + const wasStopping = instanceRow?.status === 'stopping'; + + const nextStatus: RunnerStatus = code === 0 ? 'stopped' : 'failed'; + updateInstance(id, { + status: nextStatus, + exitCode: code ?? undefined, + stoppedAt: new Date().toISOString(), + }); + router.emit(RUNNERS_EVENTS.INSTANCE.STATUS, { + instanceId: id, + status: nextStatus, + exitCode: code, + }); + healthControllers.get(id)?.abort(); + healthControllers.delete(id); + + if (!wasStopping && code !== 0 && instanceRow) { + const profileRow = db + .select() + .from(runnerProfiles) + .where(eq(runnerProfiles.id, instanceRow.profileId)) + .get(); + if (profileRow?.autoRestart) { + const inst = rowToInstance(instanceRow); + console.warn( + `[runners] auto-restart triggered for profile ${profileRow.id} after abnormal exit (code=${code ?? 'null'})`, + ); + queueMicrotask(() => { + try { + startInstance(profileRow.id, inst.scope); + } catch (err) { + console.error('[runners] auto-restart failed:', err); + } + }); + } + } + }); + supervisor.on('error', ({ id, message }: { id: string; message: string }) => { + updateInstance(id, { status: 'failed', lastError: message }); + router.emit(RUNNERS_EVENTS.INSTANCE.STATUS, { + instanceId: id, + status: 'failed', + lastError: message, + }); + }); + + function rowToProfile(row: typeof runnerProfiles.$inferSelect): RunnerProfile { + return { + id: row.id, + projectId: row.projectId, + name: row.name, + command: row.command, + cwdRelative: row.cwdRelative, + env: JSON.parse(row.envJson) as Record, + healthCheckUrl: row.healthCheckUrl ?? undefined, + healthCheckTimeoutMs: row.healthCheckTimeoutMs, + autoRestart: row.autoRestart, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + function rowToInstance(row: typeof runnerInstances.$inferSelect): RunnerInstance { + const scope: ScopeRef = + row.scopeKind === 'project' + ? { kind: 'project', projectId: row.scopeProjectId } + : { + kind: 'worktree', + projectId: row.scopeProjectId, + worktreePath: row.scopeWorktreePath ?? '', + }; + return { + id: row.id, + profileId: row.profileId, + scope, + status: row.status as RunnerStatus, + pid: row.pid ?? undefined, + resolvedCwd: row.resolvedCwd, + resolvedCommand: row.resolvedCommand, + exitCode: row.exitCode ?? null, + startedAt: row.startedAt ?? undefined, + readyAt: row.readyAt ?? undefined, + stoppedAt: row.stoppedAt ?? undefined, + lastError: row.lastError ?? undefined, + }; + } + + function updateInstance( + id: string, + patch: Partial, + ): void { + db.update(runnerInstances).set(patch).where(eq(runnerInstances.id, id)).run(); + } + + function resolveCwd(profile: RunnerProfile, scope: ScopeRef): string { + const base = + scope.kind === 'worktree' + ? scope.worktreePath + : projectService.getProjectPath(scope.projectId); + if (!base) throw new Error(`Cannot resolve cwd for scope ${JSON.stringify(scope)}`); + const resolved = profile.cwdRelative === '.' ? base : join(base, profile.cwdRelative); + if (!existsSync(resolved)) throw new Error(`Resolved cwd does not exist: ${resolved}`); + return resolved; + } + + function startHealthCheck(instanceId: string, url: string, timeoutMs: number): void { + const controller = new AbortController(); + healthControllers.set(instanceId, controller); + void pollUntilHealthy({ url, timeoutMs, signal: controller.signal }) + .then((result) => { + router.emit(RUNNERS_EVENTS.INSTANCE.HEALTH, { + instanceId, + healthy: result.healthy, + statusCode: result.statusCode, + responseTimeMs: result.responseTimeMs, + }); + if (result.healthy) { + updateInstance(instanceId, { status: 'ready', readyAt: new Date().toISOString() }); + router.emit(RUNNERS_EVENTS.INSTANCE.STATUS, { instanceId, status: 'ready' }); + } + return result; + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + updateInstance(instanceId, { status: 'failed', lastError: message }); + router.emit(RUNNERS_EVENTS.INSTANCE.STATUS, { + instanceId, + status: 'failed', + lastError: message, + }); + }) + .finally(() => { + healthControllers.delete(instanceId); + }); + } + + function startInstance(profileId: string, scope: ScopeRef): RunnerInstance { + const profileRow = db + .select() + .from(runnerProfiles) + .where(eq(runnerProfiles.id, profileId)) + .get(); + if (!profileRow) throw new Error(`Profile ${profileId} not found`); + const profile = rowToProfile(profileRow); + + const resolvedCwd = resolveCwd(profile, scope); + const id = generateId(); + const now = new Date().toISOString(); + + db.insert(runnerInstances) + .values({ + id, + profileId, + scopeKind: scope.kind, + scopeProjectId: scope.projectId, + scopeWorktreePath: scope.kind === 'worktree' ? scope.worktreePath : null, + status: 'starting', + resolvedCwd, + resolvedCommand: profile.command, + startedAt: now, + }) + .run(); + + router.emit(RUNNERS_EVENTS.INSTANCE.STATUS, { instanceId: id, status: 'starting' }); + + const handle = supervisor.spawn({ + id, + command: profile.command, + cwd: resolvedCwd, + env: profile.env, + }); + + updateInstance(id, { pid: handle.pid ?? null, status: 'running' }); + router.emit(RUNNERS_EVENTS.INSTANCE.STATUS, { instanceId: id, status: 'running' }); + + if (profile.healthCheckUrl) { + startHealthCheck(id, profile.healthCheckUrl, profile.healthCheckTimeoutMs); + } else { + updateInstance(id, { status: 'ready', readyAt: new Date().toISOString() }); + router.emit(RUNNERS_EVENTS.INSTANCE.STATUS, { instanceId: id, status: 'ready' }); + } + + const freshRow = db + .select() + .from(runnerInstances) + .where(eq(runnerInstances.id, id)) + .get(); + if (!freshRow) throw new Error('Instance row vanished'); + return rowToInstance(freshRow); + } + + return { + listProfiles(projectId) { + return db + .select() + .from(runnerProfiles) + .where(eq(runnerProfiles.projectId, projectId)) + .all() + .map(rowToProfile); + }, + + saveProfile(profile) { + db.insert(runnerProfiles) + .values({ + id: profile.id, + projectId: profile.projectId, + name: profile.name, + command: profile.command, + cwdRelative: profile.cwdRelative, + envJson: JSON.stringify(profile.env), + healthCheckUrl: profile.healthCheckUrl ?? null, + healthCheckTimeoutMs: profile.healthCheckTimeoutMs, + autoRestart: profile.autoRestart, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + }) + .onConflictDoUpdate({ + target: runnerProfiles.id, + set: { + name: profile.name, + command: profile.command, + cwdRelative: profile.cwdRelative, + envJson: JSON.stringify(profile.env), + healthCheckUrl: profile.healthCheckUrl ?? null, + healthCheckTimeoutMs: profile.healthCheckTimeoutMs, + autoRestart: profile.autoRestart, + updatedAt: profile.updatedAt, + }, + }) + .run(); + return profile; + }, + + deleteProfile(profileId) { + db.delete(runnerProfiles).where(eq(runnerProfiles.id, profileId)).run(); + return { success: true }; + }, + + listInstances(scope) { + const rows = db + .select() + .from(runnerInstances) + .where( + scope.kind === 'worktree' + ? and( + eq(runnerInstances.scopeProjectId, scope.projectId), + eq(runnerInstances.scopeKind, 'worktree'), + eq(runnerInstances.scopeWorktreePath, scope.worktreePath), + ) + : and( + eq(runnerInstances.scopeProjectId, scope.projectId), + eq(runnerInstances.scopeKind, 'project'), + ), + ) + .all(); + return rows.map(rowToInstance); + }, + + startInstance, + + stopInstance(instanceId) { + updateInstance(instanceId, { status: 'stopping' }); + router.emit(RUNNERS_EVENTS.INSTANCE.STATUS, { instanceId, status: 'stopping' }); + healthControllers.get(instanceId)?.abort(); + healthControllers.delete(instanceId); + supervisor.kill(instanceId); + return { success: true }; + }, + + restartInstance(instanceId) { + const row = db + .select() + .from(runnerInstances) + .where(eq(runnerInstances.id, instanceId)) + .get(); + if (!row) throw new Error(`Instance ${instanceId} not found`); + healthControllers.get(instanceId)?.abort(); + healthControllers.delete(instanceId); + supervisor.kill(instanceId); + const inst = rowToInstance(row); + return startInstance(inst.profileId, inst.scope); + }, + + dispose() { + for (const c of healthControllers.values()) c.abort(); + healthControllers.clear(); + supervisor.killAll(); + }, + }; +} diff --git a/src/main/features/runners/schema.ts b/src/main/features/runners/schema.ts new file mode 100644 index 00000000..660c4ba5 --- /dev/null +++ b/src/main/features/runners/schema.ts @@ -0,0 +1,45 @@ +import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core'; + +export const runnerProfiles = sqliteTable( + 'runner_profiles', + { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + name: text('name').notNull(), + command: text('command').notNull(), + cwdRelative: text('cwd_relative').notNull().default('.'), + envJson: text('env_json').notNull().default('{}'), + healthCheckUrl: text('health_check_url'), + healthCheckTimeoutMs: integer('health_check_timeout_ms').notNull().default(30_000), + autoRestart: integer('auto_restart', { mode: 'boolean' }).notNull().default(false), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (t) => [ + index('idx_runner_profiles_project').on(t.projectId), + ], +); + +export const runnerInstances = sqliteTable( + 'runner_instances', + { + id: text('id').primaryKey(), + profileId: text('profile_id').notNull(), + scopeKind: text('scope_kind').notNull(), + scopeProjectId: text('scope_project_id').notNull(), + scopeWorktreePath: text('scope_worktree_path'), + status: text('status').notNull(), + pid: integer('pid'), + resolvedCwd: text('resolved_cwd').notNull(), + resolvedCommand: text('resolved_command').notNull(), + exitCode: integer('exit_code'), + startedAt: text('started_at'), + readyAt: text('ready_at'), + stoppedAt: text('stopped_at'), + lastError: text('last_error'), + }, + (t) => [ + index('idx_runner_instances_profile').on(t.profileId), + index('idx_runner_instances_scope').on(t.scopeProjectId, t.scopeKind), + ], +); diff --git a/src/main/features/settings/security-handlers.ts b/src/main/features/security/security-handlers.ts similarity index 95% rename from src/main/features/settings/security-handlers.ts rename to src/main/features/security/security-handlers.ts index 5e1d9554..28327f02 100644 --- a/src/main/features/settings/security-handlers.ts +++ b/src/main/features/security/security-handlers.ts @@ -10,8 +10,8 @@ import { ipcInvokeContract } from '@shared/ipc-contract'; import { DEFAULT_SECURITY_SETTINGS } from '@shared/types/security'; import type { SecuritySettings } from '@shared/types/security'; -import type { SettingsService } from "./settings-service"; import type { IpcRouter } from '../../ipc/router'; +import type { SettingsService } from '../settings/settings-service'; export function registerSecurityHandlers(router: IpcRouter, service: SettingsService): void { router.handle(SECURITY.GET.SETTINGS, () => { diff --git a/src/main/features/settings/data-management.ts b/src/main/features/settings/data-management.ts index 5643d837..6bf2bfea 100644 --- a/src/main/features/settings/data-management.ts +++ b/src/main/features/settings/data-management.ts @@ -1,5 +1,5 @@ /** - * Settings — Data management sub-module barrel + * Settings — Data management sub-module barrel (re-exports from top-level feature) */ export { @@ -8,14 +8,14 @@ export { createUserDataMigrator, createUserDataResolver, isReinitializable, -} from './data-management/index'; -export { createCleanupService } from './data-management/cleanup-service'; -export { createStorageInspector } from './data-management/storage-inspector'; -export { registerDataDirHandlers } from './data-management/data-dir-handlers'; -export { registerDataManagementHandlers } from './data-management/data-management-handlers'; + createCleanupService, + createStorageInspector, + registerDataDirHandlers, + registerDataManagementHandlers, +} from '../data-management'; -export type { CleanupService } from './data-management/cleanup-service'; -export type { ConfigReader } from './data-management/config-reader'; -export type { DataMigrator } from './data-management/data-migrator'; -export type { StorageInspector } from './data-management/storage-inspector'; -export type { ReinitializableService } from './data-management/reinitializable-service'; +export type { CleanupService } from '../data-management/cleanup-service'; +export type { ConfigReader } from '../data-management/config-reader'; +export type { DataMigrator } from '../data-management/data-migrator'; +export type { StorageInspector } from '../data-management/storage-inspector'; +export type { ReinitializableService } from '../data-management/reinitializable-service'; diff --git a/src/main/features/settings/data-management/index.ts b/src/main/features/settings/data-management/index.ts deleted file mode 100644 index 60375add..00000000 --- a/src/main/features/settings/data-management/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { createCleanupService } from './cleanup-service'; -export type { CleanupService } from './cleanup-service'; -export { createStorageInspector } from './storage-inspector'; -export type { StorageInspector } from './storage-inspector'; -export { exportData, importData } from './data-export'; -export { DATA_STORE_REGISTRY } from './store-registry'; -export { STORE_CLEANUP_FUNCTIONS } from './store-cleaners'; -export { createUserDataResolver, type UserDataResolver } from './user-data-resolver'; -export { type ReinitializableService, isReinitializable } from './reinitializable-service'; -export { createUserDataMigrator, type UserDataMigrator } from './user-data-migrator'; -export { createConfigReader, type ConfigReader } from './config-reader'; -export { createDataMigrator, type DataMigrator } from './data-migrator'; -export { registerDataDirHandlers } from './data-dir-handlers'; diff --git a/src/main/features/settings/hotkeys.ts b/src/main/features/settings/hotkeys.ts index ce429293..3fab4385 100644 --- a/src/main/features/settings/hotkeys.ts +++ b/src/main/features/settings/hotkeys.ts @@ -2,7 +2,7 @@ * Hotkey IPC handlers — get, update, reset global hotkeys */ -import { HOTKEYS } from '@shared/ipc/misc/hotkeys.channels'; +import { HOTKEYS } from '@shared/ipc/hotkeys'; import { DEFAULT_HOTKEYS } from '../../tray/hotkey-manager'; diff --git a/src/main/features/settings/screen/screen-handlers.ts b/src/main/features/settings/screen/screen-handlers.ts index 1277aa77..c6e39587 100644 --- a/src/main/features/settings/screen/screen-handlers.ts +++ b/src/main/features/settings/screen/screen-handlers.ts @@ -2,7 +2,7 @@ * Screen capture IPC handlers */ -import { SCREEN } from '@shared/ipc/misc/screen.channels'; +import { SCREEN } from '@shared/ipc/screen'; import type { ScreenCaptureService } from "./screen-capture-service"; import type { IpcRouter } from '../../../ipc/router'; diff --git a/src/main/features/settings/settings-defaults.ts b/src/main/features/settings/settings-defaults.ts index 5ab5598e..4372936a 100644 --- a/src/main/features/settings/settings-defaults.ts +++ b/src/main/features/settings/settings-defaults.ts @@ -16,6 +16,7 @@ export const DEFAULT_AGENT_SETTINGS: AgentSettings = { export const DEFAULT_SETTINGS: AppSettings = { theme: 'system', colorTheme: 'default', + customThemes: [], language: 'en', uiScale: 100, onboardingCompleted: false, @@ -25,6 +26,8 @@ export const DEFAULT_SETTINGS: AppSettings = { activeProjectId: null, lastRoutePerProject: {}, sidebarCollapsed: false, + layoutGap: 8, + layoutPreset: 'default', }; export const DEFAULT_PROFILES: Profile[] = [{ id: 'default', name: 'Default', isDefault: true }]; diff --git a/src/main/features/settings/settings-service.ts b/src/main/features/settings/settings-service.ts index f6484ab4..5fbe8190 100644 --- a/src/main/features/settings/settings-service.ts +++ b/src/main/features/settings/settings-service.ts @@ -24,7 +24,7 @@ export interface LayoutState { activeProjectId: string | null; lastRoutePerProject: Record; sidebarCollapsed: boolean; - sidebarLayout: string; + layoutPreset: string; } export interface LayoutUpdate { @@ -32,7 +32,7 @@ export interface LayoutUpdate { activeProjectId?: string | null; lastRoutePerProject?: Record; sidebarCollapsed?: boolean; - sidebarLayout?: string; + layoutPreset?: string; } export interface SettingsService { @@ -227,12 +227,18 @@ export function createSettingsService(deps: { db: AdcDatabase; dataDir: string } }, getLayout() { + // Migration: map legacy individual fields to nearest preset + const legacy = store.settings as unknown as Record; + let preset = (store.settings.layoutPreset as string | undefined) ?? 'default'; + if (!store.settings.layoutPreset && (legacy.toolbarStyle === 'floating' || legacy.sidebarLayout === 'sidebar-04')) { + preset = 'floating'; + } return { openProjectTabs: store.settings.openProjectTabs ?? [], activeProjectId: store.settings.activeProjectId ?? null, lastRoutePerProject: store.settings.lastRoutePerProject ?? {}, sidebarCollapsed: store.settings.sidebarCollapsed ?? false, - sidebarLayout: store.settings.sidebarLayout ?? 'sidebar-01', + layoutPreset: preset, }; }, @@ -249,8 +255,8 @@ export function createSettingsService(deps: { db: AdcDatabase; dataDir: string } if (updates.sidebarCollapsed !== undefined) { store.settings.sidebarCollapsed = updates.sidebarCollapsed; } - if (updates.sidebarLayout !== undefined) { - store.settings.sidebarLayout = updates.sidebarLayout as AppSettings['sidebarLayout']; + if (updates.layoutPreset !== undefined) { + store.settings.layoutPreset = updates.layoutPreset as AppSettings['layoutPreset']; } persist(); return { success: true }; diff --git a/src/main/features/settings/settings-store.ts b/src/main/features/settings/settings-store.ts index 872e3de0..8f21c714 100644 --- a/src/main/features/settings/settings-store.ts +++ b/src/main/features/settings/settings-store.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { generateId } from '@shared/lib/id'; import type { AppSettings, Profile } from '@shared/types'; @@ -76,13 +76,40 @@ function decryptWebhookValue( return { decrypted: '', needsMigration: false }; } +const SETTINGS_KEY = 'app-settings'; +const SETTINGS_CATEGORY = 'settings'; + // ── JSON Migration ───────────────────────────────────────────── /** One-time migration from legacy settings.json into SQLite tables. */ export function migrateFromJson(db: AdcDatabase, dataDir: string): void { - // Only migrate if settingsKv is empty - const existing = db.select().from(settingsKv).limit(1).all(); - if (existing.length > 0) return; + // Only migrate if no app-settings row exists + const existing = db + .select() + .from(settingsKv) + .where(and(eq(settingsKv.key, SETTINGS_KEY), eq(settingsKv.category, SETTINGS_CATEGORY))) + .get(); + if (existing) return; + + // Also check for legacy 'default' key that may have been overwritten by hub config + const legacyRow = db + .select() + .from(settingsKv) + .where(and(eq(settingsKv.key, 'default'), eq(settingsKv.category, SETTINGS_CATEGORY))) + .get(); + if (legacyRow) { + // Migrate the row to the new key + db.insert(settingsKv) + .values({ + id: generateId(), + key: SETTINGS_KEY, + category: SETTINGS_CATEGORY, + settings: legacyRow.settings, + updatedAt: legacyRow.updatedAt, + }) + .run(); + return; + } const jsonPath = join(dataDir, 'settings.json'); if (!existsSync(jsonPath)) return; @@ -96,7 +123,7 @@ export function migrateFromJson(db: AdcDatabase, dataDir: string): void { // Insert settings blob as-is (webhook secrets stay encrypted in JSON) db.insert(settingsKv) - .values({ id: generateId(), key: 'default', settings: settingsRaw, updatedAt: now }) + .values({ id: generateId(), key: SETTINGS_KEY, category: SETTINGS_CATEGORY, settings: settingsRaw, updatedAt: now }) .run(); // Insert profiles @@ -136,7 +163,11 @@ export function migrateFromJson(db: AdcDatabase, dataDir: string): void { export function loadSettingsFile(db: AdcDatabase): { data: SettingsFile; needsMigration: boolean } { try { // ── Load settings blob ── - const row = db.select().from(settingsKv).where(eq(settingsKv.key, 'default')).get(); + const row = db + .select() + .from(settingsKv) + .where(and(eq(settingsKv.key, SETTINGS_KEY), eq(settingsKv.category, SETTINGS_CATEGORY))) + .get(); let settings: Record = { ...DEFAULT_SETTINGS }; let needsMigration = false; @@ -213,7 +244,7 @@ export function saveSettingsFile(db: AdcDatabase, data: SettingsFile): void { // Upsert settings blob db.insert(settingsKv) - .values({ id: generateId(), key: 'default', settings: encryptedSettings, updatedAt: now }) + .values({ id: generateId(), key: SETTINGS_KEY, category: SETTINGS_CATEGORY, settings: encryptedSettings, updatedAt: now }) .onConflictDoUpdate({ target: settingsKv.key, set: { settings: encryptedSettings, updatedAt: now }, diff --git a/src/main/features/settings/voice/voice-handlers.ts b/src/main/features/settings/voice/voice-handlers.ts index 35904979..cfe285fd 100644 --- a/src/main/features/settings/voice/voice-handlers.ts +++ b/src/main/features/settings/voice/voice-handlers.ts @@ -2,7 +2,7 @@ * Voice IPC handlers */ -import { VOICE } from '@shared/ipc/misc/voice.channels'; +import { VOICE } from '@shared/ipc/voice'; import type { VoiceService } from "./voice-service"; import type { IpcRouter } from '../../../ipc/router'; diff --git a/src/main/features/spotify/index.ts b/src/main/features/spotify/index.ts new file mode 100644 index 00000000..baf4a337 --- /dev/null +++ b/src/main/features/spotify/index.ts @@ -0,0 +1,6 @@ +/** + * Spotify feature — public API + */ +export { createSpotifyService } from './spotify-service'; +export { registerSpotifyHandlers } from './spotify-handlers'; +export type { SpotifyService } from './spotify-service'; diff --git a/src/main/features/spotify/spotify-handlers.ts b/src/main/features/spotify/spotify-handlers.ts new file mode 100644 index 00000000..b7f34b03 --- /dev/null +++ b/src/main/features/spotify/spotify-handlers.ts @@ -0,0 +1,42 @@ +/** + * Spotify IPC handlers. + */ + +import { SPOTIFY } from '@shared/ipc/spotify/channels'; + +import type { SpotifyService } from './spotify-service'; +import type { IpcRouter } from '../../ipc/router'; + +export function registerSpotifyHandlers(router: IpcRouter, service: SpotifyService): void { + router.handle(SPOTIFY.GET.PLAYBACK, async () => { + return await service.getPlayback(); + }); + + router.handle(SPOTIFY.PLAY.TRACK, async (params) => { + return await service.play(params); + }); + + router.handle(SPOTIFY.PAUSE.TRACK, async () => { + return await service.pause(); + }); + + router.handle(SPOTIFY.SKIP.NEXT, async () => { + return await service.next(); + }); + + router.handle(SPOTIFY.SKIP.PREVIOUS, async () => { + return await service.previous(); + }); + + router.handle(SPOTIFY.SEARCH.TRACKS, async (params) => { + return await service.search(params); + }); + + router.handle(SPOTIFY.SET.VOLUME, async (params) => { + return await service.setVolume(params); + }); + + router.handle(SPOTIFY.ADD['TO-QUEUE'], async (params) => { + return await service.addToQueue(params); + }); +} diff --git a/src/main/features/spotify/spotify-service.ts b/src/main/features/spotify/spotify-service.ts new file mode 100644 index 00000000..68e928b0 --- /dev/null +++ b/src/main/features/spotify/spotify-service.ts @@ -0,0 +1,117 @@ +/** + * Spotify Service — wraps the Spotify client with OAuth token management. + * + * Maps raw API responses to the IPC contract shapes. + */ + +import { createSpotifyClient } from '../../mcp-servers/spotify/spotify-client'; + +import type { OAuthManager } from '../../auth/oauth-manager'; + +// ── Interface ───────────────────────────────────────────────── + +export interface SpotifyService { + getPlayback: () => Promise<{ + isPlaying: boolean; + track?: string; + artist?: string; + album?: string; + albumArt?: string; + progressMs?: number; + durationMs?: number; + device?: string; + volume?: number; + } | null>; + + play: (params: { uri?: string }) => Promise<{ success: boolean }>; + pause: () => Promise<{ success: boolean }>; + next: () => Promise<{ success: boolean }>; + previous: () => Promise<{ success: boolean }>; + search: (params: { query: string; limit?: number }) => Promise< + Array<{ + name: string; + artist: string; + album: string; + uri: string; + durationMs: number; + }> + >; + setVolume: (params: { volumePercent: number }) => Promise<{ success: boolean }>; + addToQueue: (params: { uri: string }) => Promise<{ success: boolean }>; +} + +// ── Factory ─────────────────────────────────────────────────── + +const SPOTIFY_PROVIDER = 'spotify'; + +export function createSpotifyService(deps: { oauthManager: OAuthManager }): SpotifyService { + const { oauthManager } = deps; + + async function getClient() { + const token = await oauthManager.getAccessToken(SPOTIFY_PROVIDER); + return createSpotifyClient(token); + } + + return { + async getPlayback() { + const client = await getClient(); + const state = await client.getPlaybackState(); + if (!state) { + return null; + } + return { + isPlaying: state.is_playing, + track: state.item?.name, + artist: state.item?.artists.map((a) => a.name).join(', '), + album: state.item?.album.name, + albumArt: state.item?.album.images[0]?.url, + progressMs: state.progress_ms ?? undefined, + durationMs: state.item?.duration_ms, + device: state.device.name, + volume: state.device.volume_percent ?? undefined, + }; + }, + + async play(params) { + const client = await getClient(); + return await client.play({ uri: params.uri }); + }, + + async pause() { + const client = await getClient(); + return await client.pause(); + }, + + async next() { + const client = await getClient(); + return await client.next(); + }, + + async previous() { + const client = await getClient(); + return await client.previous(); + }, + + async search(params) { + const client = await getClient(); + const tracks = await client.search(params); + return tracks.map((t) => ({ + name: t.name, + artist: t.artists.map((a) => a.name).join(', '), + album: t.album.name, + uri: t.uri, + durationMs: t.duration_ms, + })); + }, + + async setVolume(params) { + const client = await getClient(); + return await client.setVolume(params); + }, + + async addToQueue(params) { + const client = await getClient(); + return await client.addToQueue(params); + }, + }; +} diff --git a/src/main/features/terminal/terminal-handlers.ts b/src/main/features/terminal/terminal-handlers.ts deleted file mode 100644 index 942bf3a9..00000000 --- a/src/main/features/terminal/terminal-handlers.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Terminal IPC handlers - */ - -import { TERMINALS } from '@shared/ipc/terminals/channels'; - -import type { TerminalService } from "./terminal-service"; -import type { IpcRouter } from '../../ipc/router'; - -export function registerTerminalHandlers(router: IpcRouter, service: TerminalService): void { - router.handle(TERMINALS.LIST.ALL, ({ projectPath }) => - Promise.resolve(service.listTerminals(projectPath)), - ); - - router.handle(TERMINALS.CREATE.SESSION, ({ cwd, projectPath }) => - Promise.resolve(service.createTerminal(cwd, projectPath)), - ); - - router.handle(TERMINALS.CLOSE.SESSION, ({ sessionId }) => - Promise.resolve(service.closeTerminal(sessionId)), - ); - - router.handle(TERMINALS.SEND.INPUT, ({ sessionId, data }) => - Promise.resolve(service.sendInput(sessionId, data)), - ); - - router.handle(TERMINALS.RESIZE.SESSION, ({ sessionId, cols, rows }) => - Promise.resolve(service.resizeTerminal(sessionId, cols, rows)), - ); - - router.handle(TERMINALS.INVOKE['CLAUDE-CLI'], ({ sessionId, cwd }) => - Promise.resolve(service.invokeClaudeCli(sessionId, cwd)), - ); -} diff --git a/src/main/features/terminals/terminals-handlers.ts b/src/main/features/terminals/terminals-handlers.ts new file mode 100644 index 00000000..b0e5128c --- /dev/null +++ b/src/main/features/terminals/terminals-handlers.ts @@ -0,0 +1,34 @@ +/** + * Terminal IPC handlers + */ + +import { TERMINALS } from '@shared/ipc/terminals/channels'; + +import type { TerminalService } from "./terminals-service"; +import type { IpcRouter } from '../../ipc/router'; + +export function registerTerminalHandlers(router: IpcRouter, service: TerminalService): void { + router.handle(TERMINALS.LIST.ALL, ({ projectPath }) => + Promise.resolve(service.listTerminals(projectPath)), + ); + + router.handle(TERMINALS.CREATE.SESSION, ({ cwd, projectPath }) => + Promise.resolve(service.createTerminal(cwd, projectPath)), + ); + + router.handle(TERMINALS.CLOSE.SESSION, ({ sessionId }) => + Promise.resolve(service.closeTerminal(sessionId)), + ); + + router.handle(TERMINALS.SEND.INPUT, ({ sessionId, data }) => + Promise.resolve(service.sendInput(sessionId, data)), + ); + + router.handle(TERMINALS.RESIZE.SESSION, ({ sessionId, cols, rows }) => + Promise.resolve(service.resizeTerminal(sessionId, cols, rows)), + ); + + router.handle(TERMINALS.INVOKE['CLAUDE-CLI'], ({ sessionId, cwd }) => + Promise.resolve(service.invokeClaudeCli(sessionId, cwd)), + ); +} diff --git a/src/main/features/terminal/terminal-service.ts b/src/main/features/terminals/terminals-service.ts similarity index 100% rename from src/main/features/terminal/terminal-service.ts rename to src/main/features/terminals/terminals-service.ts diff --git a/src/main/features/test-suite/analytics.ts b/src/main/features/test-suite/analytics.ts new file mode 100644 index 00000000..b032bb2d --- /dev/null +++ b/src/main/features/test-suite/analytics.ts @@ -0,0 +1,389 @@ +/** + * Test Suite Analytics — Aggregation queries over testSuiteRuns + * + * Provides summary stats, trend data, failure analysis, flaky test + * detection, and error pattern extraction for the test suite dashboard. + */ + +import { and, count, desc, eq, gte, inArray, sql } from 'drizzle-orm'; + +import { testSuiteRuns, testSuiteScripts } from '../../db/schema'; + +import type { AdcDatabase } from '../../db'; + +// ── Exported Types ───────────────────────────────────────────── + +export interface AnalyticsSummary { + totalScripts: number; + totalRuns: number; + passRate: number; + avgDurationMs: number; + flakyCount: number; +} + +export interface TrendPoint { + date: string; + passed: number; + failed: number; + flaky: number; + total: number; +} + +export interface TopFailure { + scriptId: string; + scriptName: string; + failureCount: number; + totalRuns: number; + failureRate: number; +} + +export interface SlowestTest { + scriptId: string; + scriptName: string; + avgDurationMs: number; + maxDurationMs: number; + runCount: number; +} + +export interface ErrorPattern { + pattern: string; + count: number; + scriptIds: string[]; + lastSeen: string; +} + +export interface FlakyTest { + scriptId: string; + scriptName: string; + flakeRate: number; + severity: 'high' | 'medium' | 'low'; + recentResults: Array<'passed' | 'failed'>; +} + +// ── Error-Pattern Helpers ────────────────────────────────────── + +function extractErrorLines(output: string | null): string[] { + if (!output) return []; + + let parsed: { error?: string; outputLines?: string[] }; + try { + parsed = JSON.parse(output) as { error?: string; outputLines?: string[] }; + } catch { + return []; + } + + const lines: string[] = []; + if (parsed.error) lines.push(parsed.error); + if (parsed.outputLines) { + for (const line of parsed.outputLines) { + if (/error/i.test(line)) lines.push(line); + } + } + return lines; +} + +function accumulatePattern( + patternMap: Map; lastSeen: string }>, + raw: string, + scriptId: string, + startedAt: string, +): void { + const normalized = raw.trim().replaceAll(/\s+/g, ' ').slice(0, 120); + if (!normalized) return; + + const existing = patternMap.get(normalized); + if (existing) { + existing.count++; + existing.scriptIds.add(scriptId); + if (startedAt > existing.lastSeen) existing.lastSeen = startedAt; + } else { + patternMap.set(normalized, { + count: 1, + scriptIds: new Set([scriptId]), + lastSeen: startedAt, + }); + } +} + +// ── Analytics Factory ────────────────────────────────────────── + +export interface Analytics { + summary: (projectId: string) => AnalyticsSummary; + trend: (projectId: string, days?: number) => TrendPoint[]; + topFailures: (projectId: string, limit?: number) => TopFailure[]; + slowestTests: (projectId: string, limit?: number) => SlowestTest[]; + errorPatterns: (projectId: string, limit?: number) => ErrorPattern[]; + flakyTests: (projectId: string) => FlakyTest[]; + runHistory: (scriptId: string, limit?: number) => Array<{ + status: 'running' | 'passed' | 'failed' | 'cancelled'; + startedAt: string; + durationMs: number; + }>; +} + +export function createAnalytics(db: AdcDatabase): Analytics { + // ── Helper ───────────────────────────────────────────────── + + function getScriptIdsForProject(projectId: string): string[] { + const rows = db + .select({ id: testSuiteScripts.id }) + .from(testSuiteScripts) + .where(eq(testSuiteScripts.projectId, projectId)) + .all(); + return rows.map((r) => r.id); + } + + // ── Methods ──────────────────────────────────────────────── + + function summary(projectId: string): AnalyticsSummary { + const scriptIds = getScriptIdsForProject(projectId); + if (scriptIds.length === 0) { + return { totalScripts: 0, totalRuns: 0, passRate: 0, avgDurationMs: 0, flakyCount: 0 }; + } + + const rows = db + .select({ + totalRuns: count(), + passedRuns: sql`sum(case when ${testSuiteRuns.status} = 'passed' then 1 else 0 end)`, + avgDuration: sql`avg(${testSuiteRuns.durationMs})`, + }) + .from(testSuiteRuns) + .where(inArray(testSuiteRuns.scriptId, scriptIds)) + .all(); + + const { totalRuns, passedRuns, avgDuration: avgDurationMs } = rows[0]; + const passRate = totalRuns > 0 ? Math.round((passedRuns / totalRuns) * 10000) / 100 : 0; + + // Count flaky tests + const flaky = flakyTests(projectId); + + return { + totalScripts: scriptIds.length, + totalRuns, + passRate, + avgDurationMs: Math.round(avgDurationMs), + flakyCount: flaky.length, + }; + } + + function trend(projectId: string, days = 30): TrendPoint[] { + const scriptIds = getScriptIdsForProject(projectId); + if (scriptIds.length === 0) return []; + + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const cutoffIso = cutoff.toISOString(); + + const rows = db + .select({ + date: sql`date(${testSuiteRuns.startedAt})`.as('run_date'), + passed: sql`sum(case when ${testSuiteRuns.status} = 'passed' then 1 else 0 end)`, + failed: sql`sum(case when ${testSuiteRuns.status} = 'failed' then 1 else 0 end)`, + total: count(), + }) + .from(testSuiteRuns) + .where( + and( + inArray(testSuiteRuns.scriptId, scriptIds), + gte(testSuiteRuns.startedAt, cutoffIso), + ), + ) + .groupBy(sql`date(${testSuiteRuns.startedAt})`) + .orderBy(sql`date(${testSuiteRuns.startedAt})`) + .all(); + + return rows.map((r) => ({ + date: r.date, + passed: r.passed, + failed: r.failed, + flaky: 0, // Flaky detection requires per-script analysis; set to 0 in trend view + total: r.total, + })); + } + + function topFailures(projectId: string, limit = 10): TopFailure[] { + const scriptIds = getScriptIdsForProject(projectId); + if (scriptIds.length === 0) return []; + + const rows = db + .select({ + scriptId: testSuiteRuns.scriptId, + scriptName: testSuiteScripts.name, + failureCount: sql`sum(case when ${testSuiteRuns.status} = 'failed' then 1 else 0 end)`, + totalRuns: count(), + }) + .from(testSuiteRuns) + .innerJoin(testSuiteScripts, eq(testSuiteRuns.scriptId, testSuiteScripts.id)) + .where(inArray(testSuiteRuns.scriptId, scriptIds)) + .groupBy(testSuiteRuns.scriptId, testSuiteScripts.name) + .orderBy(desc(sql`sum(case when ${testSuiteRuns.status} = 'failed' then 1 else 0 end)`)) + .limit(limit) + .all(); + + return rows + .filter((r) => r.failureCount > 0) + .map((r) => ({ + scriptId: r.scriptId, + scriptName: r.scriptName, + failureCount: r.failureCount, + totalRuns: r.totalRuns, + failureRate: r.totalRuns > 0 + ? Math.round((r.failureCount / r.totalRuns) * 10000) / 100 + : 0, + })); + } + + function slowestTests(projectId: string, limit = 10): SlowestTest[] { + const scriptIds = getScriptIdsForProject(projectId); + if (scriptIds.length === 0) return []; + + const rows = db + .select({ + scriptId: testSuiteRuns.scriptId, + scriptName: testSuiteScripts.name, + avgDuration: sql`avg(${testSuiteRuns.durationMs})`, + maxDuration: sql`max(${testSuiteRuns.durationMs})`, + runCount: count(), + }) + .from(testSuiteRuns) + .innerJoin(testSuiteScripts, eq(testSuiteRuns.scriptId, testSuiteScripts.id)) + .where( + and( + inArray(testSuiteRuns.scriptId, scriptIds), + sql`${testSuiteRuns.status} != 'running'`, + ), + ) + .groupBy(testSuiteRuns.scriptId, testSuiteScripts.name) + .orderBy(desc(sql`avg(${testSuiteRuns.durationMs})`)) + .limit(limit) + .all(); + + return rows.map((r) => ({ + scriptId: r.scriptId, + scriptName: r.scriptName, + avgDurationMs: Math.round(r.avgDuration), + maxDurationMs: r.maxDuration, + runCount: r.runCount, + })); + } + + function errorPatterns(projectId: string, limit = 10): ErrorPattern[] { + const scriptIds = getScriptIdsForProject(projectId); + if (scriptIds.length === 0) return []; + + const failedRuns = db + .select({ + scriptId: testSuiteRuns.scriptId, + output: testSuiteRuns.output, + startedAt: testSuiteRuns.startedAt, + }) + .from(testSuiteRuns) + .where( + and( + inArray(testSuiteRuns.scriptId, scriptIds), + eq(testSuiteRuns.status, 'failed'), + ), + ) + .all(); + + const patternMap = new Map; lastSeen: string }>(); + + for (const run of failedRuns) { + const errorLines = extractErrorLines(run.output); + for (const line of errorLines) { + accumulatePattern(patternMap, line, run.scriptId, run.startedAt); + } + } + + return [...patternMap.entries()] + .sort((a, b) => b[1].count - a[1].count) + .slice(0, limit) + .map(([pattern, data]) => ({ + pattern, + count: data.count, + scriptIds: [...data.scriptIds], + lastSeen: data.lastSeen, + })); + } + + function flakyTests(projectId: string): FlakyTest[] { + const scripts = db + .select({ id: testSuiteScripts.id, name: testSuiteScripts.name }) + .from(testSuiteScripts) + .where(eq(testSuiteScripts.projectId, projectId)) + .all(); + + if (scripts.length === 0) return []; + + const results: FlakyTest[] = []; + + for (const script of scripts) { + // Get last 10 completed runs (passed/failed only) + const runs = db + .select({ status: testSuiteRuns.status }) + .from(testSuiteRuns) + .where( + and( + eq(testSuiteRuns.scriptId, script.id), + inArray(testSuiteRuns.status, ['passed', 'failed']), + ), + ) + .orderBy(desc(testSuiteRuns.startedAt)) + .limit(10) + .all(); + + // Need at least 3 runs to detect flakiness + if (runs.length < 3) continue; + + // Count status flips (pass->fail or fail->pass) + let flips = 0; + for (let i = 1; i < runs.length; i++) { + if (runs[i].status !== runs[i - 1].status) flips++; + } + + const flakeRate = flips / (runs.length - 1); + + // Skip if below threshold + if (flakeRate < 0.2) continue; + + let severity: 'high' | 'medium' | 'low'; + if (flakeRate >= 0.6) severity = 'high'; + else if (flakeRate >= 0.4) severity = 'medium'; + else severity = 'low'; + + results.push({ + scriptId: script.id, + scriptName: script.name, + flakeRate: Math.round(flakeRate * 1000) / 1000, + severity, + recentResults: runs.map((r) => r.status as 'passed' | 'failed'), + }); + } + + return results; + } + + function runHistory(scriptId: string, limit = 20) { + return db + .select({ + status: testSuiteRuns.status, + startedAt: testSuiteRuns.startedAt, + durationMs: testSuiteRuns.durationMs, + }) + .from(testSuiteRuns) + .where(eq(testSuiteRuns.scriptId, scriptId)) + .orderBy(desc(testSuiteRuns.startedAt)) + .limit(limit) + .all() as Array<{ status: 'running' | 'passed' | 'failed' | 'cancelled'; startedAt: string; durationMs: number }>; + } + + return { + summary, + trend, + topFailures, + slowestTests, + errorPatterns, + flakyTests, + runHistory, + }; +} diff --git a/src/main/features/test-suite/baseline-service.ts b/src/main/features/test-suite/baseline-service.ts new file mode 100644 index 00000000..858a604a --- /dev/null +++ b/src/main/features/test-suite/baseline-service.ts @@ -0,0 +1,180 @@ +/** + * Test Suite Baseline Store — Drizzle CRUD for test_suite_baselines table + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import { eq, and } from 'drizzle-orm'; + +import { generateId } from '@shared/lib/id'; + +import { compareScreenshots } from './diff-engine'; +import { testSuiteBaselines, testSuiteDiffs } from './schema'; + +import type { AdcDatabase } from '../../db'; + +export interface BaselineRecord { + id: string; + scriptId: string; + stepIndex: number; + stepLabel: string; + filePath: string; + width: number; + height: number; + createdAt: string; + updatedAt: string; +} + +export interface DiffRecord { + id: string; + runId: string; + baselineId: string; + screenshotId: string; + diffFilePath: string; + mismatchPercentage: number; + mismatchPixels: number; + threshold: number; + status: 'match' | 'mismatch' | 'size-mismatch'; + createdAt: string; +} + +export interface BaselineService { + get: (scriptId: string, stepIndex: number) => BaselineRecord | null; + listByScript: (scriptId: string) => BaselineRecord[]; + setBaseline: (params: { + scriptId: string; + stepIndex: number; + stepLabel: string; + sourceFilePath: string; + baselineDir: string; + width: number; + height: number; + }) => BaselineRecord; + deleteByScript: (scriptId: string) => void; + compareDiffs: (params: { + runId: string; + sensitivity: 'strict' | 'balanced' | 'relaxed'; + screenshots: Array<{ id: string; scriptId: string; filePath: string; stepIndex: number }>; + }) => Promise; +} + +export function createBaselineService(db: AdcDatabase): BaselineService { + const store: BaselineService = { + get(scriptId, stepIndex) { + const rows = db + .select() + .from(testSuiteBaselines) + .where( + and( + eq(testSuiteBaselines.scriptId, scriptId), + eq(testSuiteBaselines.stepIndex, stepIndex), + ), + ) + .all(); + const row = rows.at(0); + return row ? (row as BaselineRecord) : null; + }, + + listByScript(scriptId) { + return db + .select() + .from(testSuiteBaselines) + .where(eq(testSuiteBaselines.scriptId, scriptId)) + .all() as BaselineRecord[]; + }, + + setBaseline(params) { + const now = new Date().toISOString(); + const id = generateId(); + const destFileName = `baseline-${params.scriptId}-${params.stepIndex}.png`; + const destPath = path.join(params.baselineDir, destFileName); + + if (!fs.existsSync(params.baselineDir)) { + fs.mkdirSync(params.baselineDir, { recursive: true }); + } + + fs.copyFileSync(params.sourceFilePath, destPath); + + const existing = store.get(params.scriptId, params.stepIndex); + + if (existing) { + db.update(testSuiteBaselines) + .set({ + filePath: destPath, + stepLabel: params.stepLabel, + width: params.width, + height: params.height, + updatedAt: now, + }) + .where(eq(testSuiteBaselines.id, existing.id)) + .run(); + + return { ...existing, filePath: destPath, updatedAt: now }; + } + + const record: BaselineRecord = { + id, + scriptId: params.scriptId, + stepIndex: params.stepIndex, + stepLabel: params.stepLabel, + filePath: destPath, + width: params.width, + height: params.height, + createdAt: now, + updatedAt: now, + }; + + db.insert(testSuiteBaselines).values(record).run(); + return record; + }, + + deleteByScript(scriptId) { + const baselines = store.listByScript(scriptId); + for (const b of baselines) { + if (fs.existsSync(b.filePath)) fs.unlinkSync(b.filePath); + } + db.delete(testSuiteBaselines) + .where(eq(testSuiteBaselines.scriptId, scriptId)) + .run(); + }, + + async compareDiffs(params) { + const SENSITIVITY_THRESHOLDS = { strict: 0, balanced: 5, relaxed: 15 } as const; + const threshold = SENSITIVITY_THRESHOLDS[params.sensitivity]; + const results: DiffRecord[] = []; + + for (const ss of params.screenshots) { + const baseline = store.get(ss.scriptId, ss.stepIndex); + if (!baseline) continue; + + const diffResult = await compareScreenshots({ + baselinePath: baseline.filePath, + actualPath: ss.filePath, + outputDir: path.join(path.dirname(ss.filePath), 'diffs'), + sensitivity: params.sensitivity, + }); + + const record: DiffRecord = { + id: generateId(), + runId: params.runId, + baselineId: baseline.id, + screenshotId: ss.id, + diffFilePath: diffResult.diffFilePath, + mismatchPercentage: diffResult.mismatchPercentage, + mismatchPixels: diffResult.mismatchPixels, + threshold, + status: diffResult.status, + createdAt: new Date().toISOString(), + }; + + db.insert(testSuiteDiffs).values(record).run(); + results.push(record); + } + + return results; + }, + }; + + return store; +} diff --git a/src/main/features/test-suite/browser-view-manager.ts b/src/main/features/test-suite/browser-view-manager.ts new file mode 100644 index 00000000..6a6fe0b7 --- /dev/null +++ b/src/main/features/test-suite/browser-view-manager.ts @@ -0,0 +1,114 @@ +/** + * BrowserViewManager + * + * Owns a single Electron WebContentsView overlaid on the main window so the + * test-suite recorder can display the user's running app inside our UI. + * Handlers in `test-suite-handlers.ts` drive this via the BROWSER-VIEW IPC + * channels. + * + * Migrated from the deprecated BrowserView API to WebContentsView (Electron 39+). + */ + +import { join } from 'node:path'; + +import { WebContentsView, session } from 'electron'; +import type { BrowserWindow } from 'electron'; + +// electron-vite emits preloads to out/preload/ as .cjs (see +// electron.vite.config.ts + src/main/index.ts createWindow for the +// sibling `../preload/index.cjs` pattern). +const PRELOAD = join(__dirname, '../preload/test-suite-recorder.cjs'); + +export interface BrowserViewManager { + create: (url: string, bounds: Electron.Rectangle) => { success: boolean }; + navigate: (url: string) => { success: boolean }; + back: () => { success: boolean }; + forward: () => { success: boolean }; + reload: () => { success: boolean }; + setBounds: (bounds: Electron.Rectangle) => { success: boolean }; + destroy: () => { success: boolean }; + getWebContents: () => Electron.WebContents | null; + setStepEmitter: (fn: (step: unknown) => void) => void; +} + +export function createBrowserViewManager( + getMainWindow: () => BrowserWindow | null, +): BrowserViewManager { + let view: WebContentsView | null = null; + const partition = 'persist:test-suite'; + // eslint-disable-next-line @typescript-eslint/no-empty-function + let stepEmitter: (step: unknown) => void = () => {}; + + function ensure(): WebContentsView { + if (view) return view; + view = new WebContentsView({ + webPreferences: { + preload: PRELOAD, + contextIsolation: true, + nodeIntegration: false, + partition, + webSecurity: true, + }, + }); + session.fromPartition(partition).setCertificateVerifyProc((req, cb) => { + if (req.hostname === 'localhost' || req.hostname === '127.0.0.1') { + cb(0); + } else { + cb(-3); + } + }); + view.webContents.ipc.on('adc.test-suite.step', (_e, step: unknown) => { + stepEmitter(step); + }); + return view; + } + + return { + create(url, bounds) { + const win = getMainWindow(); + if (!win) throw new Error('No main window'); + const v = ensure(); + win.contentView.addChildView(v); + v.setBounds(bounds); + void v.webContents.loadURL(url); + return { success: true }; + }, + navigate(url) { + void ensure().webContents.loadURL(url); + return { success: true }; + }, + back() { + const v = ensure(); + // eslint-disable-next-line @typescript-eslint/no-deprecated + if (v.webContents.canGoBack()) v.webContents.goBack(); + return { success: true }; + }, + forward() { + const v = ensure(); + // eslint-disable-next-line @typescript-eslint/no-deprecated + if (v.webContents.canGoForward()) v.webContents.goForward(); + return { success: true }; + }, + reload() { + ensure().webContents.reload(); + return { success: true }; + }, + setBounds(bounds) { + ensure().setBounds(bounds); + return { success: true }; + }, + destroy() { + const win = getMainWindow(); + if (view && win) win.contentView.removeChildView(view); + view?.webContents.close(); + view = null; + return { success: true }; + }, + getWebContents() { + return view?.webContents ?? null; + }, + setStepEmitter(fn) { + stepEmitter = fn; + }, + }; +} diff --git a/src/main/features/test-suite/config-service.ts b/src/main/features/test-suite/config-service.ts new file mode 100644 index 00000000..9a76e1b9 --- /dev/null +++ b/src/main/features/test-suite/config-service.ts @@ -0,0 +1,104 @@ +/** + * Test Suite Config Store + * + * SQLite-backed persistence for per-project test-suite configs. + * Uses the `settings_kv` table with category='test-suite' and + * composite key = `${projectId}:${configId}`. + * + * Follows the project's Drizzle convention (see hub-config-store.ts). + */ + +import { and, eq, like } from 'drizzle-orm'; + +import type { TestSuiteConfig } from '@shared/ipc/test-suite/schemas'; +import { generateId } from '@shared/lib/id'; + +import { settingsKv } from '../../db/schema'; + +import type { AdcDatabase } from '../../db'; + +const CATEGORY = 'test-suite'; + +function keyFor(projectId: string, configId: string): string { + return `${projectId}:${configId}`; +} + +export interface ConfigService { + list: (projectId: string) => TestSuiteConfig[]; + getActive: (projectId: string) => TestSuiteConfig | null; + save: (projectId: string, config: TestSuiteConfig) => TestSuiteConfig; + delete: (projectId: string, configId: string) => void; + setActive: (projectId: string, configId: string) => void; +} + +export function createConfigService(db: AdcDatabase): ConfigService { + function list(projectId: string): TestSuiteConfig[] { + const rows = db + .select() + .from(settingsKv) + .where(and(eq(settingsKv.category, CATEGORY), like(settingsKv.key, `${projectId}:%`))) + .all(); + + return rows.map((row) => { + const raw = row.settings as Record; + return { + ...raw, + browsers: raw.browsers ?? ['chromium'], + workers: raw.workers ?? 1, + retries: raw.retries ?? 1, + storageStatePath: raw.storageStatePath ?? undefined, + environments: raw.environments ?? [], + activeEnvironment: raw.activeEnvironment ?? undefined, + } as TestSuiteConfig; + }); + } + + function getActive(projectId: string): TestSuiteConfig | null { + return list(projectId).find((c) => c.isActive) ?? null; + } + + function save(projectId: string, config: TestSuiteConfig): TestSuiteConfig { + const key = keyFor(projectId, config.id); + const now = new Date().toISOString(); + + db.insert(settingsKv) + .values({ + id: generateId(), + key, + category: CATEGORY, + settings: config as unknown, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: settingsKv.key, + set: { + settings: config as unknown, + updatedAt: now, + }, + }) + .run(); + + return config; + } + + function deleteConfig(projectId: string, configId: string): void { + db.delete(settingsKv) + .where(and(eq(settingsKv.category, CATEGORY), eq(settingsKv.key, keyFor(projectId, configId)))) + .run(); + } + + function setActive(projectId: string, configId: string): void { + const all = list(projectId); + for (const c of all) { + save(projectId, { ...c, isActive: c.id === configId }); + } + } + + return { + list, + getActive, + save, + delete: deleteConfig, + setActive, + }; +} diff --git a/src/main/features/test-suite/data-runner.ts b/src/main/features/test-suite/data-runner.ts new file mode 100644 index 00000000..39a50317 --- /dev/null +++ b/src/main/features/test-suite/data-runner.ts @@ -0,0 +1,97 @@ +/** + * Test Suite Data-Driven Runner + * + * Parses CSV/JSON data files and iterates rows as test data. Callers run the + * test script once per row, substituting {{key}} placeholders in fill-step + * values with the row's column values. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +export type DataRow = Record; + +export function parseDataFile(filePath: string): DataRow[] { + const ext = path.extname(filePath).toLowerCase(); + const content = fs.readFileSync(filePath, 'utf-8'); + + if (ext === '.json') { + const parsed = JSON.parse(content) as unknown; + if (!Array.isArray(parsed)) throw new Error('JSON data file must be an array of objects'); + return parsed as DataRow[]; + } + + if (ext === '.csv') { + return parseCsv(content); + } + + throw new Error(`Unsupported data file format: ${ext}. Use .csv or .json`); +} + +function parseCsv(content: string): DataRow[] { + const lines = content.split('\n').filter((l) => l.trim().length > 0); + if (lines.length < 2) return []; + + const headers = parseCsvLine(lines[0]); + const rows: DataRow[] = []; + + for (let i = 1; i < lines.length; i++) { + const values = parseCsvLine(lines[i]); + const row: DataRow = {}; + for (const [j, header] of headers.entries()) { + row[header] = values[j] ?? ''; + } + rows.push(row); + } + + return rows; +} + +function handleQuoteChar(state: CsvLineState, line: string, index: number): number { + if (state.inQuotes && line[index + 1] === '"') { + state.current += '"'; + return index + 1; + } + state.inQuotes = !state.inQuotes; + return index; +} + +interface CsvLineState { + current: string; + inQuotes: boolean; + result: string[]; +} + +function parseCsvLine(line: string): string[] { + const state: CsvLineState = { current: '', inQuotes: false, result: [] }; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { + i = handleQuoteChar(state, line, i); + } else if (char === ',' && !state.inQuotes) { + state.result.push(state.current.trim()); + state.current = ''; + } else { + state.current += char; + } + } + + state.result.push(state.current.trim()); + return state.result; +} + +type StepLike = { type: string; value?: string } & Record; + +export function substituteDataInSteps(steps: StepLike[], dataRow: DataRow): StepLike[] { + return steps.map((step) => { + if (step.type !== 'fill' || !step.value) return step; + + let { value } = step; + for (const [key, val] of Object.entries(dataRow)) { + value = value.replaceAll(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), val); + } + + return { ...step, value }; + }); +} diff --git a/src/main/features/test-suite/diff-engine.ts b/src/main/features/test-suite/diff-engine.ts new file mode 100644 index 00000000..b7bfc47f --- /dev/null +++ b/src/main/features/test-suite/diff-engine.ts @@ -0,0 +1,135 @@ +/** + * Pixel Diff Engine — sharp-based screenshot comparison + * + * Reads a baseline PNG and an actual PNG, compares pixels with a + * sensitivity threshold, and writes a diff image highlighting + * mismatches in red. Uses dynamic import for `sharp` so the module + * degrades gracefully when the native binding is unavailable. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import { generateId } from '@shared/lib/id'; + +import type SharpModule from 'sharp'; + +export type DiffSensitivity = 'strict' | 'balanced' | 'relaxed'; + +export interface DiffResult { + mismatchPercentage: number; + mismatchPixels: number; + totalPixels: number; + diffFilePath: string; + status: 'match' | 'mismatch' | 'size-mismatch'; +} + +const THRESHOLDS: Record = { + strict: 0, + balanced: 5, + relaxed: 15, +}; + +export async function compareScreenshots(params: { + baselinePath: string; + actualPath: string; + outputDir: string; + sensitivity: DiffSensitivity; +}): Promise { + const { baselinePath, actualPath, outputDir, sensitivity } = params; + const threshold = THRESHOLDS[sensitivity]; + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + let sharp: typeof SharpModule; + try { + sharp = (await import('sharp')).default; + } catch { + return { + mismatchPercentage: 0, + mismatchPixels: 0, + totalPixels: 0, + diffFilePath: '', + status: 'match', + }; + } + + const baselineImg = sharp(baselinePath); + const actualImg = sharp(actualPath); + + const baselineMeta = await baselineImg.metadata(); + const actualMeta = await actualImg.metadata(); + + if ( + baselineMeta.width !== actualMeta.width || + baselineMeta.height !== actualMeta.height + ) { + return { + mismatchPercentage: 100, + mismatchPixels: (baselineMeta.width ?? 0) * (baselineMeta.height ?? 0), + totalPixels: (baselineMeta.width ?? 0) * (baselineMeta.height ?? 0), + diffFilePath: '', + status: 'size-mismatch', + }; + } + + if ( + baselineMeta.width === undefined || + baselineMeta.width === 0 || + baselineMeta.height === undefined || + baselineMeta.height === 0 + ) { + throw new Error('missing dimensions'); + } + + const { width, height } = baselineMeta; + const totalPixels = width * height; + + const baselineRaw = await baselineImg.raw().ensureAlpha().toBuffer(); + const actualRaw = await actualImg.raw().ensureAlpha().toBuffer(); + + const diffBuffer = Buffer.alloc(width * height * 4); + let mismatchPixels = 0; + + for (let i = 0; i < totalPixels; i++) { + const offset = i * 4; + const rDiff = Math.abs(baselineRaw[offset] - actualRaw[offset]); + const gDiff = Math.abs(baselineRaw[offset + 1] - actualRaw[offset + 1]); + const bDiff = Math.abs(baselineRaw[offset + 2] - actualRaw[offset + 2]); + + const maxDiff = Math.max(rDiff, gDiff, bDiff); + const diffPercent = (maxDiff / 255) * 100; + + if (diffPercent > threshold) { + mismatchPixels++; + diffBuffer[offset] = 255; + diffBuffer[offset + 1] = 0; + diffBuffer[offset + 2] = 0; + diffBuffer[offset + 3] = 200; + } else { + diffBuffer[offset] = actualRaw[offset]; + diffBuffer[offset + 1] = actualRaw[offset + 1]; + diffBuffer[offset + 2] = actualRaw[offset + 2]; + diffBuffer[offset + 3] = 80; + } + } + + const diffFileName = `diff-${generateId()}.png`; + const diffFilePath = path.join(outputDir, diffFileName); + + await sharp(diffBuffer, { raw: { width, height, channels: 4 } }) + .png() + .toFile(diffFilePath); + + const mismatchPercentage = Math.round((mismatchPixels / totalPixels) * 100); + + return { + mismatchPercentage, + mismatchPixels, + totalPixels, + diffFilePath, + status: mismatchPercentage > 0 ? 'mismatch' : 'match', + }; +} diff --git a/src/main/features/test-suite/handlers/analytics-handlers.ts b/src/main/features/test-suite/handlers/analytics-handlers.ts new file mode 100644 index 00000000..2553fd24 --- /dev/null +++ b/src/main/features/test-suite/handlers/analytics-handlers.ts @@ -0,0 +1,46 @@ +/** + * Analytics IPC Handlers + * + * Registers the 7 analytics channel handlers. All delegate to + * testSuiteService.analytics — no logic here. + */ + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerAnalyticsHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + const { analytics } = testSuiteService; + + router.handle(TEST_SUITE.ANALYTICS.SUMMARY, ({ projectId }) => + Promise.resolve(analytics.summary(projectId)), + ); + + router.handle(TEST_SUITE.ANALYTICS.TREND, ({ projectId, days }) => + Promise.resolve(analytics.trend(projectId, days)), + ); + + router.handle(TEST_SUITE.ANALYTICS['TOP-FAILURES'], ({ projectId, limit }) => + Promise.resolve(analytics.topFailures(projectId, limit)), + ); + + router.handle(TEST_SUITE.ANALYTICS.SLOWEST, ({ projectId, limit }) => + Promise.resolve(analytics.slowestTests(projectId, limit)), + ); + + router.handle(TEST_SUITE.ANALYTICS['ERROR-PATTERNS'], ({ projectId, limit }) => + Promise.resolve(analytics.errorPatterns(projectId, limit)), + ); + + router.handle(TEST_SUITE.ANALYTICS.FLAKY, ({ projectId }) => + Promise.resolve(analytics.flakyTests(projectId)), + ); + + router.handle(TEST_SUITE.ANALYTICS['RUN-HISTORY'], ({ scriptId, limit }) => + Promise.resolve(analytics.runHistory(scriptId, limit)), + ); +} diff --git a/src/main/features/test-suite/handlers/auth-handlers.ts b/src/main/features/test-suite/handlers/auth-handlers.ts new file mode 100644 index 00000000..f44f4601 --- /dev/null +++ b/src/main/features/test-suite/handlers/auth-handlers.ts @@ -0,0 +1,24 @@ +/** + * Auth IPC Handlers + * + * Registers the 2 AUTH channel handlers (SAVE, CLEAR). + * Both delegate directly to testSuiteService — no logic here. + */ + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerAuthHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + router.handle(TEST_SUITE.AUTH.SAVE, ({ projectId }) => + testSuiteService.saveAuthState(projectId), + ); + + router.handle(TEST_SUITE.AUTH.CLEAR, ({ projectId }) => + testSuiteService.clearAuthState(projectId), + ); +} diff --git a/src/main/features/test-suite/handlers/baseline-handlers.ts b/src/main/features/test-suite/handlers/baseline-handlers.ts new file mode 100644 index 00000000..0b68c679 --- /dev/null +++ b/src/main/features/test-suite/handlers/baseline-handlers.ts @@ -0,0 +1,91 @@ +/** + * Baseline & Diff IPC Handlers + * + * Registers BASELINE.LIST/SET/DELETE and DIFF.COMPARE/LIST handlers. + */ + +import path from 'node:path'; + +import { eq } from 'drizzle-orm'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { testSuiteDiffs } from '../schema'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerBaselineHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + const { db } = testSuiteService; + + router.handle(TEST_SUITE.BASELINE.LIST, ({ scriptId }) => + Promise.resolve(testSuiteService.baselineStore.listByScript(scriptId)), + ); + + router.handle(TEST_SUITE.BASELINE.SET, ({ scriptId, screenshotId }) => { + const screenshot = testSuiteService.screenshotStore.get(screenshotId); + if (!screenshot) return Promise.reject(new Error('Screenshot not found')); + + const script = testSuiteService.scriptStore.get(scriptId); + if (!script) return Promise.reject(new Error('Script not found')); + + const projectPath = testSuiteService.getProjectPath(script.projectId); + if (!projectPath) { + return Promise.reject(new Error(`Project path not found for projectId: ${script.projectId}`)); + } + + const config = testSuiteService.configStore.getActive(script.projectId); + const testDir = config?.testDirectory ?? 'tests/e2e'; + const baselineDir = path.join(projectPath, testDir, 'baselines'); + + return Promise.resolve( + testSuiteService.baselineStore.setBaseline({ + scriptId, + stepIndex: screenshot.stepIndex, + stepLabel: screenshot.stepLabel, + sourceFilePath: screenshot.filePath, + baselineDir, + width: screenshot.width, + height: screenshot.height, + }), + ); + }); + + router.handle(TEST_SUITE.BASELINE.DELETE, ({ scriptId }) => { + testSuiteService.baselineStore.deleteByScript(scriptId); + return Promise.resolve({ success: true }); + }); + + router.handle(TEST_SUITE.DIFF.COMPARE, async ({ runId, sensitivity }) => { + const screenshots = testSuiteService.screenshotStore.list(runId); + return await testSuiteService.baselineStore.compareDiffs({ + runId, + sensitivity, + screenshots, + }); + }); + + router.handle(TEST_SUITE.DIFF.LIST, ({ runId }) => + Promise.resolve( + db + .select() + .from(testSuiteDiffs) + .where(eq(testSuiteDiffs.runId, runId)) + .all() as Array<{ + id: string; + runId: string; + baselineId: string; + screenshotId: string; + diffFilePath: string; + mismatchPercentage: number; + mismatchPixels: number; + threshold: number; + status: 'match' | 'mismatch' | 'size-mismatch'; + createdAt: string; + }>, + ), + ); +} diff --git a/src/main/features/test-suite/handlers/browser-view-handlers.ts b/src/main/features/test-suite/handlers/browser-view-handlers.ts new file mode 100644 index 00000000..6f8b47a0 --- /dev/null +++ b/src/main/features/test-suite/handlers/browser-view-handlers.ts @@ -0,0 +1,72 @@ +/** + * Browser-View IPC Handlers + * + * Registers the recorder step forwarder and the 7 BROWSER-VIEW channel + * handlers (CREATE, NAVIGATE, BACK, FORWARD, RELOAD, SET-BOUNDS, DESTROY). + * Preload emits steps in contract-normalized shape; we validate, wrap with + * stepIndex + timestamp, and forward to the renderer. + */ + +import { TEST_SUITE, TEST_SUITE_EVENTS } from '@shared/ipc/test-suite/channels'; +import { TestSuiteStepSchema } from '@shared/ipc/test-suite/schemas'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +type TestSuiteStep = (typeof TestSuiteStepSchema)['_output']; + +const recorderEmittableTypes = new Set(['navigate', 'click', 'fill', 'select', 'press']); + +function normalizeStep(raw: unknown): TestSuiteStep | null { + const parsed = TestSuiteStepSchema.safeParse(raw); + if (!parsed.success) return null; + if (!recorderEmittableTypes.has(parsed.data.type)) return null; + return parsed.data; +} + +export function registerBrowserViewHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + const { browserViewManager: bvm } = testSuiteService; + + let recorderStepIndex = 0; + + bvm.setStepEmitter((raw) => { + const step = normalizeStep(raw); + if (!step) return; + router.emit(TEST_SUITE_EVENTS.RECORDER.STEP, { + stepIndex: recorderStepIndex++, + step, + timestamp: new Date().toISOString(), + }); + }); + + router.handle(TEST_SUITE['BROWSER-VIEW'].CREATE, ({ url, bounds }) => + Promise.resolve(bvm.create(url, bounds)), + ); + + router.handle(TEST_SUITE['BROWSER-VIEW'].NAVIGATE, ({ url }) => + Promise.resolve(bvm.navigate(url)), + ); + + router.handle(TEST_SUITE['BROWSER-VIEW'].BACK, () => + Promise.resolve(bvm.back()), + ); + + router.handle(TEST_SUITE['BROWSER-VIEW'].FORWARD, () => + Promise.resolve(bvm.forward()), + ); + + router.handle(TEST_SUITE['BROWSER-VIEW'].RELOAD, () => + Promise.resolve(bvm.reload()), + ); + + router.handle(TEST_SUITE['BROWSER-VIEW']['SET-BOUNDS'], (bounds) => + Promise.resolve(bvm.setBounds(bounds)), + ); + + router.handle(TEST_SUITE['BROWSER-VIEW'].DESTROY, () => + Promise.resolve(bvm.destroy()), + ); +} diff --git a/src/main/features/test-suite/handlers/config-handlers.ts b/src/main/features/test-suite/handlers/config-handlers.ts new file mode 100644 index 00000000..1f45d4f7 --- /dev/null +++ b/src/main/features/test-suite/handlers/config-handlers.ts @@ -0,0 +1,44 @@ +/** + * Config IPC Handlers + * + * Registers the 5 CONFIG channel handlers (GET, LIST, SAVE, DELETE, SET-ACTIVE). + * SET-ACTIVE emits TEST_SUITE_EVENTS.CONFIG.CHANGED when the active config changes. + */ + +import { TEST_SUITE, TEST_SUITE_EVENTS } from '@shared/ipc/test-suite/channels'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerConfigHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + const { configStore } = testSuiteService; + + router.handle(TEST_SUITE.CONFIG.GET, ({ projectId }) => + Promise.resolve(configStore.getActive(projectId)), + ); + + router.handle(TEST_SUITE.CONFIG.LIST, ({ projectId }) => + Promise.resolve(configStore.list(projectId)), + ); + + router.handle(TEST_SUITE.CONFIG.SAVE, ({ projectId, config }) => + Promise.resolve(configStore.save(projectId, config)), + ); + + router.handle(TEST_SUITE.CONFIG.DELETE, ({ projectId, configId }) => { + configStore.delete(projectId, configId); + return Promise.resolve({ success: true }); + }); + + router.handle(TEST_SUITE.CONFIG['SET-ACTIVE'], ({ projectId, configId }) => { + configStore.setActive(projectId, configId); + const active = configStore.getActive(projectId); + if (active) { + router.emit(TEST_SUITE_EVENTS.CONFIG.CHANGED, { config: active }); + } + return Promise.resolve({ success: true }); + }); +} diff --git a/src/main/features/test-suite/handlers/data-run-handlers.ts b/src/main/features/test-suite/handlers/data-run-handlers.ts new file mode 100644 index 00000000..fee9f147 --- /dev/null +++ b/src/main/features/test-suite/handlers/data-run-handlers.ts @@ -0,0 +1,72 @@ +/** + * Data-Run IPC Handlers + * + * Registers the 2 DATA-RUN channel handlers (PARSE, EXECUTE). + * EXECUTE iterates CSV/JSON rows, substitutes {{key}} placeholders, + * writes a temporary spec file per row, and runs each as a separate test run. + */ + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; +import type { TestSuiteStepSchema } from '@shared/ipc/test-suite/schemas'; + +import { parseDataFile, substituteDataInSteps } from '../data-runner'; +import { writeSpecFile } from '../script-writer'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +type TestSuiteStep = (typeof TestSuiteStepSchema)['_output']; + +export function registerDataRunHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + router.handle(TEST_SUITE['DATA-RUN'].PARSE, ({ filePath }) => { + const rows = parseDataFile(filePath); + const headers = rows.length > 0 ? Object.keys(rows[0]) : []; + return Promise.resolve({ rows, headers, rowCount: rows.length }); + }); + + router.handle(TEST_SUITE['DATA-RUN'].EXECUTE, async ({ scriptId, dataFilePath }) => { + const rows = parseDataFile(dataFilePath); + const script = await testSuiteService.getScript(scriptId); + if (!script) throw new Error(`Script not found: ${scriptId}`); + + const projectPath = testSuiteService.getProjectPath(script.projectId); + if (!projectPath) throw new Error(`Project path not found for projectId: ${script.projectId}`); + + const config = testSuiteService.configStore.getActive(script.projectId); + const testDir = config?.testDirectory ?? 'tests/e2e'; + const runIds: string[] = []; + + for (const [rowIndex, row] of rows.entries()) { + // Substitute {{key}} placeholders in fill-step values + const substitutedSteps = substituteDataInSteps( + script.steps as Array<{ type: string; value?: string } & Record>, + row, + ); + + // Write a temporary spec file with the substituted steps + const tempFilePath = writeSpecFile({ + projectRoot: projectPath, + testDir, + name: `${script.name}-data-${rowIndex}`, + baseUrl: script.targetUrl, + steps: substitutedSteps as TestSuiteStep[], + screenshotMode: config?.screenshotMode, + navigationTimeout: config?.navigationTimeout, + actionTimeout: config?.actionTimeout, + }); + + // Run the test using the substituted temp spec + const { runId } = await testSuiteService.runScript({ + scriptId, + triggeredBy: 'manual', + filePathOverride: tempFilePath, + }); + runIds.push(runId); + } + + return { runIds, totalRows: rows.length }; + }); +} diff --git a/src/main/features/test-suite/handlers/export-handlers.ts b/src/main/features/test-suite/handlers/export-handlers.ts new file mode 100644 index 00000000..787f1b22 --- /dev/null +++ b/src/main/features/test-suite/handlers/export-handlers.ts @@ -0,0 +1,48 @@ +/** + * Export IPC Handlers + * + * Registers the 4 EXPORT channel handlers: FILE, GITHUB, CI-PREVIEW, CI-COMMIT. + * CI handlers also require projectService to resolve the project root path. + */ + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { commitWorkflow, previewWorkflow } from '../workflow-exporter'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { ProjectService } from '../../projects/project-service'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerExportHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, + projectService: ProjectService, +): void { + router.handle(TEST_SUITE.EXPORT.FILE, (input) => + testSuiteService.exportFile(input), + ); + + router.handle(TEST_SUITE.EXPORT.GITHUB, (input) => + testSuiteService.exportGithub(input), + ); + + router.handle(TEST_SUITE.EXPORT['CI-PREVIEW'], ({ projectId }) => { + const projectPath = projectService.getProjectPath(projectId); + if (!projectPath) return Promise.resolve({ yaml: '', filePath: '', exists: false }); + + const config = testSuiteService.configStore.getActive(projectId); + const testDir = config?.testDirectory ?? 'tests/e2e'; + + return Promise.resolve(previewWorkflow(projectPath, testDir)); + }); + + router.handle(TEST_SUITE.EXPORT['CI-COMMIT'], ({ projectId }) => { + const projectPath = projectService.getProjectPath(projectId); + if (!projectPath) return Promise.resolve({ filePath: '', committed: false }); + + const config = testSuiteService.configStore.getActive(projectId); + const testDir = config?.testDirectory ?? 'tests/e2e'; + + return Promise.resolve(commitWorkflow(projectPath, testDir)); + }); +} diff --git a/src/main/features/test-suite/handlers/run-handlers.ts b/src/main/features/test-suite/handlers/run-handlers.ts new file mode 100644 index 00000000..05664c1d --- /dev/null +++ b/src/main/features/test-suite/handlers/run-handlers.ts @@ -0,0 +1,55 @@ +/** + * Run IPC Handlers + * + * Registers the run channel handlers: RUN.SCRIPT, GET.RUN, LIST.RUNS, + * TASK.ATTACH-RUN, BATCH.RUN, and OPEN.REPORT. RUN.SCRIPT emits + * TEST_SUITE_EVENTS.RUN.STARTED after the run is initiated. + */ + +import { existsSync } from 'node:fs'; + +import { shell } from 'electron'; + +import { TEST_SUITE, TEST_SUITE_EVENTS } from '@shared/ipc/test-suite/channels'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerRunHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + router.handle(TEST_SUITE.RUN.SCRIPT, async (input) => { + const result = await testSuiteService.runScript(input); + router.emit(TEST_SUITE_EVENTS.RUN.STARTED, { + runId: result.runId, + scriptId: input.scriptId, + timestamp: new Date().toISOString(), + }); + return result; + }); + + router.handle(TEST_SUITE.GET.RUN, ({ runId }) => + testSuiteService.getRun(runId), + ); + + router.handle(TEST_SUITE.LIST.RUNS, (input) => + testSuiteService.listRuns(input), + ); + + router.handle(TEST_SUITE.TASK['ATTACH-RUN'], ({ runId, taskId }) => + testSuiteService.attachRunToTask(runId, taskId), + ); + + router.handle(TEST_SUITE.BATCH.RUN, (input) => + testSuiteService.batchRun(input), + ); + + router.handle(TEST_SUITE.OPEN.REPORT, async ({ reportPath }) => { + if (!existsSync(reportPath)) { + throw new Error(`Report not found: ${reportPath}`); + } + await shell.openPath(reportPath); + return { success: true }; + }); +} diff --git a/src/main/features/test-suite/handlers/schedule-handlers.ts b/src/main/features/test-suite/handlers/schedule-handlers.ts new file mode 100644 index 00000000..45d3fabe --- /dev/null +++ b/src/main/features/test-suite/handlers/schedule-handlers.ts @@ -0,0 +1,53 @@ +/** + * Schedule IPC Handlers + * + * Registers SCHEDULE.LIST/GET/CREATE/UPDATE/DELETE/TRIGGER-NOW handlers. + */ + +import { TEST_SUITE, TEST_SUITE_EVENTS } from '@shared/ipc/test-suite/channels'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerScheduleHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + const { scheduler } = testSuiteService; + + router.handle(TEST_SUITE.SCHEDULE.LIST, ({ projectId }) => + Promise.resolve(scheduler.list(projectId)), + ); + + router.handle(TEST_SUITE.SCHEDULE.GET, ({ id }) => + Promise.resolve(scheduler.get(id)), + ); + + router.handle(TEST_SUITE.SCHEDULE.CREATE, (input) => + Promise.resolve(scheduler.create(input)), + ); + + router.handle(TEST_SUITE.SCHEDULE.UPDATE, ({ id, ...params }) => + Promise.resolve(scheduler.update(id, params)), + ); + + router.handle(TEST_SUITE.SCHEDULE.DELETE, ({ id }) => { + scheduler.delete(id); + return Promise.resolve({ success: true }); + }); + + router.handle(TEST_SUITE.SCHEDULE['TRIGGER-NOW'], async ({ id }) => { + const schedule = scheduler.get(id); + if (!schedule) throw new Error(`Schedule not found: ${id}`); + const result = await testSuiteService.runScript({ + scriptId: schedule.scriptId, + triggeredBy: 'scheduled', + }); + router.emit(TEST_SUITE_EVENTS.RUN.STARTED, { + runId: result.runId, + scriptId: schedule.scriptId, + timestamp: new Date().toISOString(), + }); + return result; + }); +} diff --git a/src/main/features/test-suite/handlers/screenshot-handlers.ts b/src/main/features/test-suite/handlers/screenshot-handlers.ts new file mode 100644 index 00000000..10b968f6 --- /dev/null +++ b/src/main/features/test-suite/handlers/screenshot-handlers.ts @@ -0,0 +1,48 @@ +/** + * Screenshot IPC Handlers + * + * Registers the 3 SCREENSHOT channel handlers (LIST, EXPORT-ZIP, COPY). + * EXPORT-ZIP opens the screenshot directory in the file manager. + * COPY ensures the destination directory exists before copying. + */ + +import { copyFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; + +import { shell } from 'electron'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerScreenshotHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + router.handle(TEST_SUITE.SCREENSHOT.LIST, ({ runId, scriptId }) => { + if (runId) return Promise.resolve(testSuiteService.screenshotStore.list(runId)); + if (scriptId) return Promise.resolve(testSuiteService.screenshotStore.listByScript(scriptId)); + return Promise.resolve([]); + }); + + router.handle(TEST_SUITE.SCREENSHOT['EXPORT-ZIP'], async ({ runId }) => { + const screenshots = testSuiteService.screenshotStore.list(runId); + if (screenshots.length === 0) return { filePath: '' }; + + // Return the parent directory of the screenshots and open it in the file manager + const dir = path.dirname(screenshots[0].filePath); + await shell.openPath(dir); + return { filePath: dir }; + }); + + router.handle(TEST_SUITE.SCREENSHOT.COPY, async ({ id, destPath }) => { + const screenshot = testSuiteService.screenshotStore.get(id); + if (!screenshot) return { filePath: '' }; + + // Ensure destination directory exists + await mkdir(path.dirname(destPath), { recursive: true }); + await copyFile(screenshot.filePath, destPath); + return { filePath: destPath }; + }); +} diff --git a/src/main/features/test-suite/handlers/script-handlers.ts b/src/main/features/test-suite/handlers/script-handlers.ts new file mode 100644 index 00000000..1fe7b7db --- /dev/null +++ b/src/main/features/test-suite/handlers/script-handlers.ts @@ -0,0 +1,74 @@ +/** + * Script IPC Handlers + * + * Registers the 4 script channel handlers (LIST.SCRIPTS, GET.SCRIPT, + * SAVE.SCRIPT, DELETE.SCRIPT). SAVE.SCRIPT writes spec + config files to disk + * when a project path and active config are available. + */ + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { writePlaywrightConfig } from '../playwright-config-writer'; +import { writeTestSuiteGitignore, writeTestSuiteReadme } from '../readme-writer'; +import { writeSpecFile } from '../script-writer'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { ProjectService } from '../../projects/project-service'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerScriptHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, + projectService: ProjectService, +): void { + router.handle(TEST_SUITE.LIST.SCRIPTS, ({ projectId }) => + testSuiteService.listScriptsByProject(projectId) as never, + ); + + router.handle(TEST_SUITE.GET.SCRIPT, ({ id }) => + testSuiteService.getScript(id) as never, + ); + + router.handle(TEST_SUITE.SAVE.SCRIPT, (input) => { + const { projectId, steps } = input; + const projectPath = projectService.getProjectPath(projectId); + const config = testSuiteService.configStore.getActive(projectId); + + // If we have both a project path and an active config, write files to disk + let filePath = ''; + if (projectPath && config) { + const testDir = config.testDirectory || 'tests/e2e'; + const baseUrl = config.targetUrl; + + filePath = writeSpecFile({ + projectRoot: projectPath, + testDir, + name: input.name, + baseUrl, + steps, + screenshotMode: config.screenshotMode, + navigationTimeout: config.navigationTimeout, + actionTimeout: config.actionTimeout, + }); + + writePlaywrightConfig({ + projectRoot: projectPath, + testDir, + baseUrl, + navigationTimeout: config.navigationTimeout, + actionTimeout: config.actionTimeout, + browsers: config.browsers, + workers: config.workers, + storageStatePath: config.storageStatePath, + }); + writeTestSuiteReadme({ projectRoot: projectPath, testDir }); + writeTestSuiteGitignore({ projectRoot: projectPath, testDir }); + } + + return testSuiteService.saveScript({ ...input, filePath }) as never; + }); + + router.handle(TEST_SUITE.DELETE.SCRIPT, ({ id }) => + testSuiteService.deleteScript(id), + ); +} diff --git a/src/main/features/test-suite/handlers/setup-handlers.ts b/src/main/features/test-suite/handlers/setup-handlers.ts new file mode 100644 index 00000000..eff3ba12 --- /dev/null +++ b/src/main/features/test-suite/handlers/setup-handlers.ts @@ -0,0 +1,50 @@ +/** + * Setup IPC Handlers + * + * Registers SETUP.ENSURE-DEPS handler — installs Playwright into the project. + */ + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerSetupHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + router.handle(TEST_SUITE.SETUP['ENSURE-DEPS'], async ({ projectId }) => { + const projectPath = testSuiteService.getProjectPath(projectId); + if (!projectPath) { + return { installed: false, alreadyInstalled: false, error: 'Project path not found' }; + } + + const { existsSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { execSync } = await import('node:child_process'); + + const pwPath = join(projectPath, 'node_modules', '@playwright', 'test'); + const alreadyInstalled = existsSync(pwPath); + + if (alreadyInstalled) { + return { installed: true, alreadyInstalled: true }; + } + + try { + execSync('npm install -D @playwright/test', { + cwd: projectPath, + timeout: 120_000, + stdio: 'pipe', + }); + execSync('npx playwright install chromium', { + cwd: projectPath, + timeout: 180_000, + stdio: 'pipe', + }); + return { installed: true, alreadyInstalled: false }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { installed: false, alreadyInstalled: false, error: msg }; + } + }); +} diff --git a/src/main/features/test-suite/handlers/shared-steps-handlers.ts b/src/main/features/test-suite/handlers/shared-steps-handlers.ts new file mode 100644 index 00000000..370c9416 --- /dev/null +++ b/src/main/features/test-suite/handlers/shared-steps-handlers.ts @@ -0,0 +1,42 @@ +/** + * Shared Step Groups IPC Handlers + * + * Registers SHARED-STEPS.LIST/GET/CREATE/UPDATE/DELETE/DOMAINS handlers. + */ + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerSharedStepsHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + const { sharedStepsStore } = testSuiteService; + + router.handle(TEST_SUITE['SHARED-STEPS'].LIST, ({ projectId }) => + Promise.resolve(sharedStepsStore.list(projectId)), + ); + + router.handle(TEST_SUITE['SHARED-STEPS'].GET, ({ id }) => + Promise.resolve(sharedStepsStore.get(id)), + ); + + router.handle(TEST_SUITE['SHARED-STEPS'].CREATE, (input) => + Promise.resolve(sharedStepsStore.create(input)), + ); + + router.handle(TEST_SUITE['SHARED-STEPS'].UPDATE, ({ id, ...params }) => + Promise.resolve(sharedStepsStore.update(id, params)), + ); + + router.handle(TEST_SUITE['SHARED-STEPS'].DELETE, ({ id }) => { + sharedStepsStore.delete(id); + return Promise.resolve({ success: true }); + }); + + router.handle(TEST_SUITE['SHARED-STEPS'].DOMAINS, ({ projectId }) => + Promise.resolve(sharedStepsStore.domains(projectId)), + ); +} diff --git a/src/main/features/test-suite/handlers/watch-handlers.ts b/src/main/features/test-suite/handlers/watch-handlers.ts new file mode 100644 index 00000000..b50fcbdb --- /dev/null +++ b/src/main/features/test-suite/handlers/watch-handlers.ts @@ -0,0 +1,47 @@ +/** + * Watch Mode IPC Handlers + * + * Registers WATCH.START, WATCH.STOP, WATCH.LIST handlers. + */ + +import { TEST_SUITE, TEST_SUITE_EVENTS } from '@shared/ipc/test-suite/channels'; + +import type { IpcRouter } from '../../../ipc/router'; +import type { TestSuiteService } from '../test-suite-handlers'; + +export function registerWatchHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, +): void { + router.handle(TEST_SUITE.WATCH.START, async ({ scriptId }) => { + const script = await testSuiteService.getScript(scriptId); + if (!script?.filePath) return { success: false }; + + testSuiteService.fileWatcher.watch(scriptId, script.filePath, () => { + void testSuiteService + .runScript({ scriptId, triggeredBy: 'auto-trigger' }) + .then(({ runId }) => { + router.emit(TEST_SUITE_EVENTS.WATCH.TRIGGERED, { + scriptId, + runId, + timestamp: new Date().toISOString(), + }); + return null; + }) + .catch(() => { + // Swallow — the watcher callback must not throw. + }); + }); + + return { success: true }; + }); + + router.handle(TEST_SUITE.WATCH.STOP, ({ scriptId }) => { + testSuiteService.fileWatcher.unwatch(scriptId); + return Promise.resolve({ success: true }); + }); + + router.handle(TEST_SUITE.WATCH.LIST, () => + Promise.resolve(testSuiteService.fileWatcher.listWatched()), + ); +} diff --git a/src/main/features/test-suite/index.ts b/src/main/features/test-suite/index.ts new file mode 100644 index 00000000..520a954c --- /dev/null +++ b/src/main/features/test-suite/index.ts @@ -0,0 +1,5 @@ +/** + * Test Suite — Barrel Export (main process) + */ +export { createTestSuiteService } from './test-suite-service'; +export type { TestSuiteService, TestSuiteRunEvent, QaRunIpcRecord } from './test-suite-service'; diff --git a/src/main/features/test-suite/playwright-config-writer.ts b/src/main/features/test-suite/playwright-config-writer.ts new file mode 100644 index 00000000..2f1c22fc --- /dev/null +++ b/src/main/features/test-suite/playwright-config-writer.ts @@ -0,0 +1,88 @@ +/** + * Playwright Config Writer — writes a playwright.config.ts for a project. + * + * Always writes (no existsSync skip). The config reflects current settings + * each time a script is saved, including browser selection and worker count. + */ + +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; + +function mapScreenshotConfig(mode: string | undefined): string { + switch (mode) { + case 'manual': + return "'off'"; + case 'smart': + case 'per-click': + case 'per-nav': + case 'per-form': + case 'per-assertion': + return "'on'"; + case undefined: + default: + return "'only-on-failure'"; + } +} + +export function writePlaywrightConfig(params: { + projectRoot: string; + testDir: string; + baseUrl: string; + navigationTimeout?: number; + actionTimeout?: number; + browsers?: string[]; + workers?: number; + storageStatePath?: string; + screenshotMode?: string; +}): string { + const navigationTimeout = params.navigationTimeout ?? 30000; + const actionTimeout = params.actionTimeout ?? 10000; + const browsers = params.browsers ?? ['chromium']; + const workers = params.workers ?? 1; + const configPath = path.join(params.projectRoot, 'playwright.config.ts'); + + const deviceName: Record = { + chromium: 'Desktop Chrome', + firefox: 'Desktop Firefox', + webkit: 'Desktop Safari', + }; + + const projects = browsers + .map((b) => ` { name: '${b}', use: { ...devices['${deviceName[b] ?? `Desktop ${capitalize(b)}`}'] } },`) + .join('\n'); + + const storageStateLine = params.storageStatePath + ? `\n storageState: '${escape(params.storageStatePath)}',` + : ''; + + const content = `import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: '${escape(params.testDir)}', + timeout: ${navigationTimeout}, + workers: ${workers}, + use: { + baseURL: process.env.BASE_URL || '${escape(params.baseUrl)}', + actionTimeout: ${actionTimeout},${storageStateLine} + screenshot: ${mapScreenshotConfig(params.screenshotMode)}, + trace: 'on-first-retry', + }, + projects: [ +${projects} + ], +}); +`; + + writeFileSync(configPath, content, 'utf8'); + return configPath; +} + +// ─── Helpers ───────────────────────────────────────────────── + +function escape(s: string): string { + return s.replaceAll('\\', '\\\\').replaceAll("'", "\\'"); +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/src/main/features/test-suite/readme-writer.ts b/src/main/features/test-suite/readme-writer.ts new file mode 100644 index 00000000..6ea6799a --- /dev/null +++ b/src/main/features/test-suite/readme-writer.ts @@ -0,0 +1,68 @@ +/** + * README Writer — generates a README.md inside the test-suite scripts dir + * + * Lists all .spec.ts files found in the scripts directory. + * Overwrites on every call so the README stays current. + * + * Also writes a .gitignore for test output directories (screenshots, reports). + */ + +import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +export function writeTestSuiteReadme(params: { + projectRoot: string; + testDir: string; +}): string { + const scriptsDir = path.join(params.projectRoot, params.testDir, 'scripts'); + const readmePath = path.join(scriptsDir, 'README.md'); + + const specs = existsSync(scriptsDir) + ? readdirSync(scriptsDir).filter((f) => f.endsWith('.spec.ts')).sort() + : []; + + const lines: string[] = [ + '# Test Suite Scripts', + '', + 'Auto-generated Playwright test scripts recorded via ADC.', + '', + `| # | Script |`, + `|---|--------|`, + ]; + + for (const [i, spec] of specs.entries()) { + lines.push(`| ${i + 1} | [${spec}](./${spec}) |`); + } + + lines.push(''); + + writeFileSync(readmePath, lines.join('\n'), 'utf8'); + return readmePath; +} + +/** + * Writes a .gitignore inside the test directory so generated output + * (screenshots, Playwright reports, test-results) is not committed. + * Idempotent — overwrites on every call. + */ +export function writeTestSuiteGitignore(params: { + projectRoot: string; + testDir: string; +}): string { + const testDirPath = path.join(params.projectRoot, params.testDir); + if (!existsSync(testDirPath)) { + mkdirSync(testDirPath, { recursive: true }); + } + + const gitignorePath = path.join(testDirPath, '.gitignore'); + const content = [ + '# Test Suite output (gitignored)', + 'screenshots/', + 'playwright-report/', + 'test-results/', + '', + ].join('\n'); + + writeFileSync(gitignorePath, content, 'utf8'); + return gitignorePath; +} diff --git a/src/main/features/test-suite/runner.ts b/src/main/features/test-suite/runner.ts new file mode 100644 index 00000000..b2e8eff7 --- /dev/null +++ b/src/main/features/test-suite/runner.ts @@ -0,0 +1,411 @@ +/** + * QA Recorder Runner — Spawns Playwright via child_process.spawn + * + * Creates a QaRun record, spawns `npx playwright test` for the given + * script file, streams stdout/stderr via event callbacks, then updates + * the run record with final status and report. + */ + +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync } from 'node:fs'; +import { isAbsolute, join, relative } from 'node:path'; + +import { desc, eq } from 'drizzle-orm'; + +import { generateId } from '@shared/lib/id'; + +import { testSuiteRuns } from '../../db/schema'; + +import { testSuiteScripts } from './schema'; + +import type { AdcDatabase } from '../../db'; + +export interface QaRunRecord { + id: string; + scriptId: string; + status: 'running' | 'passed' | 'failed' | 'cancelled'; + triggeredBy: 'manual' | 'scheduled' | 'ci' | 'auto-trigger'; + startedAt: string; + completedAt?: string; + outputLines: string[]; + screenshots: string[]; + error?: string; + stepsPassed: number; + stepsFailed: number; + durationMs: number; + reportPath?: string; +} + +export interface RunnerEventHandlers { + onLine?: (runId: string, line: string, timestamp: string) => void; + onStep?: (runId: string, stepIndex: number, stepLabel: string, stepType: string, timestamp: string) => void; + onComplete?: (runId: string, status: QaRunRecord['status'], record: QaRunRecord) => void; +} + +export interface QaRunner { + run: (params: { + scriptId: string; + projectId: string; + filePath: string; + projectPath: string; + triggeredBy: 'manual' | 'scheduled' | 'ci' | 'auto-trigger'; + taskId?: string; + screenshotDir?: string; + workers?: number; + retries?: number; + baseUrlOverride?: string; + handlers?: RunnerEventHandlers; + }) => string; + get: (runId: string) => QaRunRecord | null; + list: (scriptId?: string) => QaRunRecord[]; + cancel: (runId: string) => void; +} + +function toRunRecord(row: typeof testSuiteRuns.$inferSelect): QaRunRecord { + interface ParsedOutput { outputLines?: string[]; screenshots?: string[]; error?: string; reportPath?: string } + let parsed: ParsedOutput | null = null; + try { + parsed = row.output ? (JSON.parse(row.output) as ParsedOutput) : null; + } catch { + parsed = null; + } + return { + id: row.id, + scriptId: row.scriptId, + status: row.status as QaRunRecord['status'], + triggeredBy: row.triggeredBy as QaRunRecord['triggeredBy'], + startedAt: row.startedAt, + completedAt: row.completedAt ?? undefined, + outputLines: parsed?.outputLines ?? [], + screenshots: parsed?.screenshots ?? [], + error: parsed?.error, + stepsPassed: row.stepsPassed, + stepsFailed: row.stepsFailed, + durationMs: row.durationMs, + reportPath: parsed?.reportPath, + }; +} + +const PW_BROWSER_CACHE_DIR = 'ms-playwright'; + +function findPlaywrightBrowserCache(projectPath: string): boolean { + const homeDir = process.env.USERPROFILE ?? process.env.HOME ?? ''; + const candidates = [ + join(projectPath, 'node_modules', '.cache', PW_BROWSER_CACHE_DIR), + join(homeDir, '.cache', PW_BROWSER_CACHE_DIR), // Linux + join(homeDir, 'Library', 'Caches', PW_BROWSER_CACHE_DIR), // macOS + ...(process.env.LOCALAPPDATA ? [join(process.env.LOCALAPPDATA, PW_BROWSER_CACHE_DIR)] : []), // Windows + ]; + return candidates.some((dir) => existsSync(dir)); +} + +interface PreflightResult { + ok: boolean; + errors: string[]; +} + +function preflight(filePath: string, projectPath: string): PreflightResult { + const errors: string[] = []; + + // 1. Check spec file exists (may be absolute or relative to projectPath) + const absolutePath = filePath ? join(projectPath, filePath) : ''; + if (!filePath || (!existsSync(absolutePath) && !existsSync(filePath))) { + errors.push(`Spec file not found: ${filePath || '(empty path)'}`); + } + + // 2. Check Playwright is installed in the project + const pwPath = join(projectPath, 'node_modules', '@playwright', 'test'); + if (!existsSync(pwPath)) { + errors.push('Playwright is not installed. Run: npm install -D @playwright/test'); + } + + // 3. Best-effort check for Playwright browsers + if (!findPlaywrightBrowserCache(projectPath)) { + errors.push('Playwright browsers may not be installed. Run: npx playwright install'); + } + + return { ok: errors.length === 0, errors }; +} + +const PW_ACTION_STEP_TYPES: Record = { + goto: 'navigate', + click: 'click', + fill: 'fill', + selectOption: 'select', + keyboard: 'press', + waitFor: 'wait', + expect: 'assert', + screenshot: 'screenshot', +}; + +const PW_ACTION_PATTERN = /^\s*(page\.goto|page\.click|page\.fill|page\.locator|expect|page\.keyboard|page\.waitFor|page\.selectOption|page\.screenshot)/; + +function resolveStepType(keyword: string): string { + for (const [fragment, type] of Object.entries(PW_ACTION_STEP_TYPES)) { + if (keyword.includes(fragment)) return type; + } + return 'unknown'; +} + +export function createRunner(db: AdcDatabase): QaRunner { + const activeProcesses = new Map>(); + + return { + run({ scriptId, projectId, filePath, projectPath, triggeredBy, taskId, screenshotDir, workers, retries, baseUrlOverride, handlers }) { + const runId = generateId(); + const now = new Date().toISOString(); + + db.insert(testSuiteRuns).values({ + id: runId, + scriptId, + projectId, + status: 'running', + triggeredBy, + durationMs: 0, + stepsPassed: 0, + stepsFailed: 0, + output: null, + startedAt: now, + completedAt: null, + taskId: taskId ?? null, + sessionId: null, + report: null, + }).run(); + + // ── Pre-flight checks ────────────────────────────────── + const check = preflight(filePath, projectPath); + if (!check.ok) { + const completedAt = new Date().toISOString(); + const output = JSON.stringify({ + outputLines: check.errors, + screenshots: [], + error: check.errors.join('; '), + }); + + db.update(testSuiteRuns).set({ + status: 'failed', + completedAt, + durationMs: 0, + stepsFailed: check.errors.length, + output, + }).where(eq(testSuiteRuns.id, runId)).run(); + + const record: QaRunRecord = { + id: runId, + scriptId, + status: 'failed', + triggeredBy, + startedAt: now, + completedAt, + outputLines: check.errors, + screenshots: [], + error: check.errors.join('; '), + stepsPassed: 0, + stepsFailed: check.errors.length, + durationMs: 0, + }; + + handlers?.onComplete?.(runId, 'failed', record); + return runId; + } + + const outputLines: string[] = []; + const screenshots: string[] = []; + let stepCounter = 0; + + if (screenshotDir) { + mkdirSync(screenshotDir, { recursive: true }); + } + + const numWorkers = workers ?? 1; + const retryCount = retries ?? 1; + const reportDir = join(projectPath, '.playwright-reports', runId); + + // Playwright CLI treats file args as regex filters against discovered paths. + // Absolute Windows paths (backslashes) break regex matching — use relative forward-slash path. + const specFilter = (isAbsolute(filePath) ? relative(projectPath, filePath) : filePath) + .replaceAll('\\', '/'); + + const args = [ + 'playwright', 'test', specFilter, + `--reporter=json,html`, + `--retries=${retryCount}`, + `--workers=${numWorkers}`, + ]; + const env = { + ...process.env, + PLAYWRIGHT_HTML_REPORT: reportDir, + ...(screenshotDir ? { SCREENSHOT_DIR: screenshotDir } : {}), + ...(baseUrlOverride ? { BASE_URL: baseUrlOverride } : {}), + }; + const child = spawn('npx', args, { + cwd: projectPath, + shell: process.platform === 'win32', + env, + }); + + activeProcesses.set(runId, child); + + function emitLine(line: string): void { + const timestamp = new Date().toISOString(); + outputLines.push(line); + handlers?.onLine?.(runId, line, timestamp); + } + + child.stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + for (const line of text.split('\n')) { + if (line.trim()) { + emitLine(line); + // Detect step progress from Playwright action output + const actionMatch = PW_ACTION_PATTERN.exec(line); + if (actionMatch) { + const stepType = resolveStepType(actionMatch[1]); + handlers?.onStep?.(runId, stepCounter, line.trim(), stepType, new Date().toISOString()); + stepCounter++; + } + } + } + }); + + child.stderr.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + for (const line of text.split('\n')) { + if (line.trim()) emitLine(line); + } + }); + + child.on('close', (code) => { + activeProcesses.delete(runId); + const completedAt = new Date().toISOString(); + const status: QaRunRecord['status'] = code === 0 ? 'passed' : 'failed'; + + const startMs = new Date(now).getTime(); + const endMs = new Date(completedAt).getTime(); + + // ── Determine step-level pass/fail counts ── + // Playwright's JSON stats count test *cases*, not steps within a test. + // Look up the script's actual step count from the DB for accurate reporting. + const scriptRow = db.select().from(testSuiteScripts).where(eq(testSuiteScripts.id, scriptId)).all().at(0); + const totalSteps = scriptRow?.stepCount ?? 1; + let stepsPassed: number; + let stepsFailed: number; + + if (code === 0) { + stepsPassed = totalSteps; + stepsFailed = 0; + } else { + // Parse JSON output to determine how many tests failed vs passed + stepsPassed = 0; + stepsFailed = totalSteps; + try { + const fullOutput = outputLines.join('\n'); + const jsonResult = JSON.parse(fullOutput) as { + stats?: { expected?: number; unexpected?: number }; + }; + if (jsonResult.stats?.expected !== undefined) { + // At least some tests passed — partial failure + const passedTests = jsonResult.stats.expected; + const failedTests = jsonResult.stats.unexpected ?? 0; + const total = passedTests + failedTests; + if (total > 0) { + stepsPassed = Math.round((passedTests / total) * totalSteps); + stepsFailed = totalSteps - stepsPassed; + } + } + } catch { + // JSON parse failed — all steps counted as failed + } + } + + const reportPath = join(projectPath, '.playwright-reports', runId, 'index.html'); + const output = JSON.stringify({ outputLines, screenshots, reportPath }); + db.update(testSuiteRuns).set({ + status, + completedAt, + durationMs: endMs - startMs, + stepsPassed, + stepsFailed, + output, + }).where(eq(testSuiteRuns.id, runId)).run(); + + const record: QaRunRecord = { + id: runId, + scriptId, + status, + triggeredBy, + startedAt: now, + completedAt, + outputLines, + screenshots, + stepsPassed, + stepsFailed, + durationMs: endMs - startMs, + reportPath, + }; + + handlers?.onComplete?.(runId, status, record); + }); + + child.on('error', (err) => { + activeProcesses.delete(runId); + const completedAt = new Date().toISOString(); + const startMs = new Date(now).getTime(); + const endMs = new Date(completedAt).getTime(); + const output = JSON.stringify({ outputLines, screenshots, error: err.message }); + + db.update(testSuiteRuns).set({ + status: 'failed', + completedAt, + durationMs: endMs - startMs, + stepsFailed: 1, + output, + }).where(eq(testSuiteRuns.id, runId)).run(); + + const record: QaRunRecord = { + id: runId, + scriptId, + status: 'failed', + triggeredBy, + startedAt: now, + completedAt, + outputLines, + screenshots, + error: err.message, + stepsPassed: 0, + stepsFailed: 1, + durationMs: endMs - startMs, + }; + + handlers?.onComplete?.(runId, 'failed', record); + }); + + return runId; + }, + + get(runId) { + const rows = db.select().from(testSuiteRuns).where(eq(testSuiteRuns.id, runId)).all(); + const row = rows.at(0); + return row ? toRunRecord(row) : null; + }, + + list(scriptId) { + const rows = scriptId + ? db.select().from(testSuiteRuns).where(eq(testSuiteRuns.scriptId, scriptId)).orderBy(desc(testSuiteRuns.startedAt)).all() + : db.select().from(testSuiteRuns).orderBy(desc(testSuiteRuns.startedAt)).all(); + return rows.map(toRunRecord); + }, + + cancel(runId) { + const child = activeProcesses.get(runId); + if (child) { + child.kill(); + activeProcesses.delete(runId); + } + db.update(testSuiteRuns).set({ + status: 'cancelled', + completedAt: new Date().toISOString(), + }).where(eq(testSuiteRuns.id, runId)).run(); + }, + }; +} diff --git a/src/main/features/test-suite/scheduler.ts b/src/main/features/test-suite/scheduler.ts new file mode 100644 index 00000000..32c5912f --- /dev/null +++ b/src/main/features/test-suite/scheduler.ts @@ -0,0 +1,174 @@ +/** + * Test Suite Scheduler Service — setInterval-based periodic test runs + * + * Polls every 30s for due schedules, fires the provided trigger callback, and + * updates lastRunAt/nextRunAt. Notifications surface via electron.Notification. + */ + +import { Notification } from 'electron'; + +import { eq } from 'drizzle-orm'; + +import { generateId } from '@shared/lib/id'; + +import { testSuiteSchedules } from './schema'; + +import type { AdcDatabase } from '../../db'; + +const POLL_INTERVAL_MS = 30_000; + +export interface ScheduleRecord { + id: string; + scriptId: string; + projectId: string; + intervalMs: number; + enabled: boolean; + lastRunAt: string | null; + nextRunAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface SchedulerService { + list: (projectId: string) => ScheduleRecord[]; + get: (id: string) => ScheduleRecord | null; + create: (params: { + scriptId: string; + projectId: string; + intervalMs: number; + }) => ScheduleRecord; + update: ( + id: string, + params: { intervalMs?: number; enabled?: boolean }, + ) => ScheduleRecord | null; + delete: (id: string) => void; + start: (onTrigger: (schedule: ScheduleRecord) => void) => void; + stop: () => void; +} + +function rowToRecord(row: typeof testSuiteSchedules.$inferSelect): ScheduleRecord { + return { + id: row.id, + scriptId: row.scriptId, + projectId: row.projectId, + intervalMs: row.intervalMs, + enabled: row.enabled, + lastRunAt: row.lastRunAt ?? null, + nextRunAt: row.nextRunAt ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function tickOnce(db: AdcDatabase, onTrigger: (schedule: ScheduleRecord) => void): void { + const now = new Date().toISOString(); + const allSchedules = db.select().from(testSuiteSchedules).all().map(rowToRecord); + + for (const schedule of allSchedules) { + if (!schedule.enabled) continue; + if (!schedule.nextRunAt) continue; + if (schedule.nextRunAt > now) continue; + + const nextRun = new Date(Date.now() + schedule.intervalMs).toISOString(); + db.update(testSuiteSchedules) + .set({ lastRunAt: now, nextRunAt: nextRun, updatedAt: now }) + .where(eq(testSuiteSchedules.id, schedule.id)) + .run(); + + onTrigger(schedule); + } +} + +export function createScheduler(db: AdcDatabase): SchedulerService { + let checkInterval: ReturnType | null = null; + + const service: SchedulerService = { + list(projectId) { + return db + .select() + .from(testSuiteSchedules) + .where(eq(testSuiteSchedules.projectId, projectId)) + .all() + .map(rowToRecord); + }, + + get(id) { + const rows = db + .select() + .from(testSuiteSchedules) + .where(eq(testSuiteSchedules.id, id)) + .all(); + const row = rows.at(0); + return row ? rowToRecord(row) : null; + }, + + create(params) { + const now = new Date().toISOString(); + const nextRun = new Date(Date.now() + params.intervalMs).toISOString(); + const id = generateId(); + + const record: ScheduleRecord = { + id, + scriptId: params.scriptId, + projectId: params.projectId, + intervalMs: params.intervalMs, + enabled: true, + lastRunAt: null, + nextRunAt: nextRun, + createdAt: now, + updatedAt: now, + }; + + db.insert(testSuiteSchedules).values(record).run(); + + return record; + }, + + update(id, params) { + const existing = service.get(id); + if (!existing) return null; + + const updates: Record = { updatedAt: new Date().toISOString() }; + if (params.intervalMs !== undefined) { + updates.intervalMs = params.intervalMs; + updates.nextRunAt = new Date(Date.now() + params.intervalMs).toISOString(); + } + if (params.enabled !== undefined) updates.enabled = params.enabled; + + db.update(testSuiteSchedules) + .set(updates) + .where(eq(testSuiteSchedules.id, id)) + .run(); + + return service.get(id); + }, + + delete(id) { + db.delete(testSuiteSchedules).where(eq(testSuiteSchedules.id, id)).run(); + }, + + start(onTrigger) { + if (checkInterval) return; + checkInterval = setInterval(() => tickOnce(db, onTrigger), POLL_INTERVAL_MS); + }, + + stop() { + if (checkInterval) { + clearInterval(checkInterval); + checkInterval = null; + } + }, + }; + + return service; +} + +export function sendTestNotification(scriptName: string, status: string): void { + new Notification({ + title: `Test ${status}: ${scriptName}`, + body: + status === 'failed' + ? `Scheduled run of "${scriptName}" failed. Check the results panel.` + : `Scheduled run of "${scriptName}" passed.`, + }).show(); +} diff --git a/src/main/features/test-suite/schema.ts b/src/main/features/test-suite/schema.ts new file mode 100644 index 00000000..085a2f54 --- /dev/null +++ b/src/main/features/test-suite/schema.ts @@ -0,0 +1,97 @@ +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; + +export const testSuiteScripts = sqliteTable('test_suite_scripts', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + name: text('name').notNull(), + description: text('description'), + baseUrl: text('base_url').notNull().default(''), + steps: text('steps').notNull().default('[]'), + filePath: text('file_path').notNull(), + targetUrl: text('target_url').notNull(), + tags: text('tags').notNull().default('[]'), + stepCount: integer('step_count').notNull().default(0), + lastStatus: text('last_status'), + lastRunAt: text('last_run_at'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), +}); + +export const testSuiteRuns = sqliteTable('test_suite_runs', { + id: text('id').primaryKey(), + scriptId: text('script_id').notNull(), + projectId: text('project_id').notNull(), + status: text('status').notNull(), + triggeredBy: text('triggered_by').notNull().default('manual'), + report: text('report'), + durationMs: integer('duration_ms').notNull().default(0), + stepsPassed: integer('steps_passed').notNull().default(0), + stepsFailed: integer('steps_failed').notNull().default(0), + output: text('output'), + startedAt: text('started_at').notNull(), + completedAt: text('completed_at'), + taskId: text('task_id'), + sessionId: text('session_id'), +}); + +export const testSuiteScreenshots = sqliteTable('test_suite_screenshots', { + id: text('id').primaryKey(), + runId: text('run_id').notNull(), + scriptId: text('script_id').notNull(), + stepIndex: integer('step_index').notNull(), + stepLabel: text('step_label').notNull(), + trigger: text('trigger').notNull(), + filePath: text('file_path').notNull(), + width: integer('width').notNull(), + height: integer('height').notNull(), + capturedAt: text('captured_at').notNull(), +}); + +export const testSuiteBaselines = sqliteTable('test_suite_baselines', { + id: text('id').primaryKey(), + scriptId: text('script_id').notNull(), + stepIndex: integer('step_index').notNull(), + stepLabel: text('step_label').notNull(), + filePath: text('file_path').notNull(), + width: integer('width').notNull(), + height: integer('height').notNull(), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), +}); + +export const testSuiteDiffs = sqliteTable('test_suite_diffs', { + id: text('id').primaryKey(), + runId: text('run_id').notNull(), + baselineId: text('baseline_id').notNull(), + screenshotId: text('screenshot_id').notNull(), + diffFilePath: text('diff_file_path').notNull(), + mismatchPercentage: integer('mismatch_percentage').notNull(), + mismatchPixels: integer('mismatch_pixels').notNull(), + threshold: integer('threshold').notNull(), + status: text('status').notNull(), + createdAt: text('created_at').notNull(), +}); + +export const testSuiteSchedules = sqliteTable('test_suite_schedules', { + id: text('id').primaryKey(), + scriptId: text('script_id').notNull(), + projectId: text('project_id').notNull(), + intervalMs: integer('interval_ms').notNull(), + enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), + lastRunAt: text('last_run_at'), + nextRunAt: text('next_run_at'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), +}); + +export const testSuiteSharedSteps = sqliteTable('test_suite_shared_steps', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + name: text('name').notNull(), + domain: text('domain').notNull(), + description: text('description'), + steps: text('steps').notNull(), + usageCount: integer('usage_count').notNull().default(0), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), +}); diff --git a/src/main/features/test-suite/screenshot-service.ts b/src/main/features/test-suite/screenshot-service.ts new file mode 100644 index 00000000..b8694eba --- /dev/null +++ b/src/main/features/test-suite/screenshot-service.ts @@ -0,0 +1,229 @@ +/** + * Screenshot Capture — Drizzle-backed store + post-run indexer + * + * Scans the screenshot directory after a test run completes, parses + * filenames into ScreenshotRecord metadata, and persists them in the + * test_suite_screenshots SQLite table. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import { eq } from 'drizzle-orm'; + +import { generateId } from '@shared/lib/id'; + +import { testSuiteScreenshots } from '../../db/schema'; + +import type { AdcDatabase } from '../../db'; + +// ─── Types ────────────────────────────────────────────────── + +export interface ScreenshotRecord { + id: string; + runId: string; + scriptId: string; + stepIndex: number; + stepLabel: string; + trigger: 'nav' | 'click' | 'fill' | 'assert' | 'manual'; + filePath: string; + width: number; + height: number; + capturedAt: string; +} + +// ─── Service interface ──────────────────────────────────────── + +export interface ScreenshotService { + index: (params: { runId: string; scriptId: string; screenshotDir: string; projectPath?: string }) => ScreenshotRecord[]; + list: (runId: string) => ScreenshotRecord[]; + listByScript: (scriptId: string) => ScreenshotRecord[]; + get: (id: string) => ScreenshotRecord | null; +} + +// ─── Helpers ──────────────────────────────────────────────── + +/** Read width × height from a PNG file's IHDR chunk (bytes 16-23). */ +function readPngDimensions(filePath: string): { width: number; height: number } { + try { + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(24); + fs.readSync(fd, buf, 0, 24, 0); + fs.closeSync(fd); + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + return { width, height }; + } catch { + return { width: 0, height: 0 }; + } +} + +// ─── Factory ──────────────────────────────────────────────── + +export function createScreenshotService(db: AdcDatabase): ScreenshotService { + function toRecord(row: typeof testSuiteScreenshots.$inferSelect): ScreenshotRecord { + return { + id: row.id, + runId: row.runId, + scriptId: row.scriptId, + stepIndex: row.stepIndex, + stepLabel: row.stepLabel, + trigger: row.trigger as ScreenshotRecord['trigger'], + filePath: row.filePath, + width: row.width, + height: row.height, + capturedAt: row.capturedAt, + }; + } + + return { + /** + * Scan a screenshot directory after a run completes, parse filenames + * into records, and persist them to the database. + */ + index(params) { + console.warn('[screenshot-service] index() called:', { + runId: params.runId, + scriptId: params.scriptId, + screenshotDir: params.screenshotDir, + exists: fs.existsSync(params.screenshotDir), + projectPath: params.projectPath, + }); + + const records: ScreenshotRecord[] = []; + + // 1. Scan our custom SCREENSHOT_DIR for files like "01-navigate.png" + if (fs.existsSync(params.screenshotDir)) { + const allFiles = fs.readdirSync(params.screenshotDir); + const files = allFiles.filter((f) => f.endsWith('.png')).sort(); + console.warn('[screenshot-service] custom dir files:', { total: allFiles.length, pngs: files.length, files }); + + for (const file of files) { + const match = /^(\d+)-(\w+)\.png$/.exec(file); + const stepIndex = match ? parseInt(match[1], 10) : 0; + const triggerRaw = match ? match[2] : 'manual'; + const trigger = mapTrigger(triggerRaw); + const fullPath = path.join(params.screenshotDir, file); + const { width, height } = readPngDimensions(fullPath); + + records.push({ + id: generateId(), + runId: params.runId, + scriptId: params.scriptId, + stepIndex, + stepLabel: `${trigger} step ${stepIndex}`, + trigger, + filePath: fullPath, + width, + height, + capturedAt: new Date().toISOString(), + }); + } + } else { + console.warn('[screenshot-service] screenshotDir does not exist:', params.screenshotDir); + } + + // 2. Fallback: scan Playwright's test-results/ for built-in screenshots + if (records.length === 0 && params.projectPath) { + const testResultsDir = path.join(params.projectPath, 'test-results'); + console.warn('[screenshot-service] fallback: scanning test-results/', { exists: fs.existsSync(testResultsDir) }); + if (fs.existsSync(testResultsDir)) { + const pngs = findPngsRecursive(testResultsDir); + console.warn('[screenshot-service] found in test-results:', pngs.length); + let idx = 0; + for (const fullPath of pngs) { + const { width, height } = readPngDimensions(fullPath); + const baseName = path.basename(fullPath, '.png'); + records.push({ + id: generateId(), + runId: params.runId, + scriptId: params.scriptId, + stepIndex: idx++, + stepLabel: baseName, + trigger: 'manual', + filePath: fullPath, + width, + height, + capturedAt: new Date().toISOString(), + }); + } + } + } + + // Batch insert into SQLite + for (const record of records) { + db.insert(testSuiteScreenshots).values(record).run(); + } + + console.warn('[screenshot-service] indexed total:', records.length); + return records; + }, + + /** + * Retrieve indexed screenshots for a given run. + */ + list(runId) { + return db + .select() + .from(testSuiteScreenshots) + .where(eq(testSuiteScreenshots.runId, runId)) + .all() + .map(toRecord); + }, + + /** + * Retrieve indexed screenshots for a given script. + */ + listByScript(scriptId) { + return db + .select() + .from(testSuiteScreenshots) + .where(eq(testSuiteScreenshots.scriptId, scriptId)) + .all() + .map(toRecord); + }, + + /** + * Retrieve a single screenshot by its id. + */ + get(id) { + const row = db + .select() + .from(testSuiteScreenshots) + .where(eq(testSuiteScreenshots.id, id)) + .all() + .at(0); + return row ? toRecord(row) : null; + }, + }; +} + +// ─── Recursive PNG finder ────────────────────────────────── + +/** Recursively find all .png files in a directory (for Playwright test-results). */ +function findPngsRecursive(dir: string): string[] { + const results: string[] = []; + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findPngsRecursive(fullPath)); + } else if (entry.name.endsWith('.png')) { + results.push(fullPath); + } + } + } catch { + // Directory access error — skip + } + return results; +} + +// ─── Trigger mapping ──────────────────────────────────────── + +function mapTrigger(raw: string): ScreenshotRecord['trigger'] { + if (raw === 'navigate') return 'nav'; + if (raw === 'click') return 'click'; + if (raw === 'fill') return 'fill'; + if (raw === 'assert') return 'assert'; + return 'manual'; +} diff --git a/src/main/features/test-suite/script-service.ts b/src/main/features/test-suite/script-service.ts new file mode 100644 index 00000000..54aed7b1 --- /dev/null +++ b/src/main/features/test-suite/script-service.ts @@ -0,0 +1,140 @@ +/** + * Test Suite Script Store — Drizzle CRUD for test_suite_scripts table + */ + +import { eq } from 'drizzle-orm'; + +import { generateId } from '@shared/lib/id'; + +import { testSuiteScripts } from '../../db/schema'; + +import type { AdcDatabase } from '../../db'; + +export interface QaScript { + id: string; + name: string; + description: string | null; + steps: unknown[]; + tags: string[]; + filePath: string; + projectId: string; + targetUrl: string; + stepCount: number; + lastStatus: string | null; + lastRunAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ScriptService { + list: () => QaScript[]; + listByProject: (projectId: string) => QaScript[]; + get: (id: string) => QaScript | null; + save: (data: { + id?: string; + name: string; + description?: string; + steps: unknown[]; + tags?: string[]; + projectId: string; + filePath: string; + targetUrl: string; + }) => QaScript; + delete: (id: string) => { success: boolean }; +} + +function parseSteps(raw: string): unknown[] { + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function toQaScript(row: typeof testSuiteScripts.$inferSelect): QaScript { + const steps = parseSteps(row.steps); + const tags = parseSteps(row.tags) as string[]; + return { + id: row.id, + name: row.name, + description: row.description ?? null, + steps, + tags, + filePath: row.filePath, + projectId: row.projectId, + targetUrl: row.targetUrl, + stepCount: row.stepCount, + lastStatus: row.lastStatus ?? null, + lastRunAt: row.lastRunAt ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function createScriptService(db: AdcDatabase): ScriptService { + return { + list() { + return db.select().from(testSuiteScripts).all().map(toQaScript); + }, + + listByProject(projectId) { + return db.select().from(testSuiteScripts).where(eq(testSuiteScripts.projectId, projectId)).all().map(toQaScript); + }, + + get(id) { + const rows = db.select().from(testSuiteScripts).where(eq(testSuiteScripts.id, id)).all(); + const row = rows.at(0); + return row ? toQaScript(row) : null; + }, + + save(data) { + const now = new Date().toISOString(); + const stepsJson = JSON.stringify(data.steps); + const tagsJson = JSON.stringify(data.tags ?? []); + const existing = data.id + ? db.select().from(testSuiteScripts).where(eq(testSuiteScripts.id, data.id)).all().at(0) + : undefined; + + if (existing) { + const updated = { + ...existing, + name: data.name, + description: data.description ?? existing.description, + steps: stepsJson, + tags: tagsJson, + filePath: data.filePath, + targetUrl: data.targetUrl, + stepCount: data.steps.length, + updatedAt: now, + }; + db.update(testSuiteScripts).set(updated).where(eq(testSuiteScripts.id, existing.id)).run(); + return toQaScript(updated); + } + + const record = { + id: data.id ?? generateId(), + name: data.name, + description: data.description ?? null, + baseUrl: data.targetUrl, + steps: stepsJson, + tags: tagsJson, + projectId: data.projectId, + filePath: data.filePath, + targetUrl: data.targetUrl, + stepCount: data.steps.length, + lastStatus: null, + lastRunAt: null, + createdAt: now, + updatedAt: now, + }; + db.insert(testSuiteScripts).values(record).run(); + return toQaScript(record); + }, + + delete(id) { + const result = db.delete(testSuiteScripts).where(eq(testSuiteScripts.id, id)).run(); + return { success: result.changes > 0 }; + }, + }; +} diff --git a/src/main/features/test-suite/script-writer.ts b/src/main/features/test-suite/script-writer.ts new file mode 100644 index 00000000..85a8d1d0 --- /dev/null +++ b/src/main/features/test-suite/script-writer.ts @@ -0,0 +1,245 @@ +/** + * Script Writer — generates Playwright .spec.ts files from TestSuiteStep[] + * + * Pure utility: takes recorded steps, writes a valid Playwright test file + * to `//scripts/.spec.ts`. + * + * Features: + * - Smart waits after navigation (waitForLoadState + configurable timeout) + * - Playwright-preferred locators (getByTestId > getByLabel > getByPlaceholder > getByRole > getByText > CSS fallback) + * - Configurable action timeouts on click/fill/select + */ + +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { TestSuiteStepSchema } from '@shared/ipc/test-suite/schemas'; + +type TestSuiteStep = typeof TestSuiteStepSchema extends { _output: infer T } ? T : never; + +// ─── Public API ────────────────────────────────────────────── + +type ScreenshotMode = 'smart' | 'per-click' | 'per-nav' | 'per-form' | 'per-assertion' | 'manual'; + +interface StepContext { + text?: string; + label?: string; + placeholder?: string; + tagName: string; + inputType?: string; +} + +export function writeSpecFile(params: { + projectRoot: string; + testDir: string; + name: string; + baseUrl: string; + steps: TestSuiteStep[]; + screenshotMode?: ScreenshotMode; + navigationTimeout?: number; + actionTimeout?: number; +}): string { + const mode = params.screenshotMode ?? 'smart'; + const navigationTimeout = params.navigationTimeout ?? 30_000; + const actionTimeout = params.actionTimeout ?? 10_000; + let ssIdx = 0; + + function screenshotLine(stepType: string): string { + const idx = String(ssIdx++).padStart(2, '0'); + return ` await page.screenshot({ path: \`\${process.env.SCREENSHOT_DIR}/${idx}-${stepType}.png\` });`; + } + + /** + * Determines whether a screenshot should be injected after the current step. + * `prevType` is the type of the step immediately before, used for form-submit heuristic. + */ + function shouldCapture(step: TestSuiteStep, prevType: string | null): boolean { + switch (mode) { + case 'manual': + return false; + case 'per-click': + return step.type === 'click'; + case 'per-nav': + return step.type === 'navigate'; + case 'per-assertion': + return step.type === 'assert'; + case 'per-form': + // click that follows one or more fills = form submit heuristic + return step.type === 'click' && prevType === 'fill'; + case 'smart': + // Smart mode captures after every actionable step + if (step.type === 'navigate') return true; + if (step.type === 'click') return true; + if (step.type === 'fill') return true; + if (step.type === 'select') return true; + if (step.type === 'assert') return true; + return false; + default: + return false; + } + } + + const lines: string[] = [ + "import { test, expect } from '@playwright/test';", + '', + `test('${escape(params.name)}', async ({ page }) => {`, + ]; + + // If the first step isn't a navigate, inject page.goto(baseUrl) so Playwright + // doesn't run against a blank page. + const firstStep = params.steps.at(0); + if (firstStep?.type !== 'navigate' && params.baseUrl) { + lines.push( + ` await page.goto('${escape(params.baseUrl)}');`, + ` await page.waitForLoadState('networkidle', { timeout: ${navigationTimeout} });`, + ); + } + + let prevType: string | null = null; + + for (const s of params.steps) { + switch (s.type) { + case 'navigate': + lines.push( + ` await page.goto('${escape(s.url)}');`, + ` await page.waitForLoadState('networkidle', { timeout: ${navigationTimeout} });`, + ); + break; + case 'click': + lines.push(` await ${buildLocator(s.selector, s.context)}.click({ timeout: ${actionTimeout} });`); + break; + case 'fill': + lines.push(` await ${buildLocator(s.selector, s.context)}.fill('${escape(s.value)}', { timeout: ${actionTimeout} });`); + break; + case 'select': + lines.push(` await ${buildLocator(s.selector, s.context)}.selectOption('${escape(s.value)}', { timeout: ${actionTimeout} });`); + break; + case 'press': + lines.push(` await page.keyboard.press('${escape(s.key)}');`); + break; + case 'wait': + lines.push(` await page.waitForTimeout(${s.ms});`); + break; + case 'assert': { + const method = s.assertMethod ?? 'toHaveText'; + const locator = `page.locator('${escape(s.selector)}')`; + switch (method) { + case 'toBeVisible': + lines.push(` await expect(${locator}).toBeVisible({ timeout: ${actionTimeout} });`); + break; + case 'toBeHidden': + lines.push(` await expect(${locator}).toBeHidden({ timeout: ${actionTimeout} });`); + break; + case 'toContainText': + lines.push(` await expect(${locator}).toContainText('${escape(s.expected)}', { timeout: ${actionTimeout} });`); + break; + case 'toHaveCount': + lines.push(` await expect(${locator}).toHaveCount(${Number.isNaN(parseInt(s.expected, 10)) ? 0 : parseInt(s.expected, 10)}, { timeout: ${actionTimeout} });`); + break; + case 'toHaveAttribute': + lines.push(` await expect(${locator}).toHaveAttribute('${escape(s.attribute ?? '')}', '${escape(s.expected)}', { timeout: ${actionTimeout} });`); + break; + case 'toHaveURL': + lines.push(` await expect(page).toHaveURL('${escape(s.expected)}', { timeout: ${actionTimeout} });`); + break; + case 'toHaveTitle': + lines.push(` await expect(page).toHaveTitle('${escape(s.expected)}', { timeout: ${actionTimeout} });`); + break; + case 'toHaveText': + default: + lines.push(` await expect(${locator}).toHaveText('${escape(s.expected)}', { timeout: ${actionTimeout} });`); + break; + } + break; + } + } + + if (shouldCapture(s, prevType)) { + lines.push(screenshotLine(s.type)); + } + + // Track previous step type — for form-submit heuristic, carry 'fill' + // through consecutive fills so click-after-fills still triggers. + prevType = s.type === 'fill' ? 'fill' : s.type; + } + + lines.push('});', ''); + + const outDir = path.join(params.projectRoot, params.testDir, 'scripts'); + mkdirSync(outDir, { recursive: true }); + + const filePath = path.join(outDir, `${slugify(params.name)}.spec.ts`); + writeFileSync(filePath, lines.join('\n'), 'utf8'); + + return filePath; +} + +// ─── Locator Builder ──────────────────────────────────────── + +/** + * Maps a step's selector + optional context to the best Playwright locator. + * + * Priority chain (Playwright recommended order): + * 1. data-testid — `page.getByTestId('...')` + * 2. label — `page.getByLabel('...')` + * 3. placeholder — `page.getByPlaceholder('...')` + * 4. role+text — `page.getByRole('button'|'link', { name: '...' })` + * 5. short text — `page.getByText('...')` + * 6. CSS fallback — `page.locator('...')` + */ +function buildLocator(selector: string, context?: StepContext): string { + // Strip SVG/path children — clicks on icons should target the parent button/link + const cleanedSelector = selector.replace(/\s*>\s*(svg|path)(\s*>\s*(svg|path))*$/i, ''); + + // Priority 1: data-testid from selector + const testIdMatch = /\[data-testid="([^"]+)"\]/.exec(cleanedSelector); + if (testIdMatch) return `page.getByTestId('${escape(testIdMatch[1])}')`; + + if (context) { + // Priority 2: label + if (context.label) { + return `page.getByLabel('${escape(context.label)}')`; + } + + // Priority 3: placeholder + if (context.placeholder) { + return `page.getByPlaceholder('${escape(context.placeholder)}')`; + } + + // Priority 4: role-based (buttons, links) — also match when click target was SVG/path inside button + const isIconClick = ['svg', 'path'].includes(context.tagName); + if (context.text && (['button', 'a'].includes(context.tagName) || isIconClick)) { + // For icon clicks, check if the cleaned selector ends with a button/link + const parentIsClickable = isIconClick && /\b(button|a)\b/i.test(cleanedSelector); + if (!isIconClick || parentIsClickable) { + const role = context.tagName === 'a' || /\ba\b/.test(cleanedSelector) ? 'link' : 'button'; + return `page.getByRole('${role}', { name: '${escape(context.text)}' })`; + } + } + + // Priority 5: text content (short text only, exclude form elements) + if ( + context.text && + context.text.length < 60 && + !['input', 'select', 'textarea'].includes(context.tagName) + ) { + return `page.getByText('${escape(context.text)}')`; + } + } + + // Priority 6: CSS fallback — use cleaned selector (SVG/path stripped) + return `page.locator('${escape(cleanedSelector)}')`; +} + +// ─── Helpers ───────────────────────────────────────────────── + +function escape(s: string): string { + return s.replaceAll('\\', '\\\\').replaceAll("'", "\\'"); +} + +function slugify(s: string): string { + return s + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-|-$/g, ''); +} diff --git a/src/main/features/test-suite/shared-steps-service.ts b/src/main/features/test-suite/shared-steps-service.ts new file mode 100644 index 00000000..d2870d51 --- /dev/null +++ b/src/main/features/test-suite/shared-steps-service.ts @@ -0,0 +1,174 @@ +/** + * Test Suite Shared Steps Store — Drizzle CRUD for test_suite_shared_steps table + * + * Stores reusable step sequences grouped by domain. The `steps` column holds + * a JSON-serialized TestSuiteStep[] array. + */ + +import { eq, and, sql } from 'drizzle-orm'; + +import { generateId } from '@shared/lib/id'; +import type { TestSuiteStep } from '@shared/types/test-suite'; + +import { testSuiteSharedSteps } from './schema'; + +import type { AdcDatabase } from '../../db'; + +export interface SharedStepGroup { + id: string; + projectId: string; + name: string; + domain: string; + description: string | null; + steps: TestSuiteStep[]; + usageCount: number; + createdAt: string; + updatedAt: string; +} + +export interface SharedStepsService { + list: (projectId: string) => SharedStepGroup[]; + listByDomain: (projectId: string, domain: string) => SharedStepGroup[]; + get: (id: string) => SharedStepGroup | null; + create: (params: { + projectId: string; + name: string; + domain: string; + description?: string; + steps: TestSuiteStep[]; + }) => SharedStepGroup; + update: ( + id: string, + params: { + name?: string; + domain?: string; + description?: string; + steps?: TestSuiteStep[]; + }, + ) => SharedStepGroup | null; + delete: (id: string) => void; + incrementUsage: (id: string) => void; + domains: (projectId: string) => string[]; +} + +function rowToGroup(row: typeof testSuiteSharedSteps.$inferSelect): SharedStepGroup { + return { + id: row.id, + projectId: row.projectId, + name: row.name, + domain: row.domain, + description: row.description, + steps: JSON.parse(row.steps) as TestSuiteStep[], + usageCount: row.usageCount, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function createSharedStepsService(db: AdcDatabase): SharedStepsService { + const store: SharedStepsService = { + list(projectId) { + return db + .select() + .from(testSuiteSharedSteps) + .where(eq(testSuiteSharedSteps.projectId, projectId)) + .all() + .map(rowToGroup); + }, + + listByDomain(projectId, domain) { + return db + .select() + .from(testSuiteSharedSteps) + .where( + and( + eq(testSuiteSharedSteps.projectId, projectId), + eq(testSuiteSharedSteps.domain, domain), + ), + ) + .all() + .map(rowToGroup); + }, + + get(id) { + const rows = db + .select() + .from(testSuiteSharedSteps) + .where(eq(testSuiteSharedSteps.id, id)) + .all(); + const row = rows.at(0); + return row ? rowToGroup(row) : null; + }, + + create(params) { + const now = new Date().toISOString(); + const id = generateId(); + + const record = { + id, + projectId: params.projectId, + name: params.name, + domain: params.domain, + description: params.description ?? null, + steps: JSON.stringify(params.steps), + usageCount: 0, + createdAt: now, + updatedAt: now, + }; + + db.insert(testSuiteSharedSteps).values(record).run(); + + return { + id, + projectId: params.projectId, + name: params.name, + domain: params.domain, + description: record.description, + steps: params.steps, + usageCount: 0, + createdAt: now, + updatedAt: now, + }; + }, + + update(id, params) { + const existing = store.get(id); + if (!existing) return null; + + const updates: Record = { updatedAt: new Date().toISOString() }; + if (params.name !== undefined) updates.name = params.name; + if (params.domain !== undefined) updates.domain = params.domain; + if (params.description !== undefined) updates.description = params.description; + if (params.steps !== undefined) updates.steps = JSON.stringify(params.steps); + + db.update(testSuiteSharedSteps) + .set(updates) + .where(eq(testSuiteSharedSteps.id, id)) + .run(); + + return store.get(id); + }, + + delete(id) { + db.delete(testSuiteSharedSteps).where(eq(testSuiteSharedSteps.id, id)).run(); + }, + + incrementUsage(id) { + db.update(testSuiteSharedSteps) + .set({ usageCount: sql`${testSuiteSharedSteps.usageCount} + 1` }) + .where(eq(testSuiteSharedSteps.id, id)) + .run(); + }, + + domains(projectId) { + const rows = db + .selectDistinct({ domain: testSuiteSharedSteps.domain }) + .from(testSuiteSharedSteps) + .where(eq(testSuiteSharedSteps.projectId, projectId)) + .all(); + return rows.map((r) => r.domain); + }, + }; + + return store; +} diff --git a/src/main/features/test-suite/test-suite-handlers.ts b/src/main/features/test-suite/test-suite-handlers.ts new file mode 100644 index 00000000..7492feb4 --- /dev/null +++ b/src/main/features/test-suite/test-suite-handlers.ts @@ -0,0 +1,106 @@ +/** + * Test Suite IPC Handlers + * + * Bridges test-suite service to the renderer via IPC. + * Thin handlers — all logic delegated to service. + * + * Domain-specific handler groups are registered via sub-modules in ./handlers/. + */ + +import { TEST_SUITE_EVENTS } from '@shared/ipc/test-suite/channels'; + +import { registerAnalyticsHandlers } from './handlers/analytics-handlers'; +import { registerAuthHandlers } from './handlers/auth-handlers'; +import { registerBaselineHandlers } from './handlers/baseline-handlers'; +import { registerBrowserViewHandlers } from './handlers/browser-view-handlers'; +import { registerConfigHandlers } from './handlers/config-handlers'; +import { registerDataRunHandlers } from './handlers/data-run-handlers'; +import { registerExportHandlers } from './handlers/export-handlers'; +import { registerRunHandlers } from './handlers/run-handlers'; +import { registerScheduleHandlers } from './handlers/schedule-handlers'; +import { registerScreenshotHandlers } from './handlers/screenshot-handlers'; +import { registerScriptHandlers } from './handlers/script-handlers'; +import { registerSetupHandlers } from './handlers/setup-handlers'; +import { registerSharedStepsHandlers } from './handlers/shared-steps-handlers'; +import { registerWatchHandlers } from './handlers/watch-handlers'; + +import type { TestSuiteService } from './test-suite-service'; +import type { IpcRouter } from '../../ipc/router'; +import type { ProjectService } from '../projects/project-service'; + + +export type { TestSuiteService, TestSuiteRunEvent } from './test-suite-service'; + +// ─── Handler Registration ────────────────────────────────────── + +export function registerTestSuiteHandlers( + router: IpcRouter, + testSuiteService: TestSuiteService, + projectService: ProjectService, +): void { + // ── Event forwarding ────────────────────────────────────────── + + testSuiteService.onRunEvent((event) => { + if (event.type === 'output' && event.line !== undefined && event.timestamp !== undefined) { + router.emit(TEST_SUITE_EVENTS.OUTPUT.LINE, { + runId: event.runId, + line: event.line, + timestamp: event.timestamp, + }); + } + + if ( + event.type === 'screenshot' && + event.screenshotPath !== undefined && + event.stepIndex !== undefined && + event.timestamp !== undefined + ) { + router.emit(TEST_SUITE_EVENTS.RUN.SCREENSHOT, { + runId: event.runId, + screenshotPath: event.screenshotPath, + stepIndex: event.stepIndex, + timestamp: event.timestamp, + }); + } + + if ( + event.type === 'step' && + event.stepIndex !== undefined && + event.stepLabel !== undefined && + event.timestamp !== undefined + ) { + router.emit(TEST_SUITE_EVENTS.RUN.STEP, { + runId: event.runId, + stepIndex: event.stepIndex, + stepLabel: event.stepLabel, + stepType: event.stepType ?? '', + timestamp: event.timestamp, + }); + } + + if (event.type === 'complete' && event.status !== undefined && event.report !== undefined) { + router.emit(TEST_SUITE_EVENTS.RUN.COMPLETED, { + runId: event.runId, + status: event.status, + report: event.report, + }); + } + }); + + // ── Domain-specific handler groups ──────────────────────────── + + registerScriptHandlers(router, testSuiteService, projectService); + registerRunHandlers(router, testSuiteService); + registerExportHandlers(router, testSuiteService, projectService); + registerBrowserViewHandlers(router, testSuiteService); + registerConfigHandlers(router, testSuiteService); + registerScreenshotHandlers(router, testSuiteService); + registerDataRunHandlers(router, testSuiteService); + registerAuthHandlers(router, testSuiteService); + registerAnalyticsHandlers(router, testSuiteService); + registerWatchHandlers(router, testSuiteService); + registerBaselineHandlers(router, testSuiteService); + registerSharedStepsHandlers(router, testSuiteService); + registerScheduleHandlers(router, testSuiteService); + registerSetupHandlers(router, testSuiteService); +} diff --git a/src/main/features/test-suite/test-suite-service.ts b/src/main/features/test-suite/test-suite-service.ts new file mode 100644 index 00000000..1137033d --- /dev/null +++ b/src/main/features/test-suite/test-suite-service.ts @@ -0,0 +1,500 @@ +/** + * Test Suite Service — Factory + * + * Composes script store, runner, and script-writer into a single facade that + * satisfies the IPC handler interface expected by test-suite-handlers.ts. + */ + +import { execSync } from 'node:child_process'; +import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { BrowserWindow } from 'electron'; + +import { eq } from 'drizzle-orm'; + +import type { TestSuiteStepSchema } from '@shared/ipc/test-suite/schemas'; + +import { testSuiteRuns } from '../../db/schema'; + +import { createAnalytics } from './analytics'; +import { createBaselineService } from './baseline-service'; +import { createBrowserViewManager } from './browser-view-manager'; +import { createConfigService } from './config-service'; +import { writePlaywrightConfig } from './playwright-config-writer'; +import { createRunner } from './runner'; +import { createScheduler, sendTestNotification } from './scheduler'; +import { createScreenshotService } from './screenshot-service'; +import { createScriptService } from './script-service'; +import { writeSpecFile } from './script-writer'; +import { createSharedStepsService } from './shared-steps-service'; +import { createFileWatcher } from './watcher'; + +import type { Analytics } from './analytics'; +import type { BaselineService } from './baseline-service'; +import type { BrowserViewManager } from './browser-view-manager'; +import type { ConfigService } from './config-service'; +import type { QaRunner, QaRunRecord, RunnerEventHandlers } from './runner'; +import type { SchedulerService } from './scheduler'; +import type { ScreenshotService } from './screenshot-service'; +import type { ScriptService, QaScript } from './script-service'; +import type { SharedStepsService } from './shared-steps-service'; +import type { FileWatcher } from './watcher'; +import type { AdcDatabase } from '../../db'; + +type TestSuiteStep = typeof TestSuiteStepSchema extends { _output: infer T } ? T : never; + +// ─── IPC-compatible run record (triggeredBy without 'auto-trigger') ── + +export interface QaRunIpcRecord { + id: string; + scriptId: string; + status: 'running' | 'passed' | 'failed' | 'cancelled'; + triggeredBy: 'manual' | 'scheduled' | 'ci'; + startedAt: string; + completedAt?: string; + outputLines: string[]; + screenshots: string[]; + error?: string; + stepsPassed: number; + stepsFailed: number; + durationMs: number; +} + +// ─── Run event listener type ────────────────────────────────── + +export interface TestSuiteRunEvent { + type: 'output' | 'screenshot' | 'step' | 'complete'; + runId: string; + line?: string; + timestamp?: string; + screenshotPath?: string; + stepIndex?: number; + stepLabel?: string; + stepType?: string; + status?: QaRunRecord['status']; + report?: { + runId: string; + scriptId: string; + status: QaRunRecord['status']; + totalSteps: number; + passedSteps: number; + failedSteps: number; + duration: number; + screenshots: string[]; + outputLines: string[]; + startedAt: string; + completedAt?: string; + }; +} + +// ─── Facade interface (satisfies test-suite-handlers.ts) ──────── + +export interface TestSuiteService { + // Sub-services (used by qa-trigger.ts and other internal consumers) + scriptStore: ScriptService; + runner: QaRunner; + configStore: ConfigService; + browserViewManager: BrowserViewManager; + screenshotStore: ScreenshotService; + analytics: Analytics; + fileWatcher: FileWatcher; + baselineStore: BaselineService; + sharedStepsStore: SharedStepsService; + scheduler: SchedulerService; + getProjectPath: (projectId: string) => string | undefined; + db: AdcDatabase; + + // Async facade methods (used by IPC handler layer) + listScripts: () => Promise; + listScriptsByProject: (projectId: string) => Promise; + getScript: (id: string) => Promise; + saveScript: (input: { + id?: string; + projectId: string; + name: string; + description?: string; + steps: TestSuiteStep[]; + tags?: string[]; + filePath?: string; + }) => Promise; + deleteScript: (id: string) => Promise<{ success: boolean }>; + runScript: (input: { + scriptId: string; + triggeredBy: 'manual' | 'scheduled' | 'ci' | 'auto-trigger'; + filePathOverride?: string; + baseUrlOverride?: string; + }) => Promise<{ runId: string }>; + getRun: (runId: string) => Promise; + listRuns: (input: { scriptId?: string }) => Promise; + exportFile: (input: { + runId: string; + format: 'json' | 'html' | 'csv'; + }) => Promise<{ filePath: string }>; + exportGithub: (input: { + runId: string; + owner: string; + repo: string; + }) => Promise<{ issueUrl: string }>; + attachRunToTask: (runId: string, taskId: string) => Promise<{ success: boolean }>; + onRunEvent: (listener: (event: TestSuiteRunEvent) => void) => void; + saveAuthState: (projectId: string) => Promise<{ storageStatePath: string }>; + clearAuthState: (projectId: string) => Promise<{ success: boolean }>; + batchRun: (input: { + scriptIds: string[]; + triggeredBy: 'manual' | 'scheduled' | 'ci'; + baseUrlOverride?: string; + }) => Promise<{ runIds: string[]; total: number }>; +} + +export function createTestSuiteService( + db: AdcDatabase, + deps: { + getMainWindow: () => BrowserWindow | null; + getProjectPath: (projectId: string) => string | undefined; + }, +): TestSuiteService { + const scriptStore = createScriptService(db); + const screenshotStore = createScreenshotService(db); + const browserViewManager = createBrowserViewManager(deps.getMainWindow); + const runEventListeners: Array<(event: TestSuiteRunEvent) => void> = []; + + // Tracks screenshotDir per runId so onComplete can index screenshots + const runScreenshotDirs = new Map(); + + const sharedHandlers: RunnerEventHandlers = { + onLine(runId, line, timestamp) { + const event: TestSuiteRunEvent = { type: 'output', runId, line, timestamp }; + for (const listener of runEventListeners) listener(event); + }, + onStep(runId, stepIndex, stepLabel, stepType, timestamp) { + const event: TestSuiteRunEvent = { + type: 'step', + runId, + stepIndex, + stepLabel, + stepType, + timestamp, + }; + for (const listener of runEventListeners) listener(event); + }, + onComplete(runId, status, record) { + // Index screenshots if a screenshot dir was configured for this run + const ssMeta = runScreenshotDirs.get(runId); + console.warn('[test-suite-service] onComplete:', { + runId, + status, + hasScreenshotDir: !!ssMeta, + screenshotDir: ssMeta?.screenshotDir, + }); + if (ssMeta) { + const indexed = screenshotStore.index({ + runId, + scriptId: ssMeta.scriptId, + screenshotDir: ssMeta.screenshotDir, + projectPath: ssMeta.projectPath, + }); + console.warn('[test-suite-service] indexed screenshots:', indexed.length); + runScreenshotDirs.delete(runId); + } + + const startedMs = new Date(record.startedAt).getTime(); + const completedMs = record.completedAt ? new Date(record.completedAt).getTime() : Date.now(); + const event: TestSuiteRunEvent = { + type: 'complete', + runId, + status, + report: { + runId, + scriptId: record.scriptId, + status, + totalSteps: record.outputLines.length, + passedSteps: status === 'passed' ? record.outputLines.length : 0, + failedSteps: status === 'failed' ? record.outputLines.length : 0, + duration: completedMs - startedMs, + screenshots: record.screenshots, + outputLines: record.outputLines, + startedAt: record.startedAt, + completedAt: record.completedAt, + }, + }; + for (const listener of runEventListeners) listener(event); + }, + }; + + const runner = createRunner(db); + const configStore = createConfigService(db); + const analytics = createAnalytics(db); + const fileWatcher = createFileWatcher(); + const baselineStore = createBaselineService(db); + const sharedStepsStore = createSharedStepsService(db); + const scheduler = createScheduler(db); + + const service: TestSuiteService = { + // Sub-services + scriptStore, + runner, + configStore, + browserViewManager, + screenshotStore, + analytics, + fileWatcher, + baselineStore, + sharedStepsStore, + scheduler, + getProjectPath: deps.getProjectPath, + db, + + // Facade methods + listScripts: () => Promise.resolve(scriptStore.list()), + + listScriptsByProject: (projectId) => Promise.resolve(scriptStore.listByProject(projectId)), + + getScript: (id) => Promise.resolve(scriptStore.get(id)), + + saveScript: (input) => { + const config = configStore.getActive(input.projectId); + return Promise.resolve(scriptStore.save({ + id: input.id, + name: input.name, + description: input.description, + steps: input.steps, + tags: input.tags, + projectId: input.projectId, + filePath: input.filePath ?? '', + targetUrl: config?.targetUrl ?? '', + })); + }, + + deleteScript: (id) => Promise.resolve(scriptStore.delete(id)), + + async runScript({ scriptId, triggeredBy, filePathOverride, baseUrlOverride }) { + const script = scriptStore.get(scriptId); + if (!script) { + throw new Error(`Script not found: ${scriptId}`); + } + const projectPath = deps.getProjectPath(script.projectId); + if (!projectPath) { + throw new Error(`Project path not found for projectId: ${script.projectId}`); + } + + // Compute screenshot directory from config + const config = configStore.getActive(script.projectId); + const testDir = config?.testDirectory ?? 'tests/e2e'; + const screenshotMode = config?.screenshotMode ?? 'smart'; + const workers = config?.workers ?? 1; + const retries = config?.retries ?? 1; + let screenshotDir: string | undefined; + + if (screenshotMode !== 'manual') { + const slug = script.name + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-|-$/g, ''); + const dirName = `${slug}-screenshots`; + screenshotDir = path.join(projectPath, testDir, 'screenshots', dirName); + } + + // Pre-flight: verify target URL is reachable before spawning Playwright + const targetUrl = baseUrlOverride ?? config?.targetUrl ?? script.targetUrl; + if (targetUrl) { + try { + await fetch(targetUrl, { method: 'HEAD', signal: AbortSignal.timeout(5000) }); + } catch { + throw new Error(`Target URL is not reachable: ${targetUrl} — is your dev server running?`); + } + } + + // Data-driven runs pass their own spec file; normal runs always + // regenerate so script-writer improvements take effect. + let resolvedFilePath: string; + if (filePathOverride) { + resolvedFilePath = filePathOverride; + } else { + resolvedFilePath = writeSpecFile({ + projectRoot: projectPath, + testDir, + name: script.name, + baseUrl: targetUrl || script.targetUrl, + steps: script.steps as TestSuiteStep[], + screenshotMode, + navigationTimeout: config?.navigationTimeout, + actionTimeout: config?.actionTimeout, + }); + if (resolvedFilePath !== script.filePath) { + scriptStore.save({ ...script, description: script.description ?? undefined, filePath: resolvedFilePath }); + } + } + + // Regenerate playwright.config.ts to reflect current settings + writePlaywrightConfig({ + projectRoot: projectPath, + testDir, + baseUrl: targetUrl || script.targetUrl, + navigationTimeout: config?.navigationTimeout, + actionTimeout: config?.actionTimeout, + browsers: config?.browsers, + workers, + storageStatePath: config?.storageStatePath, + screenshotMode, + }); + + const runId = runner.run({ + scriptId, + projectId: script.projectId, + filePath: resolvedFilePath, + projectPath, + triggeredBy, + screenshotDir, + workers, + retries, + baseUrlOverride, + handlers: sharedHandlers, + }); + + // Track screenshot dir for post-run indexing + console.warn('[test-suite-service] runScript:', { + scriptId, + screenshotMode, + screenshotDir: screenshotDir ?? '(none - manual mode)', + runId, + }); + if (screenshotMode !== 'manual') { + runScreenshotDirs.set(runId, { + screenshotDir: screenshotDir ?? path.join(projectPath, 'test-results'), + scriptId, + projectPath, + }); + } + + return { runId }; + }, + + getRun: (runId) => Promise.resolve(runner.get(runId) as QaRunIpcRecord | null), + + listRuns: ({ scriptId }) => Promise.resolve(runner.list(scriptId) as QaRunIpcRecord[]), + + exportFile({ runId }) { + const run = runner.get(runId); + if (!run) return Promise.reject(new Error(`Run not found: ${runId}`)); + const script = scriptStore.get(run.scriptId); + if (!script) return Promise.reject(new Error(`Script not found for run ${runId}`)); + const projectPath = deps.getProjectPath(script.projectId) ?? process.cwd(); + const config = configStore.getActive(script.projectId); + const testDir = config?.testDirectory ?? 'tests/e2e'; + const filePath = writeSpecFile({ + projectRoot: projectPath, + testDir, + name: script.name, + baseUrl: script.targetUrl, + steps: script.steps as TestSuiteStep[], + screenshotMode: config?.screenshotMode ?? 'smart', + navigationTimeout: config?.navigationTimeout, + actionTimeout: config?.actionTimeout, + }); + return Promise.resolve({ filePath }); + }, + + exportGithub() { + return Promise.reject(new Error('GitHub export not implemented — configure GitHub integration first')); + }, + + attachRunToTask(runId, taskId) { + db.update(testSuiteRuns) + .set({ taskId }) + .where(eq(testSuiteRuns.id, runId)) + .run(); + return Promise.resolve({ success: true }); + }, + + onRunEvent(listener) { + runEventListeners.push(listener); + }, + + saveAuthState(projectId) { + const projectPath = deps.getProjectPath(projectId); + if (!projectPath) return Promise.reject(new Error(`Project path not found: ${projectId}`)); + const config = configStore.getActive(projectId); + const testDir = config?.testDirectory ?? 'tests/e2e'; + const baseUrl = config?.targetUrl ?? 'http://localhost:3000'; + const statePath = path.join(projectPath, testDir, '.auth', 'state.json'); + + mkdirSync(path.dirname(statePath), { recursive: true }); + + const escapedStatePath = statePath.replaceAll('\\', '\\\\'); + const captureScript = ` +const { chromium } = require('@playwright/test'); +(async () => { + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto('${baseUrl}'); + await page.waitForTimeout(2000); + await context.storageState({ path: '${escapedStatePath}' }); + await browser.close(); +})(); +`; + const tmpScript = path.join(projectPath, testDir, '.auth', '_capture.js'); + writeFileSync(tmpScript, captureScript, 'utf8'); + try { + execSync(`node "${tmpScript}"`, { cwd: projectPath, timeout: 30000 }); + } finally { + try { unlinkSync(tmpScript); } catch { /* ignore */ } + } + + if (config) { + configStore.save(projectId, { ...config, storageStatePath: statePath, updatedAt: new Date().toISOString() }); + } + + return Promise.resolve({ storageStatePath: statePath }); + }, + + clearAuthState(projectId) { + const projectPath = deps.getProjectPath(projectId); + if (!projectPath) return Promise.reject(new Error(`Project path not found: ${projectId}`)); + const config = configStore.getActive(projectId); + if (config?.storageStatePath) { + try { unlinkSync(config.storageStatePath); } catch { /* ignore */ } + configStore.save(projectId, { ...config, storageStatePath: undefined, updatedAt: new Date().toISOString() }); + } + return Promise.resolve({ success: true }); + }, + + async batchRun({ scriptIds, triggeredBy, baseUrlOverride }) { + const runIds: string[] = []; + for (const scriptId of scriptIds) { + try { + const result = await service.runScript({ scriptId, triggeredBy, baseUrlOverride }); + runIds.push(result.runId); + } catch { + // Skip scripts that fail to start (missing project path, etc.) + } + } + return { runIds, total: scriptIds.length }; + }, + }; + + // Scheduler: fire runs for due schedules + scheduler.start((schedule) => { + void service + .runScript({ + scriptId: schedule.scriptId, + triggeredBy: 'scheduled', + }) + .catch(() => { + // Swallow — scheduler callback must not throw + }); + }); + + // Notification: surface scheduled run results to the OS + service.onRunEvent((event) => { + if (event.type !== 'complete' || event.status === undefined) return; + const run = runner.get(event.runId); + if (run?.triggeredBy !== 'scheduled') return; + const script = scriptStore.get(run.scriptId); + if (!script) return; + sendTestNotification(script.name, event.status); + }); + + return service; +} diff --git a/src/main/features/test-suite/watcher.ts b/src/main/features/test-suite/watcher.ts new file mode 100644 index 00000000..074cd365 --- /dev/null +++ b/src/main/features/test-suite/watcher.ts @@ -0,0 +1,68 @@ +/** + * Test Suite File Watcher + * + * Watches spec files on disk via `fs.watch`. When a watched file changes, + * invokes a debounced callback (500ms) so the caller can re-run the script. + * Watchers are tracked by scriptId so the renderer can toggle them on/off. + */ + +import fs from 'node:fs'; + +export interface WatchEntry { + scriptId: string; + filePath: string; + watcher: fs.FSWatcher; +} + +export interface FileWatcher { + watch: (scriptId: string, filePath: string, onChanged: () => void) => void; + unwatch: (scriptId: string) => void; + unwatchAll: () => void; + isWatching: (scriptId: string) => boolean; + listWatched: () => string[]; +} + +export function createFileWatcher(): FileWatcher { + const watched = new Map(); + + return { + watch(scriptId, filePath, onChanged) { + if (watched.has(scriptId)) return; + if (!fs.existsSync(filePath)) return; + + let debounceTimer: ReturnType | null = null; + + const watcher = fs.watch(filePath, (eventType) => { + if (eventType !== 'change') return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + onChanged(); + }, 500); + }); + + watched.set(scriptId, { scriptId, filePath, watcher }); + }, + + unwatch(scriptId) { + const entry = watched.get(scriptId); + if (!entry) return; + entry.watcher.close(); + watched.delete(scriptId); + }, + + unwatchAll() { + for (const entry of watched.values()) { + entry.watcher.close(); + } + watched.clear(); + }, + + isWatching(scriptId) { + return watched.has(scriptId); + }, + + listWatched() { + return [...watched.keys()]; + }, + }; +} diff --git a/src/main/features/test-suite/workflow-exporter.ts b/src/main/features/test-suite/workflow-exporter.ts new file mode 100644 index 00000000..212c35f3 --- /dev/null +++ b/src/main/features/test-suite/workflow-exporter.ts @@ -0,0 +1,58 @@ +/** + * Workflow Exporter + * + * Generates a GitHub Actions workflow YAML for the test suite. + * Supports preview (dry-run) and commit (write to disk) modes. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const WORKFLOW_PATH = '.github/workflows/test-suite.yml'; + +export function previewWorkflow( + projectRoot: string, + testDir: string, +): { yaml: string; filePath: string; exists: boolean } { + const yaml = renderYaml(testDir); + const exists = fs.existsSync(path.join(projectRoot, WORKFLOW_PATH)); + return { yaml, filePath: WORKFLOW_PATH, exists }; +} + +export function commitWorkflow( + projectRoot: string, + testDir: string, +): { filePath: string; committed: boolean } { + const fullPath = path.join(projectRoot, WORKFLOW_PATH); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, renderYaml(testDir), 'utf8'); + return { filePath: fullPath, committed: true }; +} + +function renderYaml(testDir: string): string { + return `name: Test Suite +on: + pull_request: + paths: + - '${testDir}/**' + - 'src/**' + +jobs: + playwright: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '22' } + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npx playwright test ${testDir}/scripts/ + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: | + ${testDir}/screenshots/ + playwright-report/ +`; +} diff --git a/src/main/features/tracker/tracker-handlers.ts b/src/main/features/tracker/tracker-handlers.ts deleted file mode 100644 index 76ea7265..00000000 --- a/src/main/features/tracker/tracker-handlers.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Tracker IPC handlers - * - * Thin wrappers that delegate to TrackerService. - * Service is synchronous — handlers wrap returns with Promise.resolve(). - */ - -import { TRACKER } from '@shared/ipc/tracker/channels'; - -import type { TrackerService } from "./tracker-service"; -import type { IpcRouter } from '../../ipc/router'; - -export function registerTrackerHandlers(router: IpcRouter, trackerService: TrackerService): void { - router.handle(TRACKER.LIST.ALL, () => Promise.resolve(trackerService.list())); - - router.handle(TRACKER.GET.PLAN, ({ key }) => Promise.resolve(trackerService.get(key))); - - router.handle(TRACKER.UPDATE.PLAN, ({ key, ...patch }) => - Promise.resolve(trackerService.update(key, patch)), - ); -} diff --git a/src/main/features/tracker/tracker-service.ts b/src/main/features/tracker/tracker-service.ts deleted file mode 100644 index a7bd1153..00000000 --- a/src/main/features/tracker/tracker-service.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Plan Tracker Service - * - * Reads and writes docs/tracker.json for plan lifecycle tracking. - * Synchronous local file service — returns values directly (not Promises). - */ - -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import type { TrackerFile, TrackerPlan } from '@shared/types/tracker'; - -export interface TrackerService { - list: () => TrackerFile; - get: (key: string) => TrackerPlan | null; - update: ( - key: string, - patch: Partial>, - ) => TrackerPlan; -} - -export function createTrackerService(projectRoot: string): TrackerService { - const trackerPath = join(projectRoot, 'docs', 'tracker.json'); - - function readTracker(): TrackerFile { - if (!existsSync(trackerPath)) { - return { version: 2, lastUpdated: new Date().toISOString().slice(0, 10), plans: {} }; - } - const raw = readFileSync(trackerPath, 'utf-8'); - return JSON.parse(raw) as TrackerFile; - } - - function writeTracker(tracker: TrackerFile): void { - tracker.lastUpdated = new Date().toISOString().slice(0, 10); - writeFileSync(trackerPath, `${JSON.stringify(tracker, null, 2)}\n`, 'utf-8'); - } - - return { - list(): TrackerFile { - return readTracker(); - }, - - get(key: string): TrackerPlan | null { - const tracker = readTracker(); - return tracker.plans[key] ?? null; - }, - - update( - key: string, - patch: Partial>, - ): TrackerPlan { - const tracker = readTracker(); - const existing = tracker.plans[key] as TrackerPlan | undefined; - if (existing === undefined) { - throw new Error(`Tracker entry "${key}" not found`); - } - - if (patch.status !== undefined) { - existing.status = patch.status; - existing.statusChangedAt = new Date().toISOString().slice(0, 10); - } - if (patch.branch !== undefined) { - existing.branch = patch.branch; - } - if (patch.pr !== undefined) { - existing.pr = patch.pr; - } - if (patch.tags !== undefined) { - existing.tags = patch.tags; - } - - writeTracker(tracker); - return existing; - }, - }; -} diff --git a/src/main/features/visualization/visualization-handlers.ts b/src/main/features/visualization/visualization-handlers.ts index 0f655819..90bc574a 100644 --- a/src/main/features/visualization/visualization-handlers.ts +++ b/src/main/features/visualization/visualization-handlers.ts @@ -9,7 +9,7 @@ import { VISUALIZATION } from '@shared/ipc/visualization/channels'; import type { VisualizationService } from "."; import type { IpcRouter } from '../../ipc/router'; -import type { ProjectService } from "../project/project-service"; +import type { ProjectService } from "../projects/project-service"; export function registerVisualizationHandlers( router: IpcRouter, diff --git a/src/main/features/workflow/engine/index.ts b/src/main/features/workflow/engine/index.ts index 8ea7e3ad..cd76b346 100644 --- a/src/main/features/workflow/engine/index.ts +++ b/src/main/features/workflow/engine/index.ts @@ -19,6 +19,7 @@ import type { AgentDefinition } from '@shared/ipc/workflow-engine'; import type { WorkflowTemplate } from '@shared/ipc/workflow-templates'; import { workflowRuns } from '../../../db/schema'; +import { workflowRunsSummary } from '../workflow-runs-summary-schema'; import { runFinalizing } from './states/finalize'; import { runGuardian } from './states/guardian'; @@ -299,6 +300,68 @@ export function createWorkflowEngineModule(deps: WorkflowEngineDeps): WorkflowEn /** All engine records, keyed by runId */ const engines = new Map(); + function writeRunSummary( + runtime: EngineRuntimeRecord, + status: 'passed' | 'failed', + errorMessage?: string | null, + ): void { + try { + const id = runtime.runId; + const now = Date.now(); + // TODO(p2p-future): replace projectPath with a stable project UUID once + // the projects table is added to SYNC_TABLES — current behavior treats + // two peers with different absolute paths to the same logical project + // as distinct projects in the workflow_runs_summary view. + const projectId = runtime.config.projectPath; + const startedAtMs = Date.parse(runtime.startedAt); + const startedAt = Number.isNaN(startedAtMs) ? now : startedAtMs; + const summaryText = errorMessage ?? null; + const ranOnPeerId = deps.replicationEngine.getLocalPeerId(); + + db.insert(workflowRunsSummary) + .values({ + id, + projectId, + taskId: null, + workflowId: runtime.config.templateId ?? null, + status, + startedAt, + finishedAt: now, + summary: summaryText, + ranOnPeerId, + }) + .onConflictDoUpdate({ + target: workflowRunsSummary.id, + set: { + status, + finishedAt: now, + summary: summaryText, + }, + }) + .run(); + + deps.replicationEngine.recordLocalWrite({ + tableName: 'workflow_runs_summary', + pk: id, + opType: 'insert', + columns: { + id, + project_id: projectId, + task_id: null, + workflow_id: runtime.config.templateId ?? null, + status, + started_at: startedAt, + finished_at: now, + summary: summaryText, + ran_on_peer_id: ranOnPeerId, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[WorkflowEngine] writeRunSummary failed for ${runtime.runId}: ${message}`); + } + } + function transition( runtime: EngineRuntimeRecord, nextState: WorkflowState, @@ -318,6 +381,14 @@ export function createWorkflowEngineModule(deps: WorkflowEngineDeps): WorkflowEn // Persist to SQLite after every transition for crash recovery saveRecord(db, runtime); + // Dual-write workflow_runs_summary on terminal states so peers learn the + // outcome via replication (Phase 4 — Task 2). + if (nextState === WorkflowState.DONE && previousState !== WorkflowState.DONE) { + writeRunSummary(runtime, 'passed'); + } else if (nextState === WorkflowState.ERROR && previousState !== WorkflowState.ERROR) { + writeRunSummary(runtime, 'failed', errorMessage); + } + deps.onStateChanged({ runId: runtime.runId, featureName: runtime.featureName, diff --git a/src/main/features/workflow/engine/types.ts b/src/main/features/workflow/engine/types.ts index bf8ea539..53a839ad 100644 --- a/src/main/features/workflow/engine/types.ts +++ b/src/main/features/workflow/engine/types.ts @@ -10,6 +10,7 @@ import type { QaVerdict } from '@shared/ipc/workflow-engine/verdict-schemas'; import type { BusSessionManager } from '@main/bus/session-manager'; import type { AdcDatabase } from '@main/db'; +import type { ReplicationEngine } from '@main/features/peers/replication-engine'; import type { GitService } from '../../git/git-service'; import type { WorkflowTemplateService } from '../templates'; @@ -146,6 +147,7 @@ export interface WorkflowEngineDeps { busSessionManager: BusSessionManager; gitService: GitService; templateService: WorkflowTemplateService; + replicationEngine: ReplicationEngine; progressBaseDir: string; onStateChanged: (event: WorkflowStateChangedEvent) => void; onCompleted: (event: WorkflowCompletedEvent) => void; diff --git a/src/main/features/workflow/progress-syncer.ts b/src/main/features/workflow/progress-syncer.ts deleted file mode 100644 index e2e01d28..00000000 --- a/src/main/features/workflow/progress-syncer.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Progress Syncer - * - * Takes progress data from the ProgressWatcher and sends it to the Hub - * via POST /api/tasks/:id/progress using the hub-api-client. - */ - -import { watcherLogger } from '@main/lib/logger'; - -import type { ProgressData } from './progress-watcher'; -import type { HubApiClient } from '../hub/hub-api-client'; - -export interface ProgressSyncer { - syncProgress: (taskId: string, progressData: ProgressData) => Promise; -} - -export function createProgressSyncer(hubClient: HubApiClient): ProgressSyncer { - return { - async syncProgress(taskId, progressData) { - try { - const result = await hubClient.pushProgress(taskId, { - phase: progressData.phase, - phaseIndex: progressData.phaseIndex, - totalPhases: progressData.totalPhases, - currentAgent: progressData.currentAgent, - filesChanged: progressData.filesChanged, - logLines: [], - }); - - if (!result.ok) { - watcherLogger.error( - `[ProgressSyncer] Failed to sync progress for task ${taskId}:`, - result.error, - ); - return false; - } - - watcherLogger.info(`[ProgressSyncer] Synced progress for task ${taskId}: ${progressData.phase}`); - return true; - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - watcherLogger.error(`[ProgressSyncer] Error syncing progress for task ${taskId}:`, message); - return false; - } - }, - }; -} diff --git a/src/main/features/workflow/workflow-handlers.ts b/src/main/features/workflow/workflow-handlers.ts index dc7ecfb2..1d716ec9 100644 --- a/src/main/features/workflow/workflow-handlers.ts +++ b/src/main/features/workflow/workflow-handlers.ts @@ -9,13 +9,11 @@ * No business logic here — all logic delegates to services. */ -import { TASKS_EVENTS } from '@shared/ipc/tasks/channels'; import { WORKFLOW, WORKFLOW_EVENTS } from '@shared/ipc/workflow/channels'; import { WORKFLOW_ENGINE } from '@shared/ipc/workflow-engine/channels'; import { WORKFLOW_TEMPLATES, WORKFLOW_TEMPLATES_EVENTS } from '@shared/ipc/workflow-templates/channels'; import { createJsonlWatcher } from "./jsonl-watcher"; -import { createProgressSyncer } from "./progress-syncer"; import { createProgressWatcher } from "./progress-watcher"; import type { WorkflowEngineService } from './engine'; @@ -24,7 +22,6 @@ import type { ProgressWatcher } from "./progress-watcher"; import type { WorkflowTemplateService } from './templates'; import type { BusSessionManager } from '../../bus/session-manager'; import type { IpcRouter } from '../../ipc/router'; -import type { HubApiClient } from "../hub/hub-api-client"; interface ActiveWatcher { jsonl: JsonlWatcher; @@ -36,7 +33,6 @@ const activeWatchers = new Map(); export function registerWorkflowHandlers( router: IpcRouter, - hubApiClient: HubApiClient, workflowEngineService: WorkflowEngineService, workflowTemplateService: WorkflowTemplateService, busSessionManager: BusSessionManager, @@ -52,23 +48,11 @@ export function registerWorkflowHandlers( activeWatchers.delete(projectPath); } - // ── Legacy markdown watcher for Hub progress sync ── + // ── Legacy markdown watcher (no-op renderer emit since hub-tasks deletion) ── + // Watcher remains for parity with stop() lifecycle below; emit removed in Task 5.8 + // because the TASKS_EVENTS channel was deleted with shared/ipc/hub-tasks. Renderer + // hooks that subscribed (useTaskEvents, useWorkflowEvents) were also removed. const legacyWatcher = createProgressWatcher(projectPath); - const syncer = createProgressSyncer(hubApiClient); - legacyWatcher.onProgress((data) => { - void syncer.syncProgress(data.taskId, data); - }); - legacyWatcher.onProgress((data) => { - router.emit(TASKS_EVENTS.PROGRESS.UPDATED, { - taskId: data.taskId, - progress: { - phase: data.phase as 'idle' | 'planning' | 'coding' | 'testing' | 'reviewing' | 'complete' | 'error', - phaseProgress: data.totalPhases > 0 ? Math.round((data.phaseIndex / data.totalPhases) * 100) : 0, - overallProgress: data.totalPhases > 0 ? Math.round((data.phaseIndex / data.totalPhases) * 100) : 0, - message: `Phase: ${data.phase}`, - }, - }); - }); legacyWatcher.start(); // ── New JSONL watcher for milestone/context/permission events ── diff --git a/src/main/features/workflow/workflow-runs-summary-schema.ts b/src/main/features/workflow/workflow-runs-summary-schema.ts new file mode 100644 index 00000000..e8fa13b5 --- /dev/null +++ b/src/main/features/workflow/workflow-runs-summary-schema.ts @@ -0,0 +1,22 @@ +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const workflowRunsSummary = sqliteTable( + 'workflow_runs_summary', + { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + taskId: text('task_id'), + workflowId: text('workflow_id'), + status: text('status', { enum: ['queued', 'running', 'passed', 'failed', 'cancelled'] }).notNull(), + startedAt: integer('started_at'), + finishedAt: integer('finished_at'), + summary: text('summary'), + ranOnPeerId: text('ran_on_peer_id').notNull(), + }, + (t) => [ + index('workflow_runs_summary_by_project').on(t.projectId), + index('workflow_runs_summary_by_task').on(t.taskId), + ], +); + +export type WorkflowRunSummaryRow = typeof workflowRunsSummary.$inferSelect; diff --git a/src/main/features/workflow/workflow-service.ts b/src/main/features/workflow/workflow-service.ts index c5f5d253..aa6a4025 100644 --- a/src/main/features/workflow/workflow-service.ts +++ b/src/main/features/workflow/workflow-service.ts @@ -29,6 +29,7 @@ export function createWorkflowService(deps: WorkflowServiceDeps): WorkflowServic busSessionManager: deps.busSessionManager, gitService: deps.gitService, templateService: templates, + replicationEngine: deps.replicationEngine, progressBaseDir: deps.progressBaseDir, onStateChanged: deps.onStateChanged, onCompleted: deps.onCompleted, diff --git a/src/main/features/workspace/workspace-handlers.ts b/src/main/features/workspace/workspace-handlers.ts index e309ec26..a64b61e5 100644 --- a/src/main/features/workspace/workspace-handlers.ts +++ b/src/main/features/workspace/workspace-handlers.ts @@ -29,6 +29,10 @@ export function registerWorkspaceHandlers( return await workspace.stopTeamLead(projectId, index); }); + router.handle(WORKSPACE.STOP.PROJECT, async ({ projectId }) => { + return await workspace.stopProject(projectId); + }); + router.handle(WORKSPACE.SEND.MESSAGE, async ({ sessionId, message }) => { return await workspace.sendMessage(sessionId, message); }); @@ -46,11 +50,11 @@ export function registerWorkspaceHandlers( return workspace.executeTask(projectId, taskDescription, planPath); }); - router.handle(WORKSPACE.PROVISION.TEAMMATE, ({ projectId, agentRole, slug, teamName, taskInstructions }) => { - return Promise.resolve(workspace.provisionTeammate(projectId, agentRole, slug, teamName, taskInstructions)); + router.handle(WORKSPACE.PROVISION.TEAMMATE, async ({ projectId, agentRole, slug, teamName, taskInstructions }) => { + return await workspace.provisionTeammate(projectId, agentRole, slug, teamName, taskInstructions); }); - router.handle(WORKSPACE.TEARDOWN.TEAMMATE, ({ projectId, slug }) => { - return Promise.resolve(workspace.teardownTeammate(projectId, slug)); + router.handle(WORKSPACE.TEARDOWN.TEAMMATE, async ({ projectId, slug }) => { + return await workspace.teardownTeammate(projectId, slug); }); } diff --git a/src/main/features/workspace/workspace-session-manager.ts b/src/main/features/workspace/workspace-session-manager.ts index c9266991..cbf5f744 100644 --- a/src/main/features/workspace/workspace-session-manager.ts +++ b/src/main/features/workspace/workspace-session-manager.ts @@ -75,6 +75,8 @@ export interface WorkspaceSessionManager { getSessions: (projectId: string) => WorkspaceSession[]; spawnTeamLead: (projectId: string, planPath?: string) => Promise; stopTeamLead: (projectId: string, index: number) => Promise<{ success: boolean }>; + /** Stop all sessions (primary + all team-leads) for a project and clean up worktrees */ + stopProject: (projectId: string) => Promise<{ success: boolean }>; sendMessage: (sessionId: string, message: string) => Promise<{ success: boolean }>; /** Hand off a plan file to an idle team-lead or spawn a new one */ handOffPlan: ( @@ -95,9 +97,9 @@ export interface WorkspaceSessionManager { slug: string, teamName: string, taskInstructions?: string, - ) => { worktreePath: string; branch: string }; + ) => Promise<{ worktreePath: string; branch: string }>; /** Tear down a teammate's worktree */ - teardownTeammate: (projectId: string, slug: string) => { success: boolean }; + teardownTeammate: (projectId: string, slug: string) => Promise<{ success: boolean }>; dispose: () => void; } @@ -174,7 +176,7 @@ export function createWorkspaceSessionManager( const keyStr = keyToString(key); // Provision isolated worktree - const { worktreePath } = provisioner.provision({ + const { worktreePath } = await provisioner.provision({ projectPath, agentType: 'team-lead', agentRole: 'team-leader', @@ -203,8 +205,12 @@ export function createWorkspaceSessionManager( }); return record.id; } catch (err) { - // Clean up the worktree on spawn failure - provisioner.teardown(projectPath, slug); + // Clean up the worktree on spawn failure (fire-and-forget, don't block error path) + void provisioner.teardown(projectPath, slug).catch((teardownErr: unknown) => { + agentLogger.warn('[WorkspaceSessionManager] Teardown after spawn failure failed', { + error: teardownErr, + }); + }); worktreeSlugs.delete(keyStr); const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to spawn team lead: ${message}`); @@ -213,12 +219,15 @@ export function createWorkspaceSessionManager( /** * Clean up a team-lead's worktree when its session ends. + * Teardown is fire-and-forget — callers don't need to await. */ function cleanupWorktree(key: SessionKey, projectPath: string): void { const keyStr = keyToString(key); const slug = worktreeSlugs.get(keyStr); if (slug) { - provisioner.teardown(projectPath, slug); + void provisioner.teardown(projectPath, slug).catch((err: unknown) => { + agentLogger.warn('[WorkspaceSessionManager] Worktree cleanup failed', { error: err }); + }); worktreeSlugs.delete(keyStr); } } @@ -439,6 +448,46 @@ export function createWorkspaceSessionManager( return Promise.resolve({ success: stopped }); }, + stopProject(projectId) { + const projectSessions = [...sessions.entries()].filter( + ([, s]) => s.key.projectId === projectId, + ); + + for (const [keyStr, session] of projectSessions) { + if (session.agentSessionId) { + agentManager.stopSession(session.agentSessionId); + } + // Clean up worktrees for team-leads (fire-and-forget) + if (session.key.type === SESSION_TYPE_TEAM_LEAD) { + const slug = worktreeSlugs.get(keyStr); + if (slug) { + void provisioner.teardown(session.projectPath, slug).catch((err: unknown) => { + agentLogger.warn('[WorkspaceSessionManager] Worktree teardown failed', { error: err }); + }); + worktreeSlugs.delete(keyStr); + } + } + sessions.delete(keyStr); + } + + // Clean up any teammate worktrees for this project (fire-and-forget) + for (const [lookupKey, projectPath] of teammateSlugs.entries()) { + if (lookupKey.startsWith(`${projectId}:`)) { + const slug = lookupKey.split(':').slice(1).join(':'); + void provisioner.teardown(projectPath, slug).catch((err: unknown) => { + agentLogger.warn('[WorkspaceSessionManager] Teammate teardown failed', { error: err }); + }); + teammateSlugs.delete(lookupKey); + } + } + + agentLogger.info( + `[WorkspaceSessionManager] All sessions stopped for project ${projectId} (${String(projectSessions.length)} sessions)`, + ); + + return Promise.resolve({ success: true }); + }, + sendMessage(sessionId, message) { const success = agentManager.sendMessage(sessionId, message); return Promise.resolve({ success }); @@ -603,10 +652,10 @@ export function createWorkspaceSessionManager( }; }, - provisionTeammate(projectId, agentRole, slug, teamName, taskInstructions) { + async provisionTeammate(projectId, agentRole, slug, teamName, taskInstructions) { const projectPath = getProjectPath(projectId); - const { worktreePath, branch } = provisioner.provision({ + const { worktreePath, branch } = await provisioner.provision({ projectPath, agentType: 'teammate', agentRole, @@ -625,14 +674,14 @@ export function createWorkspaceSessionManager( return { worktreePath, branch }; }, - teardownTeammate(projectId, slug) { + async teardownTeammate(projectId, slug) { const lookupKey = `${projectId}:${slug}`; const projectPath = teammateSlugs.get(lookupKey); if (!projectPath) { return { success: false }; } - provisioner.teardown(projectPath, slug); + await provisioner.teardown(projectPath, slug); teammateSlugs.delete(lookupKey); agentLogger.info(`[WorkspaceSessionManager] Teammate ${slug} worktree torn down`); @@ -640,7 +689,7 @@ export function createWorkspaceSessionManager( }, dispose() { - // Clean up all sessions + // Clean up all sessions (fire-and-forget teardowns; dispose runs on app exit) for (const [keyStr, session] of sessions.entries()) { if (session.agentSessionId) { agentManager.stopSession(session.agentSessionId); @@ -648,15 +697,15 @@ export function createWorkspaceSessionManager( if (session.key.type === SESSION_TYPE_TEAM_LEAD) { const slug = worktreeSlugs.get(keyStr); if (slug) { - provisioner.teardown(session.projectPath, slug); + void provisioner.teardown(session.projectPath, slug).catch(() => { /* ignore on dispose */ }); } } } - // Clean up all teammate worktrees + // Clean up all teammate worktrees (fire-and-forget) for (const [lookupKey, projectPath] of teammateSlugs.entries()) { const slug = lookupKey.split(':').slice(1).join(':'); - provisioner.teardown(projectPath, slug); + void provisioner.teardown(projectPath, slug).catch(() => { /* ignore on dispose */ }); } sessions.clear(); diff --git a/src/main/features/workspaces/workspaces-handlers.ts b/src/main/features/workspace/workspaces-handlers.ts similarity index 92% rename from src/main/features/workspaces/workspaces-handlers.ts rename to src/main/features/workspace/workspaces-handlers.ts index ad902979..b500cfd9 100644 --- a/src/main/features/workspaces/workspaces-handlers.ts +++ b/src/main/features/workspace/workspaces-handlers.ts @@ -2,7 +2,7 @@ * Workspaces IPC handlers */ -import { WORKSPACES } from '@shared/ipc/misc/workspaces.channels'; +import { WORKSPACES } from '@shared/ipc/workspaces'; import type { WorkspacesService } from './workspaces-service'; import type { IpcRouter } from '../../ipc/router'; diff --git a/src/main/features/workspaces/schema.ts b/src/main/features/workspace/workspaces-schema.ts similarity index 100% rename from src/main/features/workspaces/schema.ts rename to src/main/features/workspace/workspaces-schema.ts diff --git a/src/main/features/workspaces/workspaces-service.ts b/src/main/features/workspace/workspaces-service.ts similarity index 100% rename from src/main/features/workspaces/workspaces-service.ts rename to src/main/features/workspace/workspaces-service.ts diff --git a/src/main/global.d.ts b/src/main/global.d.ts new file mode 100644 index 00000000..058fb5fc --- /dev/null +++ b/src/main/global.d.ts @@ -0,0 +1,3 @@ +/** Baked in by electron-vite `define` at build time. Empty string if unset. */ +// eslint-disable-next-line @typescript-eslint/naming-convention +declare const __ADC_CHANNEL__: string; diff --git a/src/main/index.ts b/src/main/index.ts index aa0ed507..9e123c06 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,9 +5,10 @@ * Logic lives in bootstrap modules and services — this file orchestrates startup. */ +import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { app, BrowserWindow, dialog, MessageChannelMain, shell, session, utilityProcess } from 'electron'; +import { app, BrowserWindow, dialog, MessageChannelMain, protocol, shell, session, utilityProcess } from 'electron'; import { ENV_VARS } from '@shared/constants/env'; import { ASSISTANT_EVENTS } from '@shared/ipc/assistant/channels'; @@ -19,23 +20,70 @@ import { wireEventForwarding, wireIpcHandlers, } from './bootstrap'; +import { getChannelConfig, resolveChannel } from './lib/channel'; import { appLogger } from './lib/logger'; +import { inheritShellPath } from './lib/shell-path'; import { createTrayManager } from './tray/tray-manager'; import type { AgentHostClient } from './agent-host/agent-host-client'; import type { ErrorCollector } from './features/app/health'; import type { SettingsService } from './features/settings/settings-service'; -// Dev mode: rename app before any app.getPath('userData') calls so data isolates to %APPDATA%/ADC-Dev/ -const isDevMode = process.env[ENV_VARS.ADC_DEV_MODE] === 'true' || !app.isPackaged; - -if (isDevMode) { - app.setName('ADC-Dev'); +// Resolve channel BEFORE any app.getPath() or app.whenReady() so Electron's +// path resolution picks up the renamed app. Channels isolate userData, +// cache, logs, crashDumps, the single-instance lock, and Claude CLI state. +const envChannelRaw = process.env[ENV_VARS.ADC_CHANNEL] ?? ''; +const bakedChannelRaw = __ADC_CHANNEL__; +function pickChannelSource(): string | undefined { + if (envChannelRaw !== '') return envChannelRaw; + if (bakedChannelRaw !== '') return bakedChannelRaw; + return undefined; } +const CHANNEL = resolveChannel({ + envChannel: pickChannelSource(), + devMode: process.env[ENV_VARS.ADC_DEV_MODE] === 'true', + isPackaged: app.isPackaged, +}); +const CHANNEL_CFG = getChannelConfig(CHANNEL); + +app.setName(CHANNEL_CFG.name); +app.setAppUserModelId(CHANNEL_CFG.aumid); + +// Isolate Claude CLI state per channel. CLAUDE_CONFIG_DIR is honored by the +// Claude CLI for every ~/.claude/* path (sessions, MCP auth, hooks). +// process.env propagates to utilityProcess.fork and child_process.spawn +// automatically — see process-manager.ts buildCleanEnv. +process.env.CLAUDE_CONFIG_DIR ??= join(app.getPath('userData'), '.claude'); + +appLogger.info(`[Main] Starting ADC on channel=${CHANNEL} (name=${CHANNEL_CFG.name}, aumid=${CHANNEL_CFG.aumid})`); + +// On macOS/Linux, a packaged .app launched from Finder has a minimal PATH. +// Pull the user's login-shell PATH + common bin dirs so `which claude` (and +// any other user-installed CLI we spawn) resolves correctly. +inheritShellPath(); // Enable remote debugging for DevTools MCP integration app.commandLine.appendSwitch('remote-debugging-port', '9222'); +// Register custom protocol scheme before app is ready (required by Electron) +protocol.registerSchemesAsPrivileged([ + { scheme: 'local-file', privileges: { bypassCSP: true, stream: true, supportFetchAPI: true } }, +]); + +// Single-instance lock: second invocation exits immediately and focuses the first. +// Prevents duplicate Electron trees accumulating across `npm run dev` re-runs, +// stale Ctrl+C orphans, or concurrent agent-spawned dev servers. +if (!app.requestSingleInstanceLock()) { + app.quit(); + process.exit(0); +} +app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } +}); + let mainWindow: BrowserWindow | null = null; let settingsServiceRef: SettingsService | null = null; let errorCollectorRef: ErrorCollector | null = null; @@ -64,8 +112,9 @@ function createWindow(): void { minHeight: 600, frame: false, show: false, + title: CHANNEL_CFG.label, webPreferences: { - preload: join(__dirname, '../preload/index.mjs'), + preload: join(__dirname, '../preload/index.cjs'), sandbox: false, contextIsolation: true, nodeIntegration: false, @@ -73,6 +122,11 @@ function createWindow(): void { }, }); + // Keep title pinned — renderer HTML setting would overwrite it. + mainWindow.on('page-title-updated', (event) => { + event.preventDefault(); + }); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { void shell.openExternal(url); return { action: 'deny' }; @@ -134,7 +188,7 @@ function createWindow(): void { if (rendererCrashCount >= MAX_CONSECUTIVE_CRASHES) { const choice = dialog.showMessageBoxSync({ type: 'error', - title: 'ADC — Renderer Crashed', + title: `${CHANNEL_CFG.label} — Renderer Crashed`, message: 'The app keeps crashing. Would you like to restart or quit?', buttons: ['Restart', 'Quit'], defaultId: 0, @@ -230,7 +284,7 @@ function initializeApp(): void { agentHostClientRef = agentHostClient; // ── Initialize services ─────────────────────────────────── - const registry = createServiceRegistry(getMainWindow, agentHostClient); + const registry = createServiceRegistry(getMainWindow, agentHostClient, CHANNEL); // Store refs for createWindow() settings checks and global error reporting settingsServiceRef = registry.settingsService; @@ -244,20 +298,18 @@ function initializeApp(): void { wireEventForwarding({ router: registry.router, watchEvaluator: registry.watchEvaluator, - webhookRelay: registry.webhookRelay, - hubConnectionManager: registry.hubConnectionManager, }); // Register app lifecycle handlers (quit, activate, cleanup) setupLifecycle({ createWindow, terminalService: registry.terminalService, + runnersService: registry.services.runnersService, errorCollector: registry.errorCollector, healthRegistry: registry.healthRegistry, healthService: registry.healthService, qaTrigger: registry.qaTrigger, alertService: registry.alertService, - hubConnectionManager: registry.hubConnectionManager, notificationManager: registry.notificationManager, briefingService: registry.briefingService, watchEvaluator: registry.watchEvaluator, @@ -266,7 +318,7 @@ function initializeApp(): void { appUpdateService: registry.services.appUpdateService, commandBus: registry.commandBus, busSessionManager: registry.busSessionManager, - getHeartbeatIntervalId: () => registry.heartbeatIntervalId, + disposePeerTransport: registry.disposePeerTransport, }); } @@ -304,6 +356,25 @@ void (async () => { await app.whenReady(); + // Register local-file:// protocol to serve local screenshots securely. + // The renderer can't load file:// URLs when served from http://localhost (dev mode), + // so we proxy through a custom scheme that Electron allows. + protocol.handle('local-file', async (request) => { + // URL format: local-file:///C:/Users/.../screenshot.png + const filePath = decodeURIComponent(new URL(request.url).pathname); + // On Windows the pathname starts with /C:/... — strip the leading slash + const normalized = process.platform === 'win32' ? filePath.replace(/^\//, '') : filePath; + try { + const data = await readFile(normalized); + const ext = normalized.split('.').pop()?.toLowerCase() ?? ''; + const MIME_TYPES: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg' }; + const mime = MIME_TYPES[ext] ?? 'application/octet-stream'; + return new Response(data, { headers: { 'Content-Type': mime } }); + } catch { + return new Response('Not found', { status: 404 }); + } + }); + // Bypass certificate errors for localhost webview content only session.defaultSession.setCertificateVerifyProc((request, callback) => { const { hostname } = request; diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 9265649d..6878f89c 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -6,58 +6,56 @@ */ import { AGENT_DASHBOARD } from '@shared/ipc/agent-dashboard/channels'; +import { CHANGELOG } from '@shared/ipc/changelog'; import { FITNESS } from '@shared/ipc/fitness/channels'; -import { CHANGELOG } from '@shared/ipc/misc/changelog.channels'; -import { IDEAS } from '@shared/ipc/misc/ideas.channels'; -import { MILESTONES } from '@shared/ipc/misc/milestones.channels'; -import { SCREEN } from '@shared/ipc/misc/screen.channels'; -import { VOICE } from '@shared/ipc/misc/voice.channels'; +import { IDEAS } from '@shared/ipc/ideas'; +import { SCREEN } from '@shared/ipc/screen'; +import { VOICE } from '@shared/ipc/voice'; import { registerAgentDashboardHandlers } from '../features/agent-dashboard/agent-dashboard-handlers'; import { registerAlertHandlers } from '../features/alerts/alert-handlers'; import { registerAppHandlers } from '../features/app/app-handlers'; import { registerAppUpdateHandlers } from '../features/app/app-update-handlers'; -import { registerDockerHandlers } from '../features/app/docker'; import { registerErrorHandlers } from '../features/app/health'; import { registerWindowHandlers } from '../features/app/window-handlers'; import { registerAssistantHandlers } from '../features/assistant/assistant-handlers'; -import { registerAuthHandlers } from '../features/auth/auth-handlers'; import { registerBriefingHandlers } from '../features/briefing/briefing-handlers'; import { registerBusHandlers } from '../features/bus/bus-handlers'; import { registerChangelogHandlers } from '../features/changelog/changelog-handlers'; import { registerClaudeHandlers } from '../features/claude/claude-handlers'; import { registerDashboardHandlers } from '../features/dashboard/dashboard-handlers'; -import { registerFilesHandlers } from '../features/file-tree/files-handlers'; +import { registerDataDirHandlers, registerDataManagementHandlers } from '../features/data-management'; +import { registerDockerHandlers } from '../features/docker'; +import { registerFilesHandlers } from '../features/files/files-handlers'; import { registerFitnessHandlers } from '../features/fitness/fitness-handlers'; import { registerGitHandlers } from '../features/git/git-handlers'; -import { registerDeviceHandlers } from '../features/hub/device'; -import { registerHubHandlers } from '../features/hub/hub-handlers'; +import { registerGitHubHandlers } from '../features/github'; import { registerIdeasHandlers } from '../features/ideas/ideas-handlers'; import { registerInsightsHandlers } from '../features/insights/insights-handlers'; import { registerIntegrationsHandlers } from '../features/integrations/integrations-handlers'; import { registerMcpHandlers } from '../features/mcp/mcp-handlers'; import { registerMergeHandlers } from '../features/merge/merge-handlers'; -import { registerMilestonesHandlers } from '../features/milestones/milestones-handlers'; import { registerNotesHandlers } from '../features/notes/notes-handlers'; import { registerOAuthHandlers } from '../features/oauth/oauth-handlers'; +import { registerPeersHandlers } from '../features/peers/peers-handlers'; import { registerPlannerHandlers } from '../features/planner/planner-handlers'; import { registerProgressHandlers } from '../features/progress/progress-handlers'; -import { registerProjectHandlers } from '../features/project/project-handlers'; +import { registerProjectHandlers } from '../features/projects/project-handlers'; import { registerQaHandlers } from '../features/qa/qa-handlers'; -import { registerQaRecorderHandlers } from '../features/qa/recorder/recorder-handlers'; -import { registerDataDirHandlers, registerDataManagementHandlers } from '../features/settings/data-management'; +import { registerRunnerHandlers } from '../features/runners/runners-handlers'; +import { registerSecurityHandlers } from '../features/security/security-handlers'; import { registerHotkeyHandlers } from '../features/settings/hotkeys'; import { registerScreenHandlers } from '../features/settings/screen'; -import { registerSecurityHandlers } from '../features/settings/security-handlers'; import { registerSettingsHandlers } from '../features/settings/settings-handlers'; import { registerVoiceHandlers } from '../features/settings/voice'; import { registerWebhookSettingsHandlers } from '../features/settings/webhook-settings-handlers'; -import { registerTerminalHandlers } from '../features/terminal/terminal-handlers'; -import { registerTrackerHandlers } from '../features/tracker/tracker-handlers'; +import { registerSpotifyHandlers } from '../features/spotify'; +import { registerTerminalHandlers } from '../features/terminals/terminals-handlers'; +import { registerTestSuiteHandlers } from '../features/test-suite/test-suite-handlers'; import { registerVisualizationHandlers } from '../features/visualization/visualization-handlers'; import { registerWorkflowHandlers } from '../features/workflow/workflow-handlers'; import { registerWorkspaceHandlers } from '../features/workspace/workspace-handlers'; -import { registerWorkspacesHandlers } from '../features/workspaces/workspaces-handlers'; +import { registerWorkspacesHandlers } from '../features/workspace/workspaces-handlers'; import type { IpcRouter } from './router'; import type { AgentManager } from '../agent-host/agent-host-client'; @@ -69,7 +67,6 @@ import type { BusSessionManager } from '../bus/session-manager'; import type { TeamWatcherService } from '../features/agent-dashboard/agent-dashboard-handlers'; import type { AlertService } from '../features/alerts/alert-service'; import type { AppUpdateService } from '../features/app/app-update-service'; -import type { DockerService } from '../features/app/docker'; import type { ErrorCollectorHandler, HealthRegistryHandler } from '../features/app/health'; import type { AssistantService } from '../features/assistant/assistant-service'; import type { UserSessionManager } from '../features/auth'; @@ -77,43 +74,39 @@ import type { BriefingService } from '../features/briefing/briefing-service'; import type { ChangelogService } from '../features/changelog/changelog-service'; import type { ClaudeClient } from '../features/claude'; import type { DashboardService } from '../features/dashboard/dashboard-service'; -import type { FileTreeService } from '../features/file-tree/file-tree-service'; +import type { StorageInspector, DataMigrator , ConfigReader , CleanupService } from '../features/data-management'; +import type { DockerService } from '../features/docker'; +import type { EmailService } from '../features/email/email-service'; +import type { FileTreeService } from '../features/files/files-service'; import type { FitnessService } from '../features/fitness/fitness-service'; import type { GitService } from '../features/git/git-service'; import type { WorktreeService } from '../features/git/worktree-service'; -import type { DeviceService } from '../features/hub/device'; -import type { HubApiClient } from '../features/hub/hub-api-client'; -import type { HubAuthService } from '../features/hub/hub-auth-service'; -import type { HubConnectionManager } from '../features/hub/hub-connection'; -import type { HubSyncService } from '../features/hub/hub-sync'; +import type { GitHubService } from '../features/github'; import type { IdeasService } from '../features/ideas/ideas-service'; import type { InsightsService } from '../features/insights/insights-service'; import type { CalendarService } from '../features/integrations/calendar'; -import type { EmailService } from '../features/integrations/email/email-service'; -import type { GitHubService } from '../features/integrations/github-integration'; -import type { NotificationManager } from '../features/integrations/notifications'; -import type { SpotifyService } from '../features/integrations/spotify'; import type { MergeService } from '../features/merge/merge-service'; -import type { MilestonesService } from '../features/milestones/milestones-service'; import type { NotesService } from '../features/notes/notes-service'; +import type { NotificationManager } from '../features/notifications'; +import type { PeersService } from '../features/peers/peers-service'; import type { PlannerService } from '../features/planner/planner-service'; import type { ProgressService } from '../features/progress/progress-service'; -import type { CodebaseAnalyzerService } from '../features/project/codebase-analyzer'; -import type { ProjectService } from '../features/project/project-service'; -import type { SetupPipelineService } from '../features/project/setup-pipeline'; +import type { CodebaseAnalyzerService } from '../features/projects/codebase-analyzer'; +import type { ProjectService } from '../features/projects/project-service'; +import type { SetupPipelineService } from '../features/projects/setup-pipeline'; import type { QaRunner } from '../features/qa/qa-types'; -import type { QaRecorderService } from '../features/qa/recorder'; -import type { StorageInspector, DataMigrator , ConfigReader , CleanupService } from '../features/settings/data-management'; +import type { RunnersService } from '../features/runners/runners-service'; import type { ScreenCaptureService } from '../features/settings/screen'; import type { SettingsService } from '../features/settings/settings-service'; import type { VoiceService } from '../features/settings/voice'; -import type { TerminalService } from '../features/terminal/terminal-service'; -import type { TrackerService } from '../features/tracker/tracker-service'; +import type { SpotifyService } from '../features/spotify'; +import type { TerminalService } from '../features/terminals/terminals-service'; +import type { TestSuiteService } from '../features/test-suite'; import type { VisualizationService } from '../features/visualization'; import type { WorkflowEngineService } from '../features/workflow/engine'; import type { WorkflowTemplateService } from '../features/workflow/templates'; import type { WorkspaceSessionManager } from '../features/workspace/workspace-session-manager'; -import type { WorkspacesService } from '../features/workspaces/workspaces-service'; +import type { WorkspacesService } from '../features/workspace/workspaces-service'; import type { McpManager } from '../mcp/mcp-manager'; import type { HotkeyManager } from '../tray/hotkey-manager'; @@ -125,7 +118,6 @@ export interface Services { terminalService: TerminalService; settingsService: SettingsService; claudeClient: ClaudeClient; - deviceService: DeviceService; alertService: AlertService; assistantService: AssistantService; calendarService: CalendarService; @@ -134,14 +126,12 @@ export interface Services { errorCollector: ErrorCollectorHandler; fitnessService: FitnessService | null; healthRegistry: HealthRegistryHandler; - hubConnectionManager: HubConnectionManager; - hubSyncService: HubSyncService; ideasService: IdeasService | null; insightsService: InsightsService; mcpManager: McpManager; - milestonesService: MilestonesService | null; notesService: NotesService; notificationManager: NotificationManager; + peersService: PeersService; plannerService: PlannerService; spotifyService: SpotifyService; gitService: GitService; @@ -153,10 +143,8 @@ export interface Services { briefingService: BriefingService; hotkeyManager: HotkeyManager; appUpdateService: AppUpdateService; - hubApiClient: HubApiClient; - hubAuthService: HubAuthService; qaRunner: QaRunner; - qaRecorderService: QaRecorderService; + testSuiteService: TestSuiteService; workflowTemplateService: WorkflowTemplateService; dashboardService: DashboardService; dockerService: DockerService; @@ -167,12 +155,12 @@ export interface Services { storageInspector: StorageInspector; codebaseAnalyzer: CodebaseAnalyzerService; setupPipeline: SetupPipelineService; - trackerService: TrackerService; visualizationService: VisualizationService; userSessionManager: UserSessionManager; workspaceSessionManager: WorkspaceSessionManager; workspacesService: WorkspacesService; progressService: ProgressService; + runnersService: RunnersService; teamWatcherService: TeamWatcherService | null; fileTreeService: FileTreeService; workflowEngineService: WorkflowEngineService; @@ -207,10 +195,6 @@ export function registerAllHandlers(router: IpcRouter, services: Services): void providers: services.providers, }); registerAlertHandlers(router, services.alertService); - registerAuthHandlers(router, { - hubAuthService: services.hubAuthService, - userSessionManager: services.userSessionManager, - }); registerAppHandlers(router, { tokenStore: services.tokenStore, providers: services.providers, @@ -219,10 +203,10 @@ export function registerAllHandlers(router: IpcRouter, services: Services): void registerIntegrationsHandlers(router, { email: services.emailService, notifications: services.notificationManager, - spotify: services.spotifyService, - github: services.githubService, calendar: services.calendarService, }); + registerGitHubHandlers(router, services.githubService); + registerSpotifyHandlers(router, services.spotifyService); registerClaudeHandlers(router, services.claudeClient); if (services.changelogService) { registerChangelogHandlers(router, services.changelogService); @@ -257,25 +241,10 @@ export function registerAllHandlers(router: IpcRouter, services: Services): void router.handle(IDEAS.VOTE.IDEA, unavailable('Ideas')); } registerInsightsHandlers(router, services.insightsService); - if (services.milestonesService) { - registerMilestonesHandlers(router, services.milestonesService); - } else { - router.handle(MILESTONES.LIST.ALL, emptyList); - router.handle(MILESTONES.CREATE.MILESTONE, unavailable('Milestones')); - router.handle(MILESTONES.UPDATE.MILESTONE, unavailable('Milestones')); - router.handle(MILESTONES.DELETE.MILESTONE, unavailable('Milestones')); - router.handle(MILESTONES.ADD.TASK, unavailable('Milestones')); - router.handle(MILESTONES.TOGGLE.TASK, unavailable('Milestones')); - } registerNotesHandlers(router, services.notesService); + registerPeersHandlers(router, services.peersService); registerPlannerHandlers(router, services.plannerService); registerGitHandlers(router, services.gitService, services.worktreeService); - registerHubHandlers( - router, - services.hubConnectionManager, - services.hubSyncService, - services.hubApiClient, - ); registerMcpHandlers(router, services.mcpManager); registerMergeHandlers(router, services.mergeService); registerOAuthHandlers(router, services.oauthManager); @@ -299,20 +268,19 @@ export function registerAllHandlers(router: IpcRouter, services: Services): void registerAppUpdateHandlers(router, services.appUpdateService); registerWorkflowHandlers( router, - services.hubApiClient, services.workflowEngineService, services.workflowTemplateService, services.busSessionManager, ); registerWorkspaceHandlers(router, services.workspaceSessionManager); - registerDeviceHandlers(router, services.deviceService); registerQaHandlers( router, services.qaRunner, services.busSessionManager, services.progressService, ); - registerQaRecorderHandlers(router, services.qaRecorderService); + registerTestSuiteHandlers(router, services.testSuiteService, services.projectService); + registerRunnerHandlers(router, services.runnersService); registerDashboardHandlers(router, services.dashboardService); registerDockerHandlers(router, services.dockerService); registerSecurityHandlers(router, services.settingsService); @@ -325,7 +293,6 @@ export function registerAllHandlers(router: IpcRouter, services: Services): void ); registerDataDirHandlers(router, services.configReader, services.dataMigrator); registerWindowHandlers(router); - registerTrackerHandlers(router, services.trackerService); registerVisualizationHandlers(router, services.visualizationService, services.projectService); registerProgressHandlers(router, services.progressService); registerBusHandlers(router, services.commandBus, services.busSessionManager); diff --git a/src/main/ipc/router.ts b/src/main/ipc/router.ts index c6421d61..d7307e53 100644 --- a/src/main/ipc/router.ts +++ b/src/main/ipc/router.ts @@ -62,7 +62,7 @@ export class IpcRouter { const schema = ipcInvokeContract[channel]; // Store handler for bus registration - const wrappedHandler = (input: unknown) => handler(input as InvokeInput<T>) as Promise<unknown>; + const wrappedHandler = (input: unknown) => Promise.resolve(handler(input as InvokeInput<T>)) as Promise<unknown>; this.registeredHandlers.set(channel, wrappedHandler); // If bus already attached, register immediately diff --git a/src/main/lib/channel.ts b/src/main/lib/channel.ts new file mode 100644 index 00000000..7944f803 --- /dev/null +++ b/src/main/lib/channel.ts @@ -0,0 +1,29 @@ +import { CHANNEL_CONFIG, isAppChannel } from '@shared/types/channel'; +import type { AppChannel, ChannelConfig } from '@shared/types/channel'; + +export interface ResolveChannelInput { + /** Value of process.env.ADC_CHANNEL (if any) */ + envChannel?: string; + /** True when ADC_DEV_MODE=true */ + devMode?: boolean; + /** app.isPackaged */ + isPackaged: boolean; +} + +/** + * Pure resolver — no Electron imports — so it is unit-testable. + * Precedence: explicit env channel > devMode flag > !isPackaged > release. + */ +export function resolveChannel(input: ResolveChannelInput): AppChannel { + if (input.envChannel && isAppChannel(input.envChannel)) { + return input.envChannel; + } + if (input.devMode || !input.isPackaged) { + return 'dev'; + } + return 'release'; +} + +export function getChannelConfig(channel: AppChannel): ChannelConfig { + return CHANNEL_CONFIG[channel]; +} diff --git a/src/main/lib/hub-discovery-flag.ts b/src/main/lib/hub-discovery-flag.ts new file mode 100644 index 00000000..9e6a1fd9 --- /dev/null +++ b/src/main/lib/hub-discovery-flag.ts @@ -0,0 +1,19 @@ +import { ENV_VARS } from '@shared/constants/env'; + +/** + * Returns whether the mDNS hub-discovery + network-watcher subsystem + * should start at boot. + * + * Default: true. The emergency rollback is to set + * `ENABLE_HUB_DISCOVERY=false` (literal string) — any other value keeps + * discovery enabled so the happy path survives typos and unrelated + * env noise. + * + * When disabled, the legacy `HUB.CONNECT.SERVER` URL+key flow still + * works for users who want the old manual setup. + */ +export function shouldEnableDiscovery( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return env[ENV_VARS.ENABLE_HUB_DISCOVERY] !== 'false'; +} diff --git a/src/main/lib/shell-path.ts b/src/main/lib/shell-path.ts new file mode 100644 index 00000000..3e7d638e --- /dev/null +++ b/src/main/lib/shell-path.ts @@ -0,0 +1,83 @@ +/** + * Inherit the login-shell PATH on macOS/Linux. + * + * A packaged .app launched from Finder/Launchpad (or via `open`) inherits only + * launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). That misses Homebrew + * (/opt/homebrew/bin, /usr/local/bin), nvm shims, and npm global bins — which + * means `which claude` fails and every agent spawn ENOENTs. + * + * We shell out to the user's login shell once at startup to capture the PATH + * the user actually uses in a terminal, and merge it into process.env.PATH. + * + * Windows inherits PATH from the registry, so this is a no-op there. + */ + +import { execFileSync } from 'node:child_process'; + +import { appLogger } from '@main/lib/logger'; + +/** Location probes added unconditionally — cheap insurance when the shell trick fails. */ +const COMMON_MAC_BIN_DIRS = [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/opt/local/bin', +]; + +const COMMON_HOME_BIN_DIRS = [ + '.npm-global/bin', + '.volta/bin', + '.cargo/bin', + '.local/bin', + 'bin', +]; + +function readLoginShellPath(shell: string): string | null { + try { + const out = execFileSync( + shell, + ['-ilc', 'command printf "%s" "$PATH"'], + { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }, + ); + const trimmed = out.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +} + +/** + * Idempotently merge the user's login-shell PATH and common bin directories + * into process.env.PATH. Safe to call multiple times. + */ +export function inheritShellPath(): void { + if (process.platform === 'win32') return; + + const parts = new Set<string>( + (process.env.PATH ?? '').split(':').filter((p) => p.length > 0), + ); + + const shell = process.env.SHELL && process.env.SHELL.length > 0 ? process.env.SHELL : '/bin/sh'; + const shellPath = readLoginShellPath(shell); + if (shellPath) { + for (const dir of shellPath.split(':')) { + if (dir.length > 0) parts.add(dir); + } + } + + if (process.platform === 'darwin') { + for (const dir of COMMON_MAC_BIN_DIRS) parts.add(dir); + } + + const home = process.env.HOME; + if (home && home.length > 0) { + for (const suffix of COMMON_HOME_BIN_DIRS) { + parts.add(`${home}/${suffix}`); + } + } + + const merged = [...parts].join(':'); + if (merged !== process.env.PATH) { + process.env.PATH = merged; + appLogger.info(`[ShellPath] Inherited PATH (${String(parts.size)} entries) from ${shell}`); + } +} diff --git a/src/main/services/agent-manager/agent-manager-service.ts b/src/main/services/agent-manager/agent-manager-service.ts index 2334f910..3a229cc9 100644 --- a/src/main/services/agent-manager/agent-manager-service.ts +++ b/src/main/services/agent-manager/agent-manager-service.ts @@ -41,6 +41,7 @@ export interface ProjectOwnerConfig { prompt: string; model?: string; name?: string; + projectId?: string; } /** Config for spawning a Team Lead session */ @@ -50,6 +51,7 @@ export interface TeamLeadConfig { prompt: string; model?: string; name?: string; + projectId?: string; } // ── Event Types ────────────────────────────────────────────── @@ -228,7 +230,7 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS function createSessionObject( type: AgentSessionType, - config: { name?: string; model?: string; teamName?: string; projectPath?: string }, + config: { name?: string; model?: string; teamName?: string; projectPath?: string; projectId?: string }, ): AgentSession { const now = new Date().toISOString(); return { @@ -238,6 +240,7 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS status: 'running', model: config.model ?? 'claude-sonnet-4-6', teamName: config.teamName, + projectId: config.projectId, branch: undefined, sessionJsonlPath: undefined, tokenUsage: { input: 0, output: 0 }, @@ -320,6 +323,7 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS const session = createSessionObject('project-owner', { name: config.name, model: config.model, + projectId: config.projectId, }); const managedProcess = processManager.spawn({ @@ -362,6 +366,7 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS name: config.name, model: config.model, teamName: config.teamName, + projectId: config.projectId, }); // Team-lead is spawned as a TOP-LEVEL session (no agentFlags). diff --git a/src/main/services/worktree-provisioner/worktree-provisioner.ts b/src/main/services/worktree-provisioner/worktree-provisioner.ts index f90ce441..ab74e66b 100644 --- a/src/main/services/worktree-provisioner/worktree-provisioner.ts +++ b/src/main/services/worktree-provisioner/worktree-provisioner.ts @@ -8,23 +8,21 @@ * 4. Write enforcement hooks into .claude/settings.local.json * 5. Clean up worktree on session end * - * Each provisioned worktree gets its own .claude/ directory so hooks - * and settings are scoped to that agent process — no bleed-through. + * All IO is asynchronous so the Electron main process message pump stays + * responsive while git worktrees and setup scripts run. `execSync` here + * would block window events and surface as "(Not Responding)" on Windows. */ -import { execSync } from 'node:child_process'; -import { - cpSync, - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; +import { exec } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; import { agentLogger } from '@main/lib/logger'; +const execAsync = promisify(exec); + // ─── Types ────────────────────────────────────────────────── export type AgentType = 'team-lead' | 'teammate'; @@ -57,9 +55,9 @@ export interface ProvisionResult { export interface WorktreeProvisioner { /** Create and provision a worktree for an agent */ - provision: (config: ProvisionConfig) => ProvisionResult; + provision: (config: ProvisionConfig) => Promise<ProvisionResult>; /** Remove a provisioned worktree and its branch */ - teardown: (projectPath: string, slug: string) => void; + teardown: (projectPath: string, slug: string) => Promise<void>; /** Check if a worktree exists for the given slug */ exists: (projectPath: string, slug: string) => boolean; } @@ -104,14 +102,13 @@ function stripFrontmatter(content: string): string { * Read the project's CLAUDE.md, stripping any auto-generated guidelines section * to keep the agent's CLAUDE.md focused on rules. */ -function readProjectRules(projectPath: string): string { +async function readProjectRules(projectPath: string): Promise<string> { const claudeMdPath = join(projectPath, 'CLAUDE.md'); if (!existsSync(claudeMdPath)) { return ''; } try { - const content = readFileSync(claudeMdPath, 'utf-8'); - // Keep the rules section (# ADC — Project Rules), drop auto-generated guidelines + const content = await readFile(claudeMdPath, 'utf-8'); const guidelinesMarker = '# ADC — Guidelines'; const markerIndex = content.indexOf(guidelinesMarker); if (markerIndex > 0) { @@ -126,14 +123,14 @@ function readProjectRules(projectPath: string): string { /** * Read the agent definition file and strip its YAML frontmatter. */ -function readAgentDefinition(projectPath: string, agentRole: string): string { +async function readAgentDefinition(projectPath: string, agentRole: string): Promise<string> { const agentFile = join(projectPath, '.claude', 'agents', `${agentRole}.md`); if (!existsSync(agentFile)) { agentLogger.warn(`[WorktreeProvisioner] Agent file not found: ${agentFile}`); return ''; } try { - const content = readFileSync(agentFile, 'utf-8'); + const content = await readFile(agentFile, 'utf-8'); return stripFrontmatter(content); } catch { return ''; @@ -254,19 +251,23 @@ function generateClaudeMd(config: ProvisionConfig, projectRules: string, agentBo * Fallback: manually copy .claude/ directories and files when the shared * setup script is unavailable or fails. */ -function fallbackCopyClaudeContext(sourceClaudeDir: string, targetClaudeDir: string): void { +async function fallbackCopyClaudeContext(sourceClaudeDir: string, targetClaudeDir: string): Promise<void> { + // dereference: true resolves symlinks to their real content. Needed on Windows + // where creating symlinks requires admin privileges — otherwise cp fails with + // EPERM for any .claude/ entry that is itself a symlink (Claude Code 2.0 + // consolidates skills/agents via symlinks into ~/.agents and ~/.skills). for (const dir of CLAUDE_DIRS_TO_COPY) { const source = join(sourceClaudeDir, dir); const target = join(targetClaudeDir, dir); if (existsSync(source)) { - cpSync(source, target, { recursive: true }); + await cp(source, target, { recursive: true, dereference: true }); } } for (const file of CLAUDE_FILES_TO_COPY) { const source = join(sourceClaudeDir, file); const target = join(targetClaudeDir, file); if (existsSync(source)) { - cpSync(source, target); + await cp(source, target, { dereference: true }); } } } @@ -275,7 +276,7 @@ function fallbackCopyClaudeContext(sourceClaudeDir: string, targetClaudeDir: str export function createWorktreeProvisioner(): WorktreeProvisioner { return { - provision(config) { + async provision(config) { const { projectPath, agentType, agentRole, slug } = config; const worktreeBase = join(projectPath, WORKTREE_DIR); const worktreePath = resolve(join(worktreeBase, slug)); @@ -284,38 +285,35 @@ export function createWorktreeProvisioner(): WorktreeProvisioner { agentLogger.info(`[WorktreeProvisioner] Provisioning worktree: ${slug} at ${worktreePath}`); // ── 1. Create git worktree ────────────────────────────── - mkdirSync(worktreeBase, { recursive: true }); + await mkdir(worktreeBase, { recursive: true }); // Clean up stale worktree if it exists if (existsSync(worktreePath)) { agentLogger.info(`[WorktreeProvisioner] Removing stale worktree: ${worktreePath}`); try { - execSync(`git worktree remove --force "${worktreePath}"`, { + await execAsync(`git worktree remove --force "${worktreePath}"`, { cwd: projectPath, timeout: 15_000, - stdio: 'pipe', }); } catch { // Worktree may not be registered in git — force-remove the directory - rmSync(worktreePath, { recursive: true, force: true }); + await rm(worktreePath, { recursive: true, force: true }); } } // Delete branch if it exists from a previous run try { - execSync(`git branch -D "${branch}"`, { + await execAsync(`git branch -D "${branch}"`, { cwd: projectPath, timeout: 10_000, - stdio: 'pipe', }); } catch { // Branch doesn't exist — that's fine } - execSync(`git worktree add "${worktreePath}" -b "${branch}"`, { + await execAsync(`git worktree add "${worktreePath}" -b "${branch}"`, { cwd: projectPath, timeout: 30_000, - stdio: 'pipe', }); agentLogger.info(`[WorktreeProvisioner] Git worktree created on branch: ${branch}`); @@ -324,29 +322,29 @@ export function createWorktreeProvisioner(): WorktreeProvisioner { // Copies gitignored config (.claude/settings.json, .env) and installs deps const sourceClaudeDir = join(projectPath, '.claude'); const targetClaudeDir = join(worktreePath, '.claude'); - mkdirSync(targetClaudeDir, { recursive: true }); + await mkdir(targetClaudeDir, { recursive: true }); try { - execSync( + await execAsync( `bash scripts/worktree-setup.sh "${worktreePath}" "${projectPath}"`, - { cwd: projectPath, timeout: 120_000, stdio: 'pipe' }, + { cwd: projectPath, timeout: 120_000 }, ); agentLogger.info('[WorktreeProvisioner] Shared setup script completed'); } catch (setupError) { agentLogger.warn('[WorktreeProvisioner] Setup script failed, falling back to manual copy', { error: setupError, }); - fallbackCopyClaudeContext(sourceClaudeDir, targetClaudeDir); + await fallbackCopyClaudeContext(sourceClaudeDir, targetClaudeDir); } agentLogger.info('[WorktreeProvisioner] .claude/ context copied'); // ── 3. Generate agent-specific CLAUDE.md ──────────────── - const projectRules = readProjectRules(projectPath); - const agentBody = readAgentDefinition(projectPath, agentRole); + const projectRules = await readProjectRules(projectPath); + const agentBody = await readAgentDefinition(projectPath, agentRole); const claudeMdContent = generateClaudeMd(config, projectRules, agentBody); const claudeMdPath = join(worktreePath, 'CLAUDE.md'); - writeFileSync(claudeMdPath, claudeMdContent, 'utf-8'); + await writeFile(claudeMdPath, claudeMdContent, 'utf-8'); agentLogger.info(`[WorktreeProvisioner] CLAUDE.md generated for ${agentRole}`); @@ -355,18 +353,17 @@ export function createWorktreeProvisioner(): WorktreeProvisioner { const hooks = generateTeamLeadHooks(); const settingsLocalPath = join(targetClaudeDir, 'settings.local.json'); - // Merge with any existing settings.local.json let existing: Record<string, unknown> = {}; if (existsSync(settingsLocalPath)) { try { - existing = JSON.parse(readFileSync(settingsLocalPath, 'utf-8')) as Record<string, unknown>; + existing = JSON.parse(await readFile(settingsLocalPath, 'utf-8')) as Record<string, unknown>; } catch { // Malformed — start fresh } } const merged = { ...existing, ...hooks }; - writeFileSync(settingsLocalPath, JSON.stringify(merged, null, 2), 'utf-8'); + await writeFile(settingsLocalPath, JSON.stringify(merged, null, 2), 'utf-8'); agentLogger.info('[WorktreeProvisioner] Team-lead enforcement hooks written'); } @@ -374,43 +371,36 @@ export function createWorktreeProvisioner(): WorktreeProvisioner { return { worktreePath, branch, claudeMdPath }; }, - teardown(projectPath, slug) { + async teardown(projectPath, slug) { const worktreePath = resolve(join(projectPath, WORKTREE_DIR, slug)); const branch = `worktree/${slug}`; agentLogger.info(`[WorktreeProvisioner] Tearing down worktree: ${slug}`); - // Remove the worktree try { - execSync(`git worktree remove --force "${worktreePath}"`, { + await execAsync(`git worktree remove --force "${worktreePath}"`, { cwd: projectPath, timeout: 15_000, - stdio: 'pipe', }); } catch { - // Force-remove if git fails if (existsSync(worktreePath)) { - rmSync(worktreePath, { recursive: true, force: true }); + await rm(worktreePath, { recursive: true, force: true }); } } - // Prune stale worktree entries try { - execSync('git worktree prune', { + await execAsync('git worktree prune', { cwd: projectPath, timeout: 10_000, - stdio: 'pipe', }); } catch { // Non-critical } - // Delete the branch try { - execSync(`git branch -D "${branch}"`, { + await execAsync(`git branch -D "${branch}"`, { cwd: projectPath, timeout: 10_000, - stdio: 'pipe', }); } catch { // Branch may already be gone diff --git a/src/main/tray/tray-manager.ts b/src/main/tray/tray-manager.ts index 5b6d14ff..3c65b00e 100644 --- a/src/main/tray/tray-manager.ts +++ b/src/main/tray/tray-manager.ts @@ -82,13 +82,26 @@ function createStatusIcon(color: { r: number; g: number; b: number }): NativeIma }); } +function resolveIconPath(): string { + // In packaged builds the icons live beside app.asar under resources/ (via + // electron-builder extraResources). In dev they live at the repo root. + const fileName = 'icon-16.png'; + return app.isPackaged + ? join(process.resourcesPath, fileName) + : join(__dirname, '../../resources', fileName); +} + function loadResourceIcon(): NativeImage { - const resourcePath = join(__dirname, '../../resources/icon-16.png'); - const image = nativeImage.createFromPath(resourcePath); + const image = nativeImage.createFromPath(resolveIconPath()); if (image.isEmpty()) { - // Fallback: create a default icon programmatically - return createStatusIcon({ r: 45, g: 212, b: 191 }); // #2DD4BF — Command teal + // Fallback: programmatic circle. Template on darwin so the menu bar + // renders it as a monochrome silhouette instead of a raw colored blob. + const fallback = createStatusIcon({ r: 45, g: 212, b: 191 }); // #2DD4BF — Command teal + if (process.platform === 'darwin') { + fallback.setTemplateImage(true); + } + return fallback; } if (process.platform === 'darwin') { diff --git a/src/preload/index.ts b/src/preload/index.ts index c8524ba1..d6a721c2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,9 @@ * electron's IPC to a clean interface. */ +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + import { contextBridge, ipcRenderer } from 'electron'; import { ENV_VARS, APP_INFO_BRIDGE } from '@shared/constants/env'; @@ -20,6 +23,8 @@ import type { EventPayload, } from '@shared/ipc-contract'; import { ipcInvokeContract, ipcEventContract } from '@shared/ipc-contract'; +import { isAppChannel } from '@shared/types/channel'; +import type { AppChannel } from '@shared/types/channel'; // ─── Channel Allowlists (defense-in-depth) ────────────────── const ALLOWED_INVOKE = new Set(Object.keys(ipcInvokeContract)); @@ -107,15 +112,32 @@ const agentHostBridge = { contextBridge.exposeInMainWorld('agentHost', agentHostBridge); +function resolveAppChannel(): AppChannel { + const envValue = process.env[ENV_VARS.ADC_CHANNEL] ?? ''; + if (isAppChannel(envValue)) return envValue; + if (isAppChannel(__ADC_CHANNEL__)) return __ADC_CHANNEL__; + return process.env[ENV_VARS.ADC_DEV_MODE] === 'true' ? 'dev' : 'release'; +} +const channel: AppChannel = resolveAppChannel(); + const appInfo = { devMode: process.env[ENV_VARS.ADC_DEV_MODE] === 'true', version: process.env.npm_package_version ?? 'unknown', devEmail: process.env[ENV_VARS.ADC_DEV_EMAIL] ?? '', devPassword: process.env[ENV_VARS.ADC_DEV_PASSWORD] ?? '', + channel, } as const; contextBridge.exposeInMainWorld(APP_INFO_BRIDGE, appInfo); +// ─── Sibling preload paths ─────────────────────────────── +// Renderer needs file:// URLs to pass as <webview preload="..."> +const preloads = { + testSuiteRecorder: pathToFileURL(join(__dirname, 'test-suite-recorder.cjs')).href, +}; + +contextBridge.exposeInMainWorld('preloads', preloads); + // Type declaration for the renderer process declare global { interface Window { @@ -128,6 +150,10 @@ declare global { version: string; devEmail: string; devPassword: string; + channel: AppChannel; + }; + preloads: { + testSuiteRecorder: string; }; } } diff --git a/src/preload/selector-builder.ts b/src/preload/selector-builder.ts new file mode 100644 index 00000000..9f9fbf3b --- /dev/null +++ b/src/preload/selector-builder.ts @@ -0,0 +1,102 @@ +/** + * Selector Builder + * + * Pure, browser-safe helpers for picking the best CSS selector to represent + * an element during recording. Priority order: + * 1. data-testid + * 2. id + * 3. name attribute + * 4. aria-label + * 5. role (scoped) + * 6. structural CSS path + * + * Returns both the selector string and the `strategy` used so callers can + * log / rank fallbacks. The main process normalizes events to the contract + * shape and drops the strategy field before emitting. + */ + +export type SelectorStrategy = + | 'testid' + | 'id' + | 'name' + | 'aria-label' + | 'role' + | 'css-path'; + +export interface BuiltSelector { + selector: string; + strategy: SelectorStrategy; +} + +function cssEscape(value: string): string { + const cssObj = globalThis as unknown as { CSS?: { escape?: (s: string) => string } }; + const escapeFn = cssObj.CSS?.escape; + if (typeof escapeFn === 'function') { + return escapeFn(value); + } + // Minimal fallback — escape characters that break attribute selectors. + return value.replaceAll(/(["\\])/g, '\\$1'); +} + +function structuralPath(el: Element): string { + const parts: string[] = []; + let node: Element | null = el as Element | null; + while (node?.nodeType === 1 && node !== document.documentElement) { + const tag = node.tagName.toLowerCase(); + const parent: Element | null = node.parentElement; + if (!parent) { + parts.unshift(tag); + break; + } + const currentTag = node.tagName; + const siblings = Array.from(parent.children).filter( + (c) => c.tagName === currentTag, + ); + if (siblings.length === 1) { + parts.unshift(tag); + } else { + const index = siblings.indexOf(node) + 1; + parts.unshift(`${tag}:nth-of-type(${index})`); + } + node = parent; + } + return parts.join(' > '); +} + +export function buildSelector(el: Element): BuiltSelector { + const testid = el.getAttribute('data-testid'); + if (testid) { + return { selector: `[data-testid="${cssEscape(testid)}"]`, strategy: 'testid' }; + } + + const id = el.getAttribute('id'); + if (id) { + return { selector: `#${cssEscape(id)}`, strategy: 'id' }; + } + + const name = el.getAttribute('name'); + if (name) { + return { + selector: `${el.tagName.toLowerCase()}[name="${cssEscape(name)}"]`, + strategy: 'name', + }; + } + + const aria = el.getAttribute('aria-label'); + if (aria) { + return { + selector: `${el.tagName.toLowerCase()}[aria-label="${cssEscape(aria)}"]`, + strategy: 'aria-label', + }; + } + + const role = el.getAttribute('role'); + if (role) { + return { + selector: `${el.tagName.toLowerCase()}[role="${cssEscape(role)}"]`, + strategy: 'role', + }; + } + + return { selector: structuralPath(el), strategy: 'css-path' }; +} diff --git a/src/preload/test-suite-recorder.ts b/src/preload/test-suite-recorder.ts new file mode 100644 index 00000000..e0b85a6c --- /dev/null +++ b/src/preload/test-suite-recorder.ts @@ -0,0 +1,145 @@ +/** + * Test Suite Recorder Preload + * + * Loaded into the BrowserView that hosts the user's running app during + * recording. Captures user interactions (click, fill, select, press, + * navigate) and forwards them to the host webContents via `sendToHost`. + * + * Emitted steps match the `TestSuiteStep` contract shape exactly — the + * main-process bridge only adds `stepIndex` and `timestamp` before + * forwarding to the renderer. + */ + +import { contextBridge, ipcRenderer } from 'electron'; + +import { buildSelector } from './selector-builder'; + +// Duplicated here because preload scripts run in an isolated context and +// cannot import from @shared. +interface StepContext { + text?: string; + label?: string; + placeholder?: string; + tagName: string; + inputType?: string; +} + +type PreloadStep = + | { type: 'navigate'; url: string } + | { type: 'click'; selector: string; context?: StepContext } + | { type: 'fill'; selector: string; value: string; context?: StepContext } + | { type: 'select'; selector: string; value: string; context?: StepContext } + | { type: 'press'; key: string }; + +/** Tags that represent interactive elements — click targets should resolve to these. */ +const INTERACTIVE_TAGS = new Set(['button', 'a', 'input', 'select', 'textarea']); + +/** + * If `el` is a non-interactive child (svg, path, span, img inside a button/link), + * climb up to the nearest interactive ancestor so we capture meaningful context. + */ +function resolveClickTarget(el: Element): Element { + const tag = el.tagName.toLowerCase(); + if (INTERACTIVE_TAGS.has(tag)) return el; + + let cursor: Element | null = el.parentElement; + while (cursor) { + if (INTERACTIVE_TAGS.has(cursor.tagName.toLowerCase())) return cursor; + // Also check for role="button" or role="link" + const role = cursor.getAttribute('role'); + if (role === 'button' || role === 'link') return cursor; + cursor = cursor.parentElement; + } + return el; // no interactive ancestor found — use original +} + +function extractContext(el: Element): StepContext { + const tagName = el.tagName.toLowerCase(); + const rawText = el.textContent.trim().slice(0, 80); + const text = rawText || undefined; + const labelEl = el.id ? document.querySelector(`label[for="${el.id}"]`) : null; + const label = + el.getAttribute('aria-label') ?? + (labelEl ? labelEl.textContent.trim() : undefined); + const placeholder = (el as HTMLInputElement).placeholder || undefined; + const inputType = + tagName === 'input' ? (el as HTMLInputElement).type || 'text' : undefined; + return { tagName, text, label, placeholder, inputType }; +} + +function send(step: PreloadStep): void { + ipcRenderer.sendToHost('adc.test-suite.step', step); +} + +// ── Navigation ──────────────────────────────────────────────── + +let lastUrl = location.href; +const emitNav = (): void => { + if (location.href !== lastUrl) { + lastUrl = location.href; + send({ type: 'navigate', url: location.href }); + } +}; + +window.addEventListener('popstate', emitNav); +window.addEventListener('load', emitNav); + +const origPush = history.pushState.bind(history); +history.pushState = function (...args: Parameters<History['pushState']>) { + origPush(...args); + emitNav(); +}; + +const origReplace = history.replaceState.bind(history); +history.replaceState = function (...args: Parameters<History['replaceState']>) { + origReplace(...args); + emitNav(); +}; + +// ── Click ───────────────────────────────────────────────────── + +document.addEventListener( + 'click', + (e) => { + const raw = e.target as Element | null; + if (raw?.nodeType !== 1) return; + const el = resolveClickTarget(raw); + const { selector } = buildSelector(el); + send({ type: 'click', selector, context: extractContext(el) }); + }, + true, +); + +// ── Fill / Select ───────────────────────────────────────────── + +document.addEventListener( + 'change', + (e) => { + const el = e.target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) | null; + if (!el) return; + const { selector } = buildSelector(el); + const context = extractContext(el); + if (el.tagName === 'SELECT') { + send({ type: 'select', selector, value: el.value, context }); + } else { + send({ type: 'fill', selector, value: el.value, context }); + } + }, + true, +); + +// ── Keyboard ────────────────────────────────────────────────── + +document.addEventListener( + 'keydown', + (e) => { + if (['Enter', 'Tab', 'Escape'].includes(e.key)) { + send({ type: 'press', key: e.key }); + } + }, + true, +); + +contextBridge.exposeInMainWorld('__adcRecorder', { active: true }); + +console.log('[adc recorder preload] loaded'); diff --git a/src/renderer/app/layouts/ChannelBadge.tsx b/src/renderer/app/layouts/ChannelBadge.tsx new file mode 100644 index 00000000..9fbf805c --- /dev/null +++ b/src/renderer/app/layouts/ChannelBadge.tsx @@ -0,0 +1,15 @@ +import { Badge } from '@ui'; + +export function ChannelBadge() { + const { channel } = window.appInfo; + if (channel === 'release') return null; + + const label = channel === 'dev' ? 'DEV' : 'LOCAL'; + const variant = channel === 'dev' ? 'default' : 'secondary'; + + return ( + <Badge className="ml-2 uppercase tracking-wide" variant={variant}> + {label} + </Badge> + ); +} diff --git a/src/renderer/app/layouts/ContentAreaContainer.tsx b/src/renderer/app/layouts/ContentAreaContainer.tsx index ca644e0c..867b841d 100644 --- a/src/renderer/app/layouts/ContentAreaContainer.tsx +++ b/src/renderer/app/layouts/ContentAreaContainer.tsx @@ -21,17 +21,13 @@ import { useLayoutStore } from '@renderer/shared/stores'; /** Applied to the root container — insets both toolbar and content equally */ const CONTAINER_STYLE: Record<ContentLayoutId, string> = { flush: '', - padded: 'p-2 gap-2', - bordered: 'p-2 gap-2', - inset: 'p-3 gap-3', + bordered: 'p-[var(--layout-gap)] gap-[var(--layout-gap)]', }; /** Applied to the inner content wrapper — decoration only */ const INNER_STYLE: Record<ContentLayoutId, string> = { flush: '', - padded: '', bordered: 'border-border rounded-lg border', - inset: 'bg-muted/30 border-border rounded-xl border', }; // ── Sub-components ────────────────────────────────────────── diff --git a/src/renderer/app/layouts/ContentHeader.tsx b/src/renderer/app/layouts/ContentHeader.tsx index f237eeac..141219c1 100644 --- a/src/renderer/app/layouts/ContentHeader.tsx +++ b/src/renderer/app/layouts/ContentHeader.tsx @@ -13,7 +13,7 @@ import { AppBreadcrumbs } from './AppBreadcrumbs'; export function ContentHeader() { return ( - <header className="border-border flex h-10 shrink-0 items-center gap-2 border-b px-3"> + <header className="border-border flex h-10 shrink-0 items-center gap-[var(--layout-gap)] border-b px-[var(--layout-gap-lg)]"> <SidebarTrigger className="-ml-1" /> <Separator className="bg-border mr-2 h-4 w-px" orientation="vertical" /> <AppBreadcrumbs /> diff --git a/src/renderer/app/layouts/ProjectTabBar.tsx b/src/renderer/app/layouts/ProjectTabBar.tsx index 1a875c2f..6a357762 100644 --- a/src/renderer/app/layouts/ProjectTabBar.tsx +++ b/src/renderer/app/layouts/ProjectTabBar.tsx @@ -46,10 +46,11 @@ export function ProjectTabBar() { if (!project) return null; const isActive = project.id === activeProjectId; return ( - <button + <Button key={project.id} + variant="ghost" className={cn( - 'group flex items-center gap-2 rounded-t-md px-3 py-1.5 text-sm transition-colors', + 'group flex h-auto items-center gap-2 rounded-t-md px-3 py-1.5 text-sm transition-colors', isActive ? 'bg-background text-foreground border-primary border-b-2' : 'text-muted-foreground hover:bg-accent hover:text-foreground', @@ -67,17 +68,20 @@ export function ProjectTabBar() { > <X className="h-3 w-3" /> </Button> - </button> + </Button> ); })} - <button - className="text-muted-foreground hover:bg-accent hover:text-foreground ml-1 rounded-md p-1.5" + <Button + aria-label="Open project" + className="ml-1" + size="icon-xs" title="Open project" + variant="ghost" onClick={handleAddProject} > - <Plus className="h-4 w-4" /> - </button> + <Plus className="h-4 w-4 shrink-0" /> + </Button> </div> ); } diff --git a/src/renderer/app/layouts/RootLayout.tsx b/src/renderer/app/layouts/RootLayout.tsx index 6ce788d0..814a9349 100644 --- a/src/renderer/app/layouts/RootLayout.tsx +++ b/src/renderer/app/layouts/RootLayout.tsx @@ -17,17 +17,16 @@ import { AppUpdateNotification } from '@renderer/shared/components/AppUpdateNoti import { AuthNotification } from '@renderer/shared/components/AuthNotification'; import { RouteErrorBoundary } from '@renderer/shared/components/error-boundaries'; import { EventBridge } from '@renderer/shared/components/EventBridge'; -import { HubNotification } from '@renderer/shared/components/HubNotification'; import { MutationErrorToast } from '@renderer/shared/components/MutationErrorToast'; -import { TransitionOutlet } from '@renderer/shared/components/ui/transition-outlet'; import { WebhookNotification } from '@renderer/shared/components/WebhookNotification'; -import { useLayoutSync, useThemeSync } from '@renderer/shared/hooks'; +import { WorkspaceInitOverlay } from '@renderer/shared/components/WorkspaceInitOverlay'; +import { useLayoutSync } from '@renderer/shared/hooks'; import { useRouteHistoryStore } from '@renderer/shared/stores'; import { AssistantWidget } from '@features/assistant'; import { OnboardingWizard } from '@features/onboarding'; +import { IncomingPinDialog } from '@features/peers'; import { useErrorEvents, useSettings } from '@features/settings'; -import { useHubStatus } from '@features/settings/api/useHub'; import { WorkflowPermissionModal } from '@features/workflow'; import { ContentAreaContainer } from './ContentAreaContainer'; @@ -36,14 +35,12 @@ import { TopBar } from './TopBar'; export function RootLayout() { const { data: settings, isLoading } = useSettings(); - const { data: hubStatus } = useHubStatus(); const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false); const pathname = useRouterState({ select: (s) => s.location.pathname }); const pushRoute = useRouteHistoryStore((s) => s.pushRoute); - // Sync theme & layout from IPC settings into Zustand stores - useThemeSync(); - useLayoutSync(); + // Sync layout from IPC settings into Zustand store (theme sync handled by useSettings queryFn) + const layoutSync = useLayoutSync(); // Activate error/health event listeners useErrorEvents(); @@ -84,29 +81,23 @@ export function RootLayout() { <ContentAreaContainer> <ContentAreaContainer.ToolBar> <TopBar /> - {hubStatus?.status === 'disconnected' || hubStatus?.status === 'error' ? ( - <div className="bg-destructive/10 text-destructive px-4 py-1.5 text-center text-xs"> - Hub disconnected. Some features may be unavailable. - </div> - ) : null} </ContentAreaContainer.ToolBar> <ContentAreaContainer.Content> - <TransitionOutlet routeKey={pathname}> - <RouteErrorBoundary resetKey={pathname}> - <Outlet /> - </RouteErrorBoundary> - </TransitionOutlet> + <RouteErrorBoundary resetKey={pathname}> + <Outlet /> + </RouteErrorBoundary> </ContentAreaContainer.Content> </ContentAreaContainer> </LayoutWrapper> </div> <AppUpdateNotification /> <AuthNotification /> - <HubNotification /> <MutationErrorToast /> <WebhookNotification /> <AssistantWidget /> <WorkflowPermissionModal /> + <IncomingPinDialog /> + <WorkspaceInitOverlay phase={layoutSync.phase} projectCount={layoutSync.projectCount} /> </div> ); } diff --git a/src/renderer/app/layouts/TitleBar.tsx b/src/renderer/app/layouts/TitleBar.tsx index 74a08181..837be5bd 100644 --- a/src/renderer/app/layouts/TitleBar.tsx +++ b/src/renderer/app/layouts/TitleBar.tsx @@ -2,8 +2,8 @@ * TitleBar — Custom frameless window title bar * * Replaces the native OS title bar. Provides drag region for window - * movement, utility buttons (screenshot, health, hub status), and - * minimize/maximize/close buttons for window controls. + * movement, utility buttons (screenshot, health), and minimize/maximize/close + * buttons for window controls. */ import { useCallback, useEffect, useState } from 'react'; @@ -14,13 +14,13 @@ import { Minus, Settings, Square, X } from 'lucide-react'; import { ROUTES } from '@shared/constants'; import { WINDOW } from '@shared/ipc/window/channels'; -import { HubStatus } from '@renderer/shared/components/HubStatus'; import { ipc } from '@renderer/shared/lib/ipc'; import { Button, Separator } from '@ui'; import { HealthIndicator } from '@features/settings'; +import { ChannelBadge } from './ChannelBadge'; import { TitleBarScreenshot } from './TitleBarScreenshot'; export function TitleBar() { @@ -73,12 +73,13 @@ export function TitleBar() { <span className="text-muted-foreground select-none text-xs font-semibold tracking-wide"> ADC </span> + <ChannelBadge /> </div> {/* Spacer — fills remaining drag area */} <div className="flex-1" /> - {/* Utility buttons — settings, screenshot, health, hub status */} + {/* Utility buttons — settings, screenshot, health */} <div className="electron-no-drag flex items-center gap-0.5 px-1"> <Button aria-label="Settings" @@ -91,7 +92,6 @@ export function TitleBar() { </Button> <TitleBarScreenshot /> <HealthIndicator /> - <HubStatus /> </div> {/* Divider between utility buttons and window controls */} diff --git a/src/renderer/app/layouts/TitleBarScreenshot.tsx b/src/renderer/app/layouts/TitleBarScreenshot.tsx index faf7d7d4..cf543a86 100644 --- a/src/renderer/app/layouts/TitleBarScreenshot.tsx +++ b/src/renderer/app/layouts/TitleBarScreenshot.tsx @@ -9,10 +9,12 @@ import { useCallback, useRef, useState } from 'react'; import { Camera, Check } from 'lucide-react'; -import { SCREEN } from '@shared/ipc/misc/screen.channels'; +import { SCREEN } from '@shared/ipc/screen'; import { ipc } from '@renderer/shared/lib/ipc'; +import { Button } from '@ui'; + export function TitleBarScreenshot() { // 1. Hooks const [captured, setCaptured] = useState(false); @@ -60,18 +62,18 @@ export function TitleBarScreenshot() { // 3. Render return ( - <button + <Button aria-label={captured ? 'Screenshot copied' : 'Take screenshot'} - className="border-border text-muted-foreground hover:bg-muted hover:text-foreground flex h-full w-10 items-center justify-center border-l" + size="toolbar" title={captured ? 'Screenshot copied' : 'Take screenshot'} - type="button" + variant="toolbar" onClick={handleClick} > {captured ? ( - <Check className="h-3.5 w-3.5 text-success" /> + <Check className="text-success" /> ) : ( - <Camera className="h-3.5 w-3.5" /> + <Camera /> )} - </button> + </Button> ); } diff --git a/src/renderer/app/layouts/TopBar.tsx b/src/renderer/app/layouts/TopBar.tsx index 65e6f8ff..e1501d1b 100644 --- a/src/renderer/app/layouts/TopBar.tsx +++ b/src/renderer/app/layouts/TopBar.tsx @@ -19,7 +19,7 @@ import { ipc } from '@renderer/shared/lib/ipc'; import { cn } from '@renderer/shared/lib/utils'; import { useLayoutStore } from '@renderer/shared/stores'; -import { Badge } from '@ui'; +import { Badge, Button } from '@ui'; import { useProjects } from '@features/projects'; import { HealthIndicator } from '@features/settings'; @@ -33,13 +33,7 @@ import { TitleBarScreenshot } from './TitleBarScreenshot'; const TOOLBAR_CLASSES: Record<ToolbarStyleId, string> = { default: 'h-10 bg-card border border-border', - compact: 'h-8 bg-card border border-border', - spacious: 'h-12 bg-card border border-border', - floating: 'h-9 bg-card/90 border border-border rounded-lg shadow-sm', - bordered: 'h-10 bg-card border-2 border-border', - glass: 'h-10 bg-card/60 backdrop-blur-md border border-border/50', - minimal: 'h-8 bg-transparent', - inset: 'h-10 bg-muted/40 border border-border', + floating: 'h-9 bg-card/90 border border-border rounded-lg shadow-sm overflow-hidden', }; export function TopBar() { @@ -102,31 +96,40 @@ export function TopBar() { <div className={cn('electron-drag flex shrink-0 items-stretch', TOOLBAR_CLASSES[toolbarStyle])}> {/* Sidebar toggle */} <div className="electron-no-drag flex shrink-0 items-stretch"> - <button + <Button aria-label="Toggle sidebar" - className="border-border text-muted-foreground hover:bg-muted hover:text-foreground flex h-full w-10 items-center justify-center border-r" - type="button" + className="border-r border-l-0" + size="toolbar" + variant="toolbar" onClick={toggleSidebar} > - <PanelLeft className="h-3.5 w-3.5" /> - </button> + <PanelLeft /> + </Button> </div> {/* Project tabs — VSCode-style: right border per tab, horizontal scroll */} - <div className="electron-no-drag flex min-w-0 flex-1 items-stretch overflow-x-auto overflow-y-hidden"> + <div className="electron-no-drag flex min-w-0 items-stretch overflow-x-auto overflow-y-hidden"> {openProjects.map((project) => { if (!project) return null; const isActive = project.id === activeProjectId; return ( - <button + <div key={project.id} + role="tab" + tabIndex={0} className={cn( - 'border-border group flex h-full shrink-0 items-center gap-1.5 border-r px-3 text-xs transition-colors', + 'border-border group flex h-full shrink-0 cursor-pointer items-center gap-1.5 border-r px-3 text-xs transition-colors', isActive ? 'bg-background text-foreground' : 'bg-card text-muted-foreground hover:text-foreground', )} onClick={() => handleSelectProject(project.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelectProject(project.id); + } + }} > {isActive ? ( <FolderOpen className="text-primary h-3 w-3 shrink-0" /> @@ -134,47 +137,50 @@ export function TopBar() { <Folder className="h-3 w-3 shrink-0" /> )} <span className="max-w-32 truncate">{project.name}</span> - <button + <Button aria-label={`Close ${project.name} tab`} - className="text-muted-foreground hover:text-foreground ml-0.5 flex h-4 w-4 items-center justify-center rounded-sm opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100" - type="button" + className="text-muted-foreground hover:text-foreground ml-0.5 h-4 w-4 opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100" + size="icon" + variant="ghost" onClick={(e) => { e.stopPropagation(); removeProjectTab(project.id); }} > <X className="h-3 w-3" /> - </button> - </button> + </Button> + </div> ); })} {/* Add tab — sticky, grows with list until hitting settings */} - <button - className="border-border text-muted-foreground hover:text-foreground flex h-full shrink-0 items-center border-r px-3 transition-colors" + <Button + aria-label="Open project" + className="border-border text-muted-foreground hover:text-foreground h-full shrink-0 rounded-none border-r px-3" title="Open project" - type="button" + variant="ghost" onClick={handleAddProject} > - <Plus className="h-3.5 w-3.5" /> - </button> + <Plus className="h-4 w-4" /> + </Button> - {/* Drag spacer fills remaining space */} - <div className="flex-1" /> </div> + {/* Drag spacer fills remaining space — must be outside electron-no-drag */} + <div className="electron-drag flex-1" /> + {/* Utility buttons — same w-10 sizing as window controls */} <div className="electron-no-drag flex h-full items-center"> <Badge display={window.appInfo.devMode} size="sm" value="DEV" variant="warning" /> <WorkflowStatusBar /> - <button + <Button aria-label="Settings" - className="border-border text-muted-foreground hover:bg-muted hover:text-foreground flex h-full w-10 items-center justify-center border-l" - type="button" + size="toolbar" + variant="toolbar" onClick={() => void navigate({ to: ROUTES.SETTINGS })} > - <Settings className="h-3.5 w-3.5" /> - </button> + <Settings /> + </Button> <TitleBarScreenshot /> <div className="border-border flex h-full items-center border-l px-2"> <HealthIndicator /> @@ -183,24 +189,23 @@ export function TopBar() { {/* Window controls — left border per button */} <div className="electron-no-drag flex h-full items-center"> - <button + <Button aria-label="Minimize window" - className="border-border text-muted-foreground hover:bg-muted hover:text-foreground flex h-full w-10 items-center justify-center border-l" - type="button" + size="toolbar" + variant="toolbar" onClick={handleMinimize} > - <Minus className="h-3.5 w-3.5" /> - </button> - <button + <Minus /> + </Button> + <Button aria-label={isMaximized ? 'Restore window' : 'Maximize window'} - className="border-border text-muted-foreground hover:bg-muted hover:text-foreground flex h-full w-10 items-center justify-center border-l" - type="button" + size="toolbar" + variant="toolbar" onClick={handleMaximize} > {isMaximized ? ( <svg aria-hidden="true" - className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="1.5" @@ -210,17 +215,18 @@ export function TopBar() { <path d="M2.5 2.5V1a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H8" /> </svg> ) : ( - <Square className="h-3 w-3" /> + <Square /> )} - </button> - <button + </Button> + <Button aria-label="Close window" - className="border-border text-muted-foreground hover:bg-destructive hover:text-destructive-foreground flex h-full w-10 items-center justify-center border-l" - type="button" + className="hover:bg-destructive hover:text-destructive-foreground" + size="toolbar" + variant="toolbar" onClick={handleClose} > - <X className="h-3.5 w-3.5" /> - </button> + <X /> + </Button> </div> </div> ); diff --git a/src/renderer/app/layouts/UserMenu.tsx b/src/renderer/app/layouts/UserMenu.tsx deleted file mode 100644 index 6d50ed30..00000000 --- a/src/renderer/app/layouts/UserMenu.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/** - * UserMenu -- Sidebar user menu with logout dropdown - * - * Shows the current user's avatar and display name in the sidebar footer. - * Click opens a dropdown with user info and a logout button. - * In collapsed mode, only the avatar is shown. - */ - -import { useNavigate } from '@tanstack/react-router'; -import { ChevronDown, LogOut } from 'lucide-react'; - -import { ROUTES } from '@shared/constants'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { - Badge, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, - Text, -} from '@ui'; - -import { useAuthStore, useLogout } from '@features/auth'; - -// -- Types -- - -interface UserMenuProps { - collapsed: boolean; -} - -// -- Helpers -- - -function getInitial(displayName: string, email: string): string { - const source = displayName.length > 0 ? displayName : email; - return source.charAt(0).toUpperCase(); -} - -// -- Component -- - -export function UserMenu({ collapsed }: UserMenuProps) { - const navigate = useNavigate(); - const user = useAuthStore((s) => s.user); - const logout = useLogout(); - - if (!user) return null; - - const initial = getInitial(user.displayName, user.email); - const displayLabel = user.displayName.length > 0 ? user.displayName : user.email; - - function handleLogout() { - logout.mutate(undefined, { - onSuccess: () => { - void navigate({ to: ROUTES.LOGIN }); - }, - }); - } - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <button - aria-label={collapsed ? `User menu for ${displayLabel}` : undefined} - title={collapsed ? displayLabel : undefined} - type="button" - className={cn( - 'group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors', - 'text-muted-foreground hover:bg-accent hover:text-foreground', - collapsed && 'justify-center px-0', - )} - > - <Badge - className="bg-primary text-primary-foreground flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium" - > - {initial} - </Badge> - {collapsed ? null : ( - <> - <Text className="min-w-0 flex-1 truncate text-left font-medium" size="md" variant="default"> - {displayLabel} - </Text> - <ChevronDown className="h-4 w-4 shrink-0 transition-transform group-data-[state=open]:rotate-180" /> - </> - )} - </button> - </DropdownMenuTrigger> - - <DropdownMenuContent - align={collapsed ? 'start' : 'end'} - className="w-56" - side="bottom" - sideOffset={4} - > - <DropdownMenuLabel> - <Text className="truncate font-medium" size="md" variant="default">{user.displayName}</Text> - <Text className="truncate font-normal" size="sm" variant="muted">{user.email}</Text> - </DropdownMenuLabel> - <DropdownMenuSeparator /> - <DropdownMenuItem - className="text-destructive focus:text-destructive" - onClick={handleLogout} - > - <LogOut className="h-4 w-4" /> - <span>Log out</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ); -} diff --git a/src/renderer/app/layouts/sidebar-layouts/AppSidebar.tsx b/src/renderer/app/layouts/sidebar-layouts/AppSidebar.tsx index 959c9275..20624992 100644 --- a/src/renderer/app/layouts/sidebar-layouts/AppSidebar.tsx +++ b/src/renderer/app/layouts/sidebar-layouts/AppSidebar.tsx @@ -20,16 +20,12 @@ import { SidebarGroup, SidebarGroupContent, SidebarGroupLabel, - SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail, - useSidebar, } from '@ui/sidebar'; -import { UserMenu } from '../UserMenu'; - import { LAYOUT_CONFIGS } from './layout-configs'; import { NavGroup } from './NavGroup'; import { @@ -41,7 +37,6 @@ export function AppSidebar() { const navigate = useNavigate(); const routerState = useRouterState(); const { activeProjectId, addProjectTab, sidebarLayout } = useLayoutStore(); - const { open } = useSidebar(); const currentPath = routerState.location.pathname; const config = LAYOUT_CONFIGS[sidebarLayout]; @@ -81,10 +76,6 @@ export function AppSidebar() { side={config.side ?? 'left'} variant={config.variant} > - <SidebarHeader> - <UserMenu collapsed={!open} /> - </SidebarHeader> - <SidebarContent> <NavGroup devSubGroups={config.devSubGroups} @@ -123,10 +114,6 @@ export function AppSidebar() { side={config.dualSidebar.rightSide ?? 'right'} variant={config.dualSidebar.rightVariant} > - <SidebarHeader> - <UserMenu collapsed={!open} /> - </SidebarHeader> - <SidebarContent> <SidebarGroup> <SidebarGroupLabel>Actions</SidebarGroupLabel> diff --git a/src/renderer/app/layouts/sidebar-layouts/layout-configs.ts b/src/renderer/app/layouts/sidebar-layouts/layout-configs.ts index 1db18f89..5fd9f581 100644 --- a/src/renderer/app/layouts/sidebar-layouts/layout-configs.ts +++ b/src/renderer/app/layouts/sidebar-layouts/layout-configs.ts @@ -44,32 +44,6 @@ export interface SidebarLayoutConfig { // ── Layout Configs ──────────────────────────────────────────── export const LAYOUT_CONFIGS: Record<SidebarLayoutId, SidebarLayoutConfig> = { - 'sidebar-01': { groupStyle: 'flat' }, - 'sidebar-02': { groupStyle: 'collapsible' }, - 'sidebar-03': { groupStyle: 'flat' }, 'sidebar-04': { groupStyle: 'collapsible', variant: 'floating' }, - 'sidebar-05': { groupStyle: 'nested-submenu', collapsible: 'offcanvas' }, - 'sidebar-06': { groupStyle: 'flat', collapsible: 'offcanvas' }, 'sidebar-07': { groupStyle: 'flat', showTooltips: true }, - 'sidebar-08': { groupStyle: 'flat', variant: 'inset' }, - 'sidebar-09': { - groupStyle: 'nested-categorized', - className: 'w-[350px]', - devSubGroups: [ - { label: 'Code', startIndex: 0, endIndex: 3 }, - { label: 'Plan', startIndex: 3, endIndex: 5 }, - { label: 'Track', startIndex: 5, endIndex: 7 }, - ], - }, - 'sidebar-10': { groupStyle: 'flat', variant: 'floating', showTooltips: true }, - 'sidebar-11': { groupStyle: 'tree' }, - 'sidebar-12': { groupStyle: 'flat' }, - 'sidebar-13': { groupStyle: 'flat', collapsible: 'offcanvas' }, - 'sidebar-14': { groupStyle: 'flat', side: 'right' }, - 'sidebar-15': { - groupStyle: 'flat', - side: 'left', - dualSidebar: { rightVariant: 'inset', rightSide: 'right' }, - }, - 'sidebar-16': { groupStyle: 'collapsible' }, }; diff --git a/src/renderer/app/layouts/sidebar-layouts/shared-nav.ts b/src/renderer/app/layouts/sidebar-layouts/shared-nav.ts index c8eeeccb..773f2341 100644 --- a/src/renderer/app/layouts/sidebar-layouts/shared-nav.ts +++ b/src/renderer/app/layouts/sidebar-layouts/shared-nav.ts @@ -19,6 +19,7 @@ import { Terminal, TestTube2, User, + Workflow, Wrench, } from 'lucide-react'; @@ -40,6 +41,7 @@ export interface NavItem { export const personalItems: NavItem[] = [ { label: 'Dashboard', icon: Home, path: ROUTES.DASHBOARD }, { label: 'My Work', icon: Briefcase, path: ROUTES.MY_WORK }, + { label: 'Agents', icon: Bot, path: ROUTES.AGENTS }, { label: 'Personal', icon: User, path: ROUTES.PERSONAL }, { label: 'Assistant', icon: MessageSquare, path: ROUTES.ASSISTANT }, { label: 'Integrations', icon: Plug, path: ROUTES.INTEGRATIONS }, @@ -53,6 +55,7 @@ export const developmentItems: NavItem[] = [ { label: 'Planning', icon: Map, path: PROJECT_VIEWS.PLANNING }, { label: 'Git', icon: GitBranch, path: PROJECT_VIEWS.GIT }, { label: 'Tools', icon: Wrench, path: PROJECT_VIEWS.TOOLS }, + { label: 'Workflow', icon: Workflow, path: PROJECT_VIEWS.WORKFLOW }, { label: 'Visual Map', icon: Network, path: PROJECT_VIEWS.VISUALIZATION }, - { label: 'QA', icon: TestTube2, path: PROJECT_VIEWS.QA_RECORDER }, + { label: 'Test Suite', icon: TestTube2, path: PROJECT_VIEWS.TEST_SUITE }, ]; diff --git a/src/renderer/app/router.tsx b/src/renderer/app/router.tsx index 0c4b2681..fe7e3653 100644 --- a/src/renderer/app/router.tsx +++ b/src/renderer/app/router.tsx @@ -19,14 +19,13 @@ import { ROUTES } from '@shared/constants'; import { RootErrorBoundary } from '@renderer/shared/components/error-boundaries'; -import { AuthGuard } from '@features/auth'; +import { Button, Heading } from '@ui'; import { Spinner } from '@ui/spinner'; import { RootLayout } from './layouts/RootLayout'; import { createAssistantRoutes, - createAuthRoutes, createDashboardRoutes, createIntegrationsRoutes, createMiscRoutes, @@ -55,20 +54,19 @@ function DefaultErrorComponent({ error, reset }: ErrorComponentProps) { <div className="bg-destructive/10 flex h-12 w-12 items-center justify-center rounded-full"> <AlertTriangle aria-hidden="true" className="text-destructive h-6 w-6" /> </div> - <h2 className="text-foreground text-lg font-semibold"> + <Heading as="h2" className="text-lg"> Failed to load page - </h2> + </Heading> <p className="text-muted-foreground text-sm"> {error.message || 'An unexpected error occurred.'} </p> - <button - className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors" - type="button" + <Button + variant="primary" onClick={reset} > - <RotateCcw aria-hidden="true" className="h-4 w-4" /> + <RotateCcw aria-hidden="true" className="h-4 w-4 shrink-0" /> Retry - </button> + </Button> </div> </div> ); @@ -78,14 +76,8 @@ function DefaultErrorComponent({ error, reset }: ErrorComponentProps) { const rootRoute = createRootRoute(); -const authLayoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: 'auth', - component: AuthGuard, -}); - const appLayoutRoute = createRoute({ - getParentRoute: () => authLayoutRoute, + getParentRoute: () => rootRoute, id: 'app', component: RootLayout, }); @@ -101,8 +93,6 @@ const indexRoute = createRoute({ // ─── Route Groups ──────────────────────────────────────────── -const { loginRoute, registerRoute, hubSetupRoute } = createAuthRoutes(rootRoute); - const assistantRoutes = createAssistantRoutes(appLayoutRoute); const dashboardRoutes = createDashboardRoutes(appLayoutRoute); const projectRoutes = createProjectRoutes(appLayoutRoute); @@ -115,21 +105,16 @@ const miscRoutes = createMiscRoutes(appLayoutRoute); // ─── Route Tree ────────────────────────────────────────────── const routeTree = rootRoute.addChildren([ - loginRoute, - registerRoute, - hubSetupRoute, - authLayoutRoute.addChildren([ - appLayoutRoute.addChildren([ - indexRoute, - ...assistantRoutes, - ...dashboardRoutes, - ...projectRoutes, - ...personalRoutes, - ...productivityRoutes, - ...integrationsRoutes, - ...settingsRoutes, - ...miscRoutes, - ]), + appLayoutRoute.addChildren([ + indexRoute, + ...assistantRoutes, + ...dashboardRoutes, + ...projectRoutes, + ...personalRoutes, + ...productivityRoutes, + ...integrationsRoutes, + ...settingsRoutes, + ...miscRoutes, ]), ]); diff --git a/src/renderer/app/routes/FEATURE.md b/src/renderer/app/routes/FEATURE.md index e14dc3ae..9d812a85 100644 --- a/src/renderer/app/routes/FEATURE.md +++ b/src/renderer/app/routes/FEATURE.md @@ -4,7 +4,6 @@ TanStack Router route definitions, split by domain. Each file exports a `create* ## Key Files -- **`auth.routes.tsx`** — Login and register routes (outside app layout) - **`dashboard.routes.ts`** — Dashboard, my-work, and agents (top-level) views - **`project.routes.ts`** — Project detail, task board, terminal views - **`productivity.routes.ts`** — Planner, fitness, changelog, milestones, insights, ideas @@ -17,4 +16,3 @@ TanStack Router route definitions, split by domain. Each file exports a `create* - **`router.tsx`** (parent) imports these creators, calls each with the app layout route, and assembles the full route tree - Each route references page components from `src/renderer/features/` -- Auth routes use `AuthGuard` for protected route redirection diff --git a/src/renderer/app/routes/auth.routes.tsx b/src/renderer/app/routes/auth.routes.tsx deleted file mode 100644 index 547aa4e0..00000000 --- a/src/renderer/app/routes/auth.routes.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/* eslint-disable react-refresh/only-export-components -- Route config file, not a component module */ -/** - * Auth route group — Login, register, hub setup (standalone, no sidebar) - */ - -import { Suspense } from 'react'; - -import { - type AnyRoute, - createRoute, - lazyRouteComponent, - redirect, - useNavigate, -} from '@tanstack/react-router'; - -import { ROUTES } from '@shared/constants'; -import { HUB } from '@shared/ipc/hub/channels'; -import { SETTINGS } from '@shared/ipc/settings/channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { useAuthStore } from '@features/auth'; - -import { Spinner } from '@ui/spinner'; - -function redirectIfAuthenticated() { - const { isAuthenticated } = useAuthStore.getState(); - if (isAuthenticated) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack Router redirect pattern - throw redirect({ to: ROUTES.DASHBOARD }); - } -} - -const LOCAL_SESSION_TOKEN = 'local-session'; - -async function redirectIfHubAlreadyConfigured(): Promise<void> { - try { - const config = await ipc(HUB.GET.CONFIG, {}); - if (config.hubUrl) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack Router redirect pattern - throw redirect({ to: ROUTES.LOGIN }); - } - } catch (error: unknown) { - if ( - typeof error === 'object' && - error !== null && - 'to' in error - ) { - throw error; - } - } -} - -// ─── Lazy Page Components ───────────────────────────────────── - -const LazyLoginPage = lazyRouteComponent( - () => import('@features/auth'), - 'LoginPage', -); - -const LazyRegisterPage = lazyRouteComponent( - () => import('@features/auth'), - 'RegisterPage', -); - -const LazyHubSetupPage = lazyRouteComponent( - () => import('@features/hub-setup'), - 'HubSetupPage', -); - -// ─── Auth Suspense Fallback ─────────────────────────────────── - -function AuthPendingFallback() { - return ( - <div className="flex min-h-screen items-center justify-center"> - <Spinner aria-label="Loading" size="lg" /> - </div> - ); -} - -// ─── Route Factory ──────────────────────────────────────────── - -export function createAuthRoutes(rootRoute: AnyRoute) { - const loginRoute = createRoute({ - getParentRoute: () => rootRoute, - path: ROUTES.LOGIN, - beforeLoad: () => { - redirectIfAuthenticated(); - // Hub is optional — don't redirect to hub-setup - }, - component: LoginRouteComponent, - }); - - const registerRoute = createRoute({ - getParentRoute: () => rootRoute, - path: ROUTES.REGISTER, - beforeLoad: () => { - redirectIfAuthenticated(); - // Hub is optional — don't redirect to hub-setup - }, - component: RegisterRouteComponent, - }); - - const hubSetupRoute = createRoute({ - getParentRoute: () => rootRoute, - path: ROUTES.HUB_SETUP, - beforeLoad: async () => { - redirectIfAuthenticated(); - await redirectIfHubAlreadyConfigured(); - }, - component: HubSetupRouteComponent, - }); - - return { loginRoute, registerRoute, hubSetupRoute }; -} - -// ─── Route Components (wired with navigation callbacks) ───── - -function LoginRouteComponent() { - const navigate = useNavigate(); - - return ( - <Suspense fallback={<AuthPendingFallback />}> - <LazyLoginPage - onNavigateToHubSetup={() => { - void navigate({ to: ROUTES.HUB_SETUP }); - }} - onNavigateToRegister={() => { - void navigate({ to: ROUTES.REGISTER }); - }} - onSkip={() => { - useAuthStore.getState().setAuth( - { id: 'local', email: 'local@localhost', displayName: 'Local User', avatarUrl: null, createdAt: new Date().toISOString(), lastLoginAt: new Date().toISOString() }, - { accessToken: LOCAL_SESSION_TOKEN, refreshToken: LOCAL_SESSION_TOKEN, expiresIn: 0 }, - ); - useAuthStore.getState().setInitializing(false); - // Skip onboarding for local-skip users (returning / non-new users) - void ipc(SETTINGS.UPDATE.ALL, { onboardingCompleted: true }).then(() => - navigate({ to: ROUTES.DASHBOARD }), - ); - }} - onSuccess={() => { - // Skip onboarding for returning users who log in - void ipc(SETTINGS.UPDATE.ALL, { onboardingCompleted: true }).then(() => - navigate({ to: ROUTES.DASHBOARD }), - ); - }} - /> - </Suspense> - ); -} - -function RegisterRouteComponent() { - const navigate = useNavigate(); - - return ( - <Suspense fallback={<AuthPendingFallback />}> - <LazyRegisterPage - onNavigateToHubSetup={() => { - void navigate({ to: ROUTES.HUB_SETUP }); - }} - onNavigateToLogin={() => { - void navigate({ to: ROUTES.LOGIN }); - }} - onSuccess={() => { - void navigate({ to: ROUTES.DASHBOARD }); - }} - /> - </Suspense> - ); -} - -function HubSetupRouteComponent() { - const navigate = useNavigate(); - - return ( - <Suspense fallback={<AuthPendingFallback />}> - <LazyHubSetupPage - onNavigateToLogin={() => { - void navigate({ to: ROUTES.LOGIN }); - }} - onSkip={() => { - // Local-only mode: skip Hub, create a local session and go to dashboard - useAuthStore.getState().setAuth( - { id: 'local', email: 'local@localhost', displayName: 'Local User', avatarUrl: null, createdAt: new Date().toISOString(), lastLoginAt: new Date().toISOString() }, - { accessToken: LOCAL_SESSION_TOKEN, refreshToken: LOCAL_SESSION_TOKEN, expiresIn: 0 }, - ); - useAuthStore.getState().setInitializing(false); - // Skip onboarding for local-skip users - void ipc(SETTINGS.UPDATE.ALL, { onboardingCompleted: true }).then(() => - navigate({ to: ROUTES.DASHBOARD }), - ); - }} - onSuccess={() => { - void navigate({ to: ROUTES.LOGIN }); - }} - /> - </Suspense> - ); -} diff --git a/src/renderer/app/routes/index.ts b/src/renderer/app/routes/index.ts index c616f940..c3baa3c7 100644 --- a/src/renderer/app/routes/index.ts +++ b/src/renderer/app/routes/index.ts @@ -3,7 +3,6 @@ */ export { createAssistantRoutes } from './assistant.routes'; -export { createAuthRoutes } from './auth.routes'; export { createIntegrationsRoutes } from './integrations.routes'; export { createDashboardRoutes } from './dashboard.routes'; export { createMiscRoutes } from './misc.routes'; diff --git a/src/renderer/app/routes/project.routes.ts b/src/renderer/app/routes/project.routes.ts index a9a9875c..3afb01ab 100644 --- a/src/renderer/app/routes/project.routes.ts +++ b/src/renderer/app/routes/project.routes.ts @@ -85,21 +85,12 @@ export function createProjectRoutes(appLayoutRoute: AnyRoute) { staticData: { breadcrumbLabel: 'Git' }, pendingComponent: ProjectSkeleton, component: lazyRouteComponent( - () => import('@features/git-overview'), + () => import('@features/git'), 'GitPage', ), }); // ── Legacy redirect routes ────────────────────────────────── - const roadmapRedirect = createRoute({ - getParentRoute: () => appLayoutRoute, - path: ROUTE_PATTERNS.PROJECT_ROADMAP, - beforeLoad: ({ params }) => { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack Router redirect pattern - throw redirect({ to: ROUTE_PATTERNS.PROJECT_PLANNING, params }); - }, - }); - const ideationRedirect = createRoute({ getParentRoute: () => appLayoutRoute, path: ROUTE_PATTERNS.PROJECT_IDEATION, @@ -169,14 +160,14 @@ export function createProjectRoutes(appLayoutRoute: AnyRoute) { ), }); - const qaRecorderRoute = createRoute({ + const testSuiteRoute = createRoute({ getParentRoute: () => appLayoutRoute, - path: ROUTE_PATTERNS.PROJECT_QA_RECORDER, - staticData: { breadcrumbLabel: 'QA Recorder' }, + path: ROUTE_PATTERNS.PROJECT_TEST_SUITE, + staticData: { breadcrumbLabel: 'Test Suite' }, pendingComponent: ProjectSkeleton, component: lazyRouteComponent( - () => import('@features/qa-recorder'), - 'QaRecorderPage', + () => import('@features/test-suite'), + 'TestSuitePage', ), }); @@ -191,9 +182,8 @@ export function createProjectRoutes(appLayoutRoute: AnyRoute) { toolsRoute, workflowRoute, visualizationRoute, - qaRecorderRoute, + testSuiteRoute, // Legacy redirects - roadmapRedirect, ideationRedirect, insightsRedirect, githubRedirect, diff --git a/src/renderer/features/agent-dashboard/components/AgentChatPanel.tsx b/src/renderer/features/agent-dashboard/components/AgentChatPanel/AgentChatPanel.tsx similarity index 86% rename from src/renderer/features/agent-dashboard/components/AgentChatPanel.tsx rename to src/renderer/features/agent-dashboard/components/AgentChatPanel/AgentChatPanel.tsx index f4beea2e..ca5aa09c 100644 --- a/src/renderer/features/agent-dashboard/components/AgentChatPanel.tsx +++ b/src/renderer/features/agent-dashboard/components/AgentChatPanel/AgentChatPanel.tsx @@ -6,18 +6,18 @@ * Handles streaming messages (partial text updates). */ -import { useRef, useEffect } from 'react'; - import type { AgentChatItem } from '@shared/types/agent-dashboard'; import { cn } from '@renderer/shared/lib/utils'; import { ScrollArea } from '@ui'; -import { ActivityLine } from './ActivityLine'; -import { TextMessage } from './TextMessage'; -import { ToolCallCard } from './ToolCallCard'; -import { UserMessage } from './UserMessage'; +import { ActivityLine } from '../ActivityLine'; +import { TextMessage } from '../TextMessage'; +import { ToolCallCard } from '../ToolCallCard'; +import { UserMessage } from '../UserMessage'; + +import { useAgentChatPanel } from './useAgentChatPanel'; // ─── Props ───────────────────────────────────────────────── @@ -32,11 +32,7 @@ interface AgentChatPanelProps { // ─── Component ───────────────────────────────────────────── export function AgentChatPanel({ messages, className, onViewAgent, showHandOff = true }: AgentChatPanelProps) { - const bottomRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages.length]); + const { bottomRef } = useAgentChatPanel({ messageCount: messages.length }); if (messages.length === 0) { return ( diff --git a/src/renderer/features/agent-dashboard/components/AgentChatPanel/index.ts b/src/renderer/features/agent-dashboard/components/AgentChatPanel/index.ts new file mode 100644 index 00000000..9504f4dd --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentChatPanel/index.ts @@ -0,0 +1 @@ +export { AgentChatPanel } from './AgentChatPanel'; diff --git a/src/renderer/features/agent-dashboard/components/AgentChatPanel/useAgentChatPanel.ts b/src/renderer/features/agent-dashboard/components/AgentChatPanel/useAgentChatPanel.ts new file mode 100644 index 00000000..ef31cb5e --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentChatPanel/useAgentChatPanel.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef } from 'react'; + +interface UseAgentChatPanelParams { + messageCount: number; +} + +interface UseAgentChatPanelReturn { + bottomRef: React.RefObject<HTMLDivElement | null>; +} + +export function useAgentChatPanel({ messageCount }: UseAgentChatPanelParams): UseAgentChatPanelReturn { + const bottomRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messageCount]); + + return { bottomRef }; +} diff --git a/src/renderer/features/agent-dashboard/components/AgentDashboardPage.tsx b/src/renderer/features/agent-dashboard/components/AgentDashboardPage.tsx deleted file mode 100644 index cb6b0fe0..00000000 --- a/src/renderer/features/agent-dashboard/components/AgentDashboardPage.tsx +++ /dev/null @@ -1,603 +0,0 @@ -/** - * AgentDashboardPage — Top-level page component - * - * Two tabs: - * - Agents: existing agent session monitoring UI - * - Workflows: template picker + editor + running workflows + launch flow - */ - -import { useCallback, useMemo, useState } from 'react'; - -import { Bot, Search, Square } from 'lucide-react'; - -import type { - AgentDashboardFilters, - AgentDashboardState, - AgentLayoutMode, - AgentPanelState, - AgentSession, - AgentSessionType, - AgentStatus, -} from '@shared/types/agent-dashboard'; - -import { useDebounce } from '@renderer/shared/hooks/useDebounce'; -import { cn } from '@renderer/shared/lib/utils'; - -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - PageHeader, - PageLayout, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Separator, - Spinner, - Tabs, - TabsContent, - TabsList, - TabsTrigger, - Text, -} from '@ui'; - -import { useStopSession } from '../api/useAgentMutations'; -import { useApplyWorkflow } from '../api/useWorkflowEngine'; -import { useWorkflowTemplate } from '../api/useWorkflowTemplates'; -import { useAgentDashboardStore } from '../store'; - -import { AgentLayoutGrid } from './AgentLayoutGrid'; -import { AgentLayoutSingle } from './AgentLayoutSingle'; -import { AgentLayoutToolbar } from './AgentLayoutToolbar'; -import { AgentPanelPopup } from './AgentPanelPopup'; -import { RunningWorkflowsPanel } from './RunningWorkflowsPanel'; -import { TemplateEditorPanel } from './TemplateEditorPanel'; -import { TemplateListPanel } from './TemplateListPanel'; - -// ─── Constants ───────────────────────────────────────────── - -const SESSION_TYPE_OPTIONS: Array<{ value: AgentSessionType | 'all'; label: string }> = [ - { value: 'all', label: 'All types' }, - { value: 'project-owner', label: 'Project owner' }, - { value: 'team-lead', label: 'Team lead' }, - { value: 'teammate', label: 'Teammate' }, -]; - -const STATUS_OPTIONS: Array<{ value: AgentStatus | 'all'; label: string }> = [ - { value: 'all', label: 'All statuses' }, - { value: 'running', label: 'Running' }, - { value: 'completed', label: 'Completed' }, - { value: 'failed', label: 'Failed' }, - { value: 'idle', label: 'Idle' }, -]; - -// ─── Local filter state ───────────────────────────────────── - -interface SessionFilterState { - sessionType: AgentSessionType | 'all'; - status: AgentStatus | 'all'; - teamName: string; - search: string; -} - -// ─── Props ───────────────────────────────────────────────── - -interface AgentDashboardPageProps { - agents?: AgentSession[]; - projectOptions?: Array<{ id: string; name: string }>; - className?: string; -} - -// ─── Component ───────────────────────────────────────────── - -export function AgentDashboardPage({ - agents = [], - projectOptions = [], - className, -}: AgentDashboardPageProps) { - const [agentUiState, setAgentUiState] = useState<AgentDashboardState>({ - layoutMode: 'single', - filters: {}, - }); - - const [sessionFilters, setSessionFilters] = useState<SessionFilterState>({ - sessionType: 'all', - status: 'all', - teamName: 'all', - search: '', - }); - - const debouncedSearch = useDebounce(sessionFilters.search, 250); - - const [isStopAllDialogOpen, setIsStopAllDialogOpen] = useState(false); - const [pendingStopIds, setPendingStopIds] = useState<Set<string>>(new Set()); - - const stopSession = useStopSession(); - - const activeMainTab = useAgentDashboardStore((s) => s.activeMainTab); - const setActiveMainTab = useAgentDashboardStore((s) => s.setActiveMainTab); - - // ─── Agent panel handlers ───────────────────────────── - - const handleLayoutChange = useCallback((mode: AgentLayoutMode) => { - setAgentUiState((prev) => ({ ...prev, layoutMode: mode })); - }, []); - - const handleFilterChange = useCallback((filters: AgentDashboardFilters) => { - setAgentUiState((prev) => ({ ...prev, filters })); - }, []); - - const handlePanelStateChange = useCallback( - (agentId: string, panelState: AgentPanelState) => { - setAgentUiState((prev) => { - switch (panelState) { - case 'expanded': - return { - ...prev, - expandedAgentId: prev.expandedAgentId === agentId ? undefined : agentId, - }; - case 'popup': - return { ...prev, popupAgentId: agentId }; - case 'compact': - return { - ...prev, - expandedAgentId: - prev.expandedAgentId === agentId ? undefined : prev.expandedAgentId, - }; - default: - return prev; - } - }); - }, - [], - ); - - const handleClosePopup = useCallback(() => { - setAgentUiState((prev) => ({ ...prev, popupAgentId: undefined })); - }, []); - - const handleViewAgent = useCallback((agentId: string) => { - setAgentUiState((prev) => ({ ...prev, popupAgentId: agentId })); - }, []); - - const handleStopSession = useCallback( - (agentId: string) => { - setPendingStopIds((prev) => new Set([...prev, agentId])); - stopSession.mutate( - { sessionId: agentId }, - { - onSettled: () => { - setPendingStopIds((prev) => { - const next = new Set(prev); - next.delete(agentId); - return next; - }); - }, - }, - ); - }, - [stopSession], - ); - - function handleStopAll() { - runningAgents.forEach((a) => { - handleStopSession(a.id); - }); - setIsStopAllDialogOpen(false); - } - - // ─── Team name options ──────────────────────────────── - - const teamNameOptions = useMemo(() => { - const names = Array.from( - new Set(agents.map((a) => a.teamName).filter((n): n is string => n !== undefined && n !== '')) - ); - return names; - }, [agents]); - - // ─── Filtered Agents ────────────────────────────────── - - const filteredAgents = useMemo(() => { - let result = agents; - - // Existing IPC-backed filters - if (agentUiState.filters.projectId !== undefined) { - result = result.filter((a) => a.projectId === agentUiState.filters.projectId); - } - if (agentUiState.filters.status !== undefined) { - result = result.filter((a) => a.status === agentUiState.filters.status); - } - - // New client-side filters - if (sessionFilters.sessionType !== 'all') { - result = result.filter((a) => a.type === sessionFilters.sessionType); - } - if (sessionFilters.status !== 'all') { - result = result.filter((a) => a.status === sessionFilters.status); - } - if (sessionFilters.teamName !== 'all') { - result = result.filter((a) => a.teamName === sessionFilters.teamName); - } - if (debouncedSearch.trim().length > 0) { - const query = debouncedSearch.trim().toLowerCase(); - result = result.filter((a) => a.name.toLowerCase().includes(query)); - } - - return result; - }, [agents, agentUiState.filters, sessionFilters, debouncedSearch]); - - const runningAgents = useMemo(() => agents.filter((a) => a.status === 'running'), [agents]); - - const hasRunningAgents = runningAgents.length > 0; - - const popupAgent = useMemo( - () => agents.find((a) => a.id === agentUiState.popupAgentId), - [agents, agentUiState.popupAgentId], - ); - - // ─── Agents tab filter bar ──────────────────────────── - - function renderSessionFilterBar() { - return ( - <div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border px-4 py-3"> - {/* Session type filter */} - <Select - value={sessionFilters.sessionType} - onValueChange={(v) => - setSessionFilters((prev) => ({ - ...prev, - sessionType: v as AgentSessionType | 'all', - })) - } - > - <SelectTrigger aria-label="Filter by session type" className="w-40"> - <SelectValue placeholder="All types" /> - </SelectTrigger> - <SelectContent> - {SESSION_TYPE_OPTIONS.map((opt) => ( - <SelectItem key={opt.value} value={opt.value}> - {opt.label} - </SelectItem> - ))} - </SelectContent> - </Select> - - {/* Status filter */} - <Select - value={sessionFilters.status} - onValueChange={(v) => - setSessionFilters((prev) => ({ - ...prev, - status: v as AgentStatus | 'all', - })) - } - > - <SelectTrigger aria-label="Filter by status" className="w-40"> - <SelectValue placeholder="All statuses" /> - </SelectTrigger> - <SelectContent> - {STATUS_OPTIONS.map((opt) => ( - <SelectItem key={opt.value} value={opt.value}> - {opt.label} - </SelectItem> - ))} - </SelectContent> - </Select> - - {/* Team name filter */} - <Select - value={sessionFilters.teamName} - onValueChange={(v) => - setSessionFilters((prev) => ({ ...prev, teamName: v })) - } - > - <SelectTrigger aria-label="Filter by team" className="w-40"> - <SelectValue placeholder="All teams" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="all">All teams</SelectItem> - {teamNameOptions.map((name) => ( - <SelectItem key={name} value={name}> - {name} - </SelectItem> - ))} - </SelectContent> - </Select> - - {/* Search by session name */} - <div className="relative flex-1"> - <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> - <Input - aria-label="Search sessions by name" - className="pl-9" - placeholder="Search sessions..." - type="text" - value={sessionFilters.search} - onChange={(e) => - setSessionFilters((prev) => ({ ...prev, search: e.target.value })) - } - /> - </div> - </div> - ); - } - - // ─── Agents tab empty state ─────────────────────────── - - function renderAgentsContent() { - if (agents.length === 0) { - return ( - <> - {renderSessionFilterBar()} - <div className="flex flex-1 flex-col items-center justify-center"> - <Bot className="mb-4 h-12 w-12 text-muted-foreground" /> - <Text className="text-lg font-medium text-foreground">No agents running</Text> - <Text className="mt-1 text-sm text-muted-foreground"> - Start a session to see agent activity here - </Text> - </div> - </> - ); - } - - return ( - <> - {renderSessionFilterBar()} - <div className="min-h-0 flex-1 p-4"> - {agentUiState.layoutMode === 'single' ? ( - <AgentLayoutSingle - agents={filteredAgents} - className="h-full" - expandedAgentId={agentUiState.expandedAgentId} - pendingStopIds={pendingStopIds} - onPanelStateChange={handlePanelStateChange} - onStop={handleStopSession} - onViewAgent={handleViewAgent} - /> - ) : ( - <AgentLayoutGrid - agents={filteredAgents} - className="h-full" - expandedAgentId={agentUiState.expandedAgentId} - pendingStopIds={pendingStopIds} - onPanelStateChange={handlePanelStateChange} - onStop={handleStopSession} - onViewAgent={handleViewAgent} - /> - )} - </div> - {popupAgent === undefined ? null : ( - <AgentPanelPopup - agent={popupAgent} - open={agentUiState.popupAgentId !== undefined} - onClose={handleClosePopup} - onViewAgent={handleViewAgent} - /> - )} - </> - ); - } - - // ─── Render ──────────────────────────────────────────── - - return ( - <PageLayout className={className}> - <PageHeader> - <PageHeader.Row> - <PageHeader.Title>Agent Dashboard</PageHeader.Title> - {activeMainTab === 'agents' ? ( - <PageHeader.Actions> - {hasRunningAgents ? ( - <Button - size="sm" - variant="destructive" - onClick={() => setIsStopAllDialogOpen(true)} - > - <Square className="mr-1.5 h-3.5 w-3.5" /> - Stop All ({runningAgents.length}) - </Button> - ) : null} - <AgentLayoutToolbar - filters={agentUiState.filters} - layoutMode={agentUiState.layoutMode} - projectOptions={projectOptions} - onFilterChange={handleFilterChange} - onLayoutChange={handleLayoutChange} - /> - </PageHeader.Actions> - ) : null} - </PageHeader.Row> - </PageHeader> - - <div className={cn('min-h-0 flex-1', 'flex flex-col')}> - <Tabs - className="flex h-full flex-col" - value={activeMainTab} - onValueChange={(v) => setActiveMainTab(v as 'agents' | 'workflows')} - > - <div className="shrink-0 px-4"> - <TabsList> - <TabsTrigger value="agents">Agents</TabsTrigger> - <TabsTrigger value="workflows">Workflows</TabsTrigger> - </TabsList> - </div> - - <TabsContent className="flex min-h-0 flex-1 flex-col" value="agents"> - {renderAgentsContent()} - </TabsContent> - - <TabsContent className="min-h-0 flex-1 overflow-auto p-4" value="workflows"> - <WorkflowsTabContent /> - </TabsContent> - </Tabs> - </div> - - {/* Stop All confirmation dialog */} - <Dialog - open={isStopAllDialogOpen} - onOpenChange={(open) => { - if (!open) setIsStopAllDialogOpen(false); - }} - > - <DialogContent className="max-w-sm"> - <DialogHeader> - <DialogTitle>Stop all sessions?</DialogTitle> - <DialogDescription> - This will stop {runningAgents.length} running{' '} - {runningAgents.length === 1 ? 'session' : 'sessions'}. This action cannot be undone. - </DialogDescription> - </DialogHeader> - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => setIsStopAllDialogOpen(false)} - > - Cancel - </Button> - <Button - type="button" - variant="destructive" - onClick={handleStopAll} - > - Stop All - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - - {/* Template editor dialog — driven by store */} - <TemplateEditorPanel /> - - {/* Launch dialog — driven by store */} - <LaunchWorkflowDialog /> - </PageLayout> - ); -} - -// ─── WorkflowsTabContent ───────────────────────────────────── - -function WorkflowsTabContent() { - return ( - <div className="grid h-full gap-4" style={{ gridTemplateColumns: '1fr 1fr' }}> - <TemplateListPanel /> - <RunningWorkflowsPanel /> - </div> - ); -} - -// ─── LaunchWorkflowDialog ──────────────────────────────────── - -function LaunchWorkflowDialog() { - const isOpen = useAgentDashboardStore((s) => s.isLaunchDialogOpen); - const launchTemplateId = useAgentDashboardStore((s) => s.launchTemplateId); - const closeLaunchDialog = useAgentDashboardStore((s) => s.closeLaunchDialog); - - const { data: template } = useWorkflowTemplate(launchTemplateId); - const applyWorkflow = useApplyWorkflow(); - - const [featureName, setFeatureName] = useState(''); - const [projectPath, setProjectPath] = useState(''); - - function handleClose() { - closeLaunchDialog(); - setFeatureName(''); - setProjectPath(''); - } - - function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) { - e.preventDefault(); - if (launchTemplateId === null) return; - - applyWorkflow.mutate( - { - templateId: launchTemplateId, - featureName, - projectPath, - overrides: {}, - }, - { onSuccess: () => handleClose() }, - ); - } - - const canSubmit = - featureName.trim().length > 0 && projectPath.trim().length > 0 && !applyWorkflow.isPending; - - return ( - <Dialog - open={isOpen} - onOpenChange={(open) => { - if (!open) handleClose(); - }} - > - <DialogContent className="max-w-md"> - <DialogHeader> - <DialogTitle>Launch Workflow</DialogTitle> - </DialogHeader> - <Separator /> - <form className="space-y-4 py-2" id="launch-workflow-form" onSubmit={handleSubmit}> - {template === undefined ? null : ( - <Text size="sm" variant="muted"> - Template: <span className="font-medium text-foreground">{template.name}</span> - </Text> - )} - - <div className="space-y-1.5"> - <Label htmlFor="launch-feature-name">Feature name</Label> - <Input - required - id="launch-feature-name" - placeholder="my-feature-slug" - value={featureName} - onChange={(e) => setFeatureName(e.target.value)} - /> - </div> - - <div className="space-y-1.5"> - <Label htmlFor="launch-project-path">Project path</Label> - <Input - required - id="launch-project-path" - placeholder="/absolute/path/to/project" - value={projectPath} - onChange={(e) => setProjectPath(e.target.value)} - /> - </div> - - {applyWorkflow.isError ? ( - <Text className="text-destructive" size="sm"> - Failed to launch workflow. Please try again. - </Text> - ) : null} - </form> - <Separator /> - <DialogFooter> - <Button - disabled={applyWorkflow.isPending} - type="button" - variant="outline" - onClick={handleClose} - > - Cancel - </Button> - <Button - disabled={!canSubmit} - form="launch-workflow-form" - type="submit" - > - {applyWorkflow.isPending ? <Spinner className="mr-2" size="sm" /> : null} - Launch - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/agent-dashboard/components/AgentDashboardPage/AgentDashboardPage.tsx b/src/renderer/features/agent-dashboard/components/AgentDashboardPage/AgentDashboardPage.tsx new file mode 100644 index 00000000..df11d8f3 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentDashboardPage/AgentDashboardPage.tsx @@ -0,0 +1,455 @@ +/** + * AgentDashboardPage — Top-level page component + * + * Two tabs: + * - Agents: existing agent session monitoring UI + * - Workflows: template picker + editor + running workflows + launch flow + */ + +import { Bot, Search, Square } from 'lucide-react'; + +import type { + AgentSession, + AgentSessionType, + AgentStatus, +} from '@shared/types/agent-dashboard'; + +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + PageContent, + PageHeader, + PageLayout, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Separator, + Spinner, + Text, +} from '@ui'; + +import { AgentLayoutGrid } from '../AgentLayoutGrid'; +import { AgentLayoutSingle } from '../AgentLayoutSingle'; +import { AgentLayoutToolbar } from '../AgentLayoutToolbar'; +import { AgentPanelPopup } from '../AgentPanelPopup'; +import { RunningWorkflowsPanel } from '../RunningWorkflowsPanel'; +import { TemplateEditorPanel } from '../TemplateEditorPanel'; +import { TemplateListPanel } from '../TemplateListPanel'; + +import { useAgentDashboardPage } from './useAgentDashboardPage'; + +// ─── Constants ───────────────────────────────────────────── + +const SESSION_TYPE_OPTIONS: Array<{ value: AgentSessionType | 'all'; label: string }> = [ + { value: 'all', label: 'All types' }, + { value: 'project-owner', label: 'Project owner' }, + { value: 'team-lead', label: 'Team lead' }, + { value: 'teammate', label: 'Teammate' }, +]; + +const STATUS_OPTIONS: Array<{ value: AgentStatus | 'all'; label: string }> = [ + { value: 'all', label: 'All statuses' }, + { value: 'running', label: 'Running' }, + { value: 'completed', label: 'Completed' }, + { value: 'failed', label: 'Failed' }, + { value: 'idle', label: 'Idle' }, +]; + +// ─── Props ───────────────────────────────────────────────── + +interface AgentDashboardPageProps { + /** If omitted, the component self-fetches via useAgentSessions() */ + agents?: AgentSession[]; + projectOptions?: Array<{ id: string; name: string }>; + className?: string; +} + +// ─── Component ───────────────────────────────────────────── + +export function AgentDashboardPage({ + agents: agentsProp, + projectOptions = [], + className, +}: AgentDashboardPageProps) { + const { + agents, + filteredAgents, + runningAgents, + hasRunningAgents, + popupAgent, + agentUiState, + sessionFilters, + setSessionFilters, + isStopAllDialogOpen, + setIsStopAllDialogOpen, + pendingStopIds, + activeMainTab, + setActiveMainTab, + teamNameOptions, + handleLayoutChange, + handleFilterChange, + handlePanelStateChange, + handleClosePopup, + handleViewAgent, + handleStopSession, + handleStopAll, + launchDialog, + } = useAgentDashboardPage({ agentsProp }); + + // ─── Agents tab filter bar ──────────────────────────── + + function renderSessionFilterBar() { + return ( + <div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border px-4 py-3"> + {/* Session type filter */} + <Select + value={sessionFilters.sessionType} + onValueChange={(v) => + setSessionFilters((prev) => ({ + ...prev, + sessionType: v as AgentSessionType | 'all', + })) + } + > + <SelectTrigger aria-label="Filter by session type" className="w-40"> + <SelectValue placeholder="All types" /> + </SelectTrigger> + <SelectContent> + {SESSION_TYPE_OPTIONS.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + + {/* Status filter */} + <Select + value={sessionFilters.status} + onValueChange={(v) => + setSessionFilters((prev) => ({ + ...prev, + status: v as AgentStatus | 'all', + })) + } + > + <SelectTrigger aria-label="Filter by status" className="w-40"> + <SelectValue placeholder="All statuses" /> + </SelectTrigger> + <SelectContent> + {STATUS_OPTIONS.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + + {/* Team name filter */} + <Select + value={sessionFilters.teamName} + onValueChange={(v) => + setSessionFilters((prev) => ({ ...prev, teamName: v })) + } + > + <SelectTrigger aria-label="Filter by team" className="w-40"> + <SelectValue placeholder="All teams" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All teams</SelectItem> + {teamNameOptions.map((name) => ( + <SelectItem key={name} value={name}> + {name} + </SelectItem> + ))} + </SelectContent> + </Select> + + {/* Search by session name */} + <div className="relative flex-1"> + <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + <Input + aria-label="Search sessions by name" + className="pl-9" + placeholder="Search sessions..." + type="text" + value={sessionFilters.search} + onChange={(e) => + setSessionFilters((prev) => ({ ...prev, search: e.target.value })) + } + /> + </div> + </div> + ); + } + + // ─── Agents tab empty state ─────────────────────────── + + function renderAgentsContent() { + if (agents.length === 0) { + return ( + <> + {renderSessionFilterBar()} + <div className="flex flex-1 flex-col items-center justify-center"> + <Bot className="mb-4 h-12 w-12 text-muted-foreground" /> + <Text className="text-lg font-medium text-foreground">No agents running</Text> + <Text className="mt-1 text-sm text-muted-foreground"> + Start a session to see agent activity here + </Text> + </div> + </> + ); + } + + return ( + <> + {renderSessionFilterBar()} + <div className="min-h-0 flex-1 p-4"> + {agentUiState.layoutMode === 'single' ? ( + <AgentLayoutSingle + agents={filteredAgents} + className="h-full" + expandedAgentId={agentUiState.expandedAgentId} + pendingStopIds={pendingStopIds} + onPanelStateChange={handlePanelStateChange} + onStop={handleStopSession} + onViewAgent={handleViewAgent} + /> + ) : ( + <AgentLayoutGrid + agents={filteredAgents} + className="h-full" + expandedAgentId={agentUiState.expandedAgentId} + pendingStopIds={pendingStopIds} + onPanelStateChange={handlePanelStateChange} + onStop={handleStopSession} + onViewAgent={handleViewAgent} + /> + )} + </div> + {popupAgent === undefined ? null : ( + <AgentPanelPopup + agent={popupAgent} + open={agentUiState.popupAgentId !== undefined} + onClose={handleClosePopup} + onViewAgent={handleViewAgent} + /> + )} + </> + ); + } + + // ─── Render ──────────────────────────────────────────── + + return ( + <PageLayout className={className}> + <PageHeader.Tabs + value={activeMainTab} + onValueChange={(v) => setActiveMainTab(v as 'agents' | 'workflows')} + > + <PageHeader> + <PageHeader.Row> + <PageHeader.Title>Agent Dashboard</PageHeader.Title> + {activeMainTab === 'agents' ? ( + <PageHeader.Actions> + {hasRunningAgents ? ( + <Button + size="sm" + variant="destructive" + onClick={() => setIsStopAllDialogOpen(true)} + > + <Square className="mr-1.5 h-3.5 w-3.5" /> + Stop All ({runningAgents.length}) + </Button> + ) : null} + <AgentLayoutToolbar + filters={agentUiState.filters} + layoutMode={agentUiState.layoutMode} + projectOptions={projectOptions} + onFilterChange={handleFilterChange} + onLayoutChange={handleLayoutChange} + /> + </PageHeader.Actions> + ) : null} + </PageHeader.Row> + <PageHeader.TabList> + <PageHeader.Tab value="agents"> + <Bot className="h-4 w-4" /> Agents + </PageHeader.Tab> + <PageHeader.Tab value="workflows">Workflows</PageHeader.Tab> + </PageHeader.TabList> + </PageHeader> + + <PageHeader.TabContent className="flex min-h-0 flex-1 flex-col" value="agents"> + {renderAgentsContent()} + </PageHeader.TabContent> + + <PageHeader.TabContent className="min-h-0 flex-1 overflow-auto" value="workflows"> + <PageContent> + <WorkflowsTabContent /> + </PageContent> + </PageHeader.TabContent> + </PageHeader.Tabs> + + {/* Stop All confirmation dialog */} + <Dialog + open={isStopAllDialogOpen} + onOpenChange={(open) => { + if (!open) setIsStopAllDialogOpen(false); + }} + > + <DialogContent className="max-w-sm"> + <DialogHeader> + <DialogTitle>Stop all sessions?</DialogTitle> + <DialogDescription> + This will stop {runningAgents.length} running{' '} + {runningAgents.length === 1 ? 'session' : 'sessions'}. This action cannot be undone. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setIsStopAllDialogOpen(false)} + > + Cancel + </Button> + <Button + type="button" + variant="destructive" + onClick={handleStopAll} + > + Stop All + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Template editor dialog — driven by store */} + <TemplateEditorPanel /> + + {/* Launch dialog — driven by store */} + <LaunchWorkflowDialog {...launchDialog} /> + </PageLayout> + ); +} + +// ─── WorkflowsTabContent ───────────────────────────────────── + +function WorkflowsTabContent() { + return ( + <div className="grid h-full gap-4" style={{ gridTemplateColumns: '1fr 1fr' }}> + <TemplateListPanel /> + <RunningWorkflowsPanel /> + </div> + ); +} + +// ─── LaunchWorkflowDialog ──────────────────────────────────── + +interface LaunchWorkflowDialogProps { + isOpen: boolean; + templateName: string | undefined; + featureName: string; + projectPath: string; + canSubmit: boolean; + isPending: boolean; + isError: boolean; + setFeatureName: (value: string) => void; + setProjectPath: (value: string) => void; + handleClose: () => void; + handleSubmit: (e: React.SyntheticEvent<HTMLFormElement>) => void; +} + +function LaunchWorkflowDialog({ + isOpen, + templateName, + featureName, + projectPath, + canSubmit, + isPending, + isError, + setFeatureName, + setProjectPath, + handleClose, + handleSubmit, +}: LaunchWorkflowDialogProps) { + return ( + <Dialog + open={isOpen} + onOpenChange={(open) => { + if (!open) handleClose(); + }} + > + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Launch Workflow</DialogTitle> + </DialogHeader> + <Separator /> + <form className="space-y-4 py-2" id="launch-workflow-form" onSubmit={handleSubmit}> + {templateName === undefined ? null : ( + <Text size="sm" variant="muted"> + Template: <span className="font-medium text-foreground">{templateName}</span> + </Text> + )} + + <div className="space-y-1.5"> + <Label htmlFor="launch-feature-name">Feature name</Label> + <Input + required + id="launch-feature-name" + placeholder="my-feature-slug" + value={featureName} + onChange={(e) => setFeatureName(e.target.value)} + /> + </div> + + <div className="space-y-1.5"> + <Label htmlFor="launch-project-path">Project path</Label> + <Input + required + id="launch-project-path" + placeholder="/absolute/path/to/project" + value={projectPath} + onChange={(e) => setProjectPath(e.target.value)} + /> + </div> + + {isError ? ( + <Text className="text-destructive" size="sm"> + Failed to launch workflow. Please try again. + </Text> + ) : null} + </form> + <Separator /> + <DialogFooter> + <Button + disabled={isPending} + type="button" + variant="outline" + onClick={handleClose} + > + Cancel + </Button> + <Button + disabled={!canSubmit} + form="launch-workflow-form" + type="submit" + > + {isPending ? <Spinner className="mr-2" size="sm" /> : null} + Launch + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/agent-dashboard/components/AgentDashboardPage/index.ts b/src/renderer/features/agent-dashboard/components/AgentDashboardPage/index.ts new file mode 100644 index 00000000..2ca8cfdf --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentDashboardPage/index.ts @@ -0,0 +1 @@ +export { AgentDashboardPage } from './AgentDashboardPage'; diff --git a/src/renderer/features/agent-dashboard/components/AgentDashboardPage/useAgentDashboardPage.ts b/src/renderer/features/agent-dashboard/components/AgentDashboardPage/useAgentDashboardPage.ts new file mode 100644 index 00000000..7aff5fa5 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentDashboardPage/useAgentDashboardPage.ts @@ -0,0 +1,288 @@ +import { useCallback, useMemo, useState } from 'react'; + +import type { + AgentDashboardFilters, + AgentDashboardState, + AgentLayoutMode, + AgentPanelState, + AgentSession, + AgentSessionType, + AgentStatus, +} from '@shared/types/agent-dashboard'; + +import { useDebounce } from '@renderer/shared/hooks/useDebounce'; + +import { useStopSession } from '../../api/useAgentMutations'; +import { useAgentSessions } from '../../api/useAgentSessions'; +import { useApplyWorkflow } from '../../api/useWorkflowEngine'; +import { useWorkflowTemplate } from '../../api/useWorkflowTemplates'; +import { useAgentDashboardStore } from '../../store'; + +// ─── Types ────────────────────────────────────────────────── + +interface SessionFilterState { + sessionType: AgentSessionType | 'all'; + status: AgentStatus | 'all'; + teamName: string; + search: string; +} + +interface UseAgentDashboardPageParams { + agentsProp?: AgentSession[]; +} + +interface LaunchDialogState { + isOpen: boolean; + templateName: string | undefined; + featureName: string; + projectPath: string; + canSubmit: boolean; + isPending: boolean; + isError: boolean; + setFeatureName: (value: string) => void; + setProjectPath: (value: string) => void; + handleClose: () => void; + handleSubmit: (e: React.SyntheticEvent<HTMLFormElement>) => void; +} + +interface UseAgentDashboardPageReturn { + agents: AgentSession[]; + filteredAgents: AgentSession[]; + runningAgents: AgentSession[]; + hasRunningAgents: boolean; + popupAgent: AgentSession | undefined; + agentUiState: AgentDashboardState; + sessionFilters: SessionFilterState; + setSessionFilters: React.Dispatch<React.SetStateAction<SessionFilterState>>; + isStopAllDialogOpen: boolean; + setIsStopAllDialogOpen: React.Dispatch<React.SetStateAction<boolean>>; + pendingStopIds: Set<string>; + activeMainTab: 'agents' | 'workflows'; + setActiveMainTab: (tab: 'agents' | 'workflows') => void; + teamNameOptions: string[]; + handleLayoutChange: (mode: AgentLayoutMode) => void; + handleFilterChange: (filters: AgentDashboardFilters) => void; + handlePanelStateChange: (agentId: string, panelState: AgentPanelState) => void; + handleClosePopup: () => void; + handleViewAgent: (agentId: string) => void; + handleStopSession: (agentId: string) => void; + handleStopAll: () => void; + launchDialog: LaunchDialogState; +} + +export function useAgentDashboardPage({ agentsProp }: UseAgentDashboardPageParams): UseAgentDashboardPageReturn { + const { data: fetchedAgents = [] } = useAgentSessions(); + const agents = agentsProp ?? fetchedAgents; + + const [agentUiState, setAgentUiState] = useState<AgentDashboardState>({ + layoutMode: 'single', + filters: {}, + }); + + const [sessionFilters, setSessionFilters] = useState<SessionFilterState>({ + sessionType: 'all', + status: 'all', + teamName: 'all', + search: '', + }); + + const debouncedSearch = useDebounce(sessionFilters.search, 250); + + const [isStopAllDialogOpen, setIsStopAllDialogOpen] = useState(false); + const [pendingStopIds, setPendingStopIds] = useState<Set<string>>(new Set()); + + const stopSession = useStopSession(); + + const activeMainTab = useAgentDashboardStore((s) => s.activeMainTab); + const setActiveMainTab = useAgentDashboardStore((s) => s.setActiveMainTab); + + const handleLayoutChange = useCallback((mode: AgentLayoutMode) => { + setAgentUiState((prev) => ({ ...prev, layoutMode: mode })); + }, []); + + const handleFilterChange = useCallback((filters: AgentDashboardFilters) => { + setAgentUiState((prev) => ({ ...prev, filters })); + }, []); + + const handlePanelStateChange = useCallback( + (agentId: string, panelState: AgentPanelState) => { + setAgentUiState((prev) => { + switch (panelState) { + case 'expanded': + return { + ...prev, + expandedAgentId: prev.expandedAgentId === agentId ? undefined : agentId, + }; + case 'popup': + return { ...prev, popupAgentId: agentId }; + case 'compact': + return { + ...prev, + expandedAgentId: + prev.expandedAgentId === agentId ? undefined : prev.expandedAgentId, + }; + default: + return prev; + } + }); + }, + [], + ); + + const handleClosePopup = useCallback(() => { + setAgentUiState((prev) => ({ ...prev, popupAgentId: undefined })); + }, []); + + const handleViewAgent = useCallback((agentId: string) => { + setAgentUiState((prev) => ({ ...prev, popupAgentId: agentId })); + }, []); + + const handleStopSession = useCallback( + (agentId: string) => { + setPendingStopIds((prev) => new Set([...prev, agentId])); + stopSession.mutate( + { sessionId: agentId }, + { + onSettled: () => { + setPendingStopIds((prev) => { + const next = new Set(prev); + next.delete(agentId); + return next; + }); + }, + }, + ); + }, + [stopSession], + ); + + const teamNameOptions = useMemo(() => { + const names = Array.from( + new Set(agents.map((a) => a.teamName).filter((n): n is string => n !== undefined && n !== '')) + ); + return names; + }, [agents]); + + const filteredAgents = useMemo(() => { + let result = agents; + + if (agentUiState.filters.projectId !== undefined) { + result = result.filter((a) => a.projectId === agentUiState.filters.projectId); + } + if (agentUiState.filters.status !== undefined) { + result = result.filter((a) => a.status === agentUiState.filters.status); + } + + if (sessionFilters.sessionType !== 'all') { + result = result.filter((a) => a.type === sessionFilters.sessionType); + } + if (sessionFilters.status !== 'all') { + result = result.filter((a) => a.status === sessionFilters.status); + } + if (sessionFilters.teamName !== 'all') { + result = result.filter((a) => a.teamName === sessionFilters.teamName); + } + if (debouncedSearch.trim().length > 0) { + const query = debouncedSearch.trim().toLowerCase(); + result = result.filter((a) => a.name.toLowerCase().includes(query)); + } + + return result; + }, [agents, agentUiState.filters, sessionFilters, debouncedSearch]); + + const runningAgents = useMemo(() => agents.filter((a) => a.status === 'running'), [agents]); + + const hasRunningAgents = runningAgents.length > 0; + + const popupAgent = useMemo( + () => agents.find((a) => a.id === agentUiState.popupAgentId), + [agents, agentUiState.popupAgentId], + ); + + function handleStopAll() { + runningAgents.forEach((a) => { + handleStopSession(a.id); + }); + setIsStopAllDialogOpen(false); + } + + // ─── Launch workflow dialog ──────────────────────────── + + const isLaunchDialogOpen = useAgentDashboardStore((s) => s.isLaunchDialogOpen); + const launchTemplateId = useAgentDashboardStore((s) => s.launchTemplateId); + const closeLaunchDialog = useAgentDashboardStore((s) => s.closeLaunchDialog); + + const { data: launchTemplate } = useWorkflowTemplate(launchTemplateId); + const applyWorkflow = useApplyWorkflow(); + + const [launchFeatureName, setLaunchFeatureName] = useState(''); + const [launchProjectPath, setLaunchProjectPath] = useState(''); + + const handleLaunchClose = useCallback(() => { + closeLaunchDialog(); + setLaunchFeatureName(''); + setLaunchProjectPath(''); + }, [closeLaunchDialog]); + + const handleLaunchSubmit = useCallback( + (e: React.SyntheticEvent<HTMLFormElement>) => { + e.preventDefault(); + if (launchTemplateId === null) return; + + applyWorkflow.mutate( + { + templateId: launchTemplateId, + featureName: launchFeatureName, + projectPath: launchProjectPath, + overrides: {}, + }, + { onSuccess: () => handleLaunchClose() }, + ); + }, + [launchTemplateId, launchFeatureName, launchProjectPath, applyWorkflow, handleLaunchClose], + ); + + const launchCanSubmit = + launchFeatureName.trim().length > 0 && + launchProjectPath.trim().length > 0 && + !applyWorkflow.isPending; + + const launchDialog: LaunchDialogState = { + isOpen: isLaunchDialogOpen, + templateName: launchTemplate?.name, + featureName: launchFeatureName, + projectPath: launchProjectPath, + canSubmit: launchCanSubmit, + isPending: applyWorkflow.isPending, + isError: applyWorkflow.isError, + setFeatureName: setLaunchFeatureName, + setProjectPath: setLaunchProjectPath, + handleClose: handleLaunchClose, + handleSubmit: handleLaunchSubmit, + }; + + return { + agents, + filteredAgents, + runningAgents, + hasRunningAgents, + popupAgent, + agentUiState, + sessionFilters, + setSessionFilters, + isStopAllDialogOpen, + setIsStopAllDialogOpen, + pendingStopIds, + activeMainTab, + setActiveMainTab, + teamNameOptions, + handleLayoutChange, + handleFilterChange, + handlePanelStateChange, + handleClosePopup, + handleViewAgent, + handleStopSession, + handleStopAll, + launchDialog, + }; +} diff --git a/src/renderer/features/agent-dashboard/components/AgentLayoutGrid.tsx b/src/renderer/features/agent-dashboard/components/AgentLayoutGrid/AgentLayoutGrid.tsx similarity index 95% rename from src/renderer/features/agent-dashboard/components/AgentLayoutGrid.tsx rename to src/renderer/features/agent-dashboard/components/AgentLayoutGrid/AgentLayoutGrid.tsx index 41a9dcf6..8ce1f2dd 100644 --- a/src/renderer/features/agent-dashboard/components/AgentLayoutGrid.tsx +++ b/src/renderer/features/agent-dashboard/components/AgentLayoutGrid/AgentLayoutGrid.tsx @@ -11,8 +11,8 @@ import { cn } from '@renderer/shared/lib/utils'; import { ScrollArea, Text } from '@ui'; -import { AgentPanelCompact } from './AgentPanelCompact'; -import { AgentPanelExpanded } from './AgentPanelExpanded'; +import { AgentPanelCompact } from '../AgentPanelCompact'; +import { AgentPanelExpanded } from '../AgentPanelExpanded'; // ─── Props ───────────────────────────────────────────────── diff --git a/src/renderer/features/agent-dashboard/components/AgentLayoutGrid/index.ts b/src/renderer/features/agent-dashboard/components/AgentLayoutGrid/index.ts new file mode 100644 index 00000000..49ba28c1 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentLayoutGrid/index.ts @@ -0,0 +1 @@ +export { AgentLayoutGrid } from './AgentLayoutGrid'; diff --git a/src/renderer/features/agent-dashboard/components/AgentLayoutSingle.tsx b/src/renderer/features/agent-dashboard/components/AgentLayoutSingle/AgentLayoutSingle.tsx similarity index 96% rename from src/renderer/features/agent-dashboard/components/AgentLayoutSingle.tsx rename to src/renderer/features/agent-dashboard/components/AgentLayoutSingle/AgentLayoutSingle.tsx index faa24ccb..316c0af4 100644 --- a/src/renderer/features/agent-dashboard/components/AgentLayoutSingle.tsx +++ b/src/renderer/features/agent-dashboard/components/AgentLayoutSingle/AgentLayoutSingle.tsx @@ -11,8 +11,8 @@ import { cn } from '@renderer/shared/lib/utils'; import { ScrollArea, Text } from '@ui'; -import { AgentPanelCompact } from './AgentPanelCompact'; -import { AgentPanelExpanded } from './AgentPanelExpanded'; +import { AgentPanelCompact } from '../AgentPanelCompact'; +import { AgentPanelExpanded } from '../AgentPanelExpanded'; // ─── Props ───────────────────────────────────────────────── diff --git a/src/renderer/features/agent-dashboard/components/AgentLayoutSingle/index.ts b/src/renderer/features/agent-dashboard/components/AgentLayoutSingle/index.ts new file mode 100644 index 00000000..6197ec8b --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentLayoutSingle/index.ts @@ -0,0 +1 @@ +export { AgentLayoutSingle } from './AgentLayoutSingle'; diff --git a/src/renderer/features/agent-dashboard/components/AgentLayoutToolbar.tsx b/src/renderer/features/agent-dashboard/components/AgentLayoutToolbar/AgentLayoutToolbar.tsx similarity index 100% rename from src/renderer/features/agent-dashboard/components/AgentLayoutToolbar.tsx rename to src/renderer/features/agent-dashboard/components/AgentLayoutToolbar/AgentLayoutToolbar.tsx diff --git a/src/renderer/features/agent-dashboard/components/AgentLayoutToolbar/index.ts b/src/renderer/features/agent-dashboard/components/AgentLayoutToolbar/index.ts new file mode 100644 index 00000000..27bd08c8 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentLayoutToolbar/index.ts @@ -0,0 +1 @@ +export { AgentLayoutToolbar } from './AgentLayoutToolbar'; diff --git a/src/renderer/features/agent-dashboard/components/AgentPanelCompact.tsx b/src/renderer/features/agent-dashboard/components/AgentPanelCompact/AgentPanelCompact.tsx similarity index 98% rename from src/renderer/features/agent-dashboard/components/AgentPanelCompact.tsx rename to src/renderer/features/agent-dashboard/components/AgentPanelCompact/AgentPanelCompact.tsx index b9d99d14..da675606 100644 --- a/src/renderer/features/agent-dashboard/components/AgentPanelCompact.tsx +++ b/src/renderer/features/agent-dashboard/components/AgentPanelCompact/AgentPanelCompact.tsx @@ -13,7 +13,7 @@ import { cn, truncate } from '@renderer/shared/lib/utils'; import { Button, Card, CardContent, CardHeader, Spinner, Text } from '@ui'; -import { AgentStatusBar } from './AgentStatusBar'; +import { AgentStatusBar } from '../AgentStatusBar'; // ─── Props ───────────────────────────────────────────────── diff --git a/src/renderer/features/agent-dashboard/components/AgentPanelCompact/index.ts b/src/renderer/features/agent-dashboard/components/AgentPanelCompact/index.ts new file mode 100644 index 00000000..8c5296fe --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentPanelCompact/index.ts @@ -0,0 +1 @@ +export { AgentPanelCompact } from './AgentPanelCompact'; diff --git a/src/renderer/features/agent-dashboard/components/AgentPanelExpanded.tsx b/src/renderer/features/agent-dashboard/components/AgentPanelExpanded/AgentPanelExpanded.tsx similarity index 88% rename from src/renderer/features/agent-dashboard/components/AgentPanelExpanded.tsx rename to src/renderer/features/agent-dashboard/components/AgentPanelExpanded/AgentPanelExpanded.tsx index eecedb36..3d291256 100644 --- a/src/renderer/features/agent-dashboard/components/AgentPanelExpanded.tsx +++ b/src/renderer/features/agent-dashboard/components/AgentPanelExpanded/AgentPanelExpanded.tsx @@ -24,11 +24,11 @@ import { Text, } from '@ui'; -import { AgentChatPanel } from './AgentChatPanel'; -import { ErrorsTab, FilesChangedTab } from './AgentPanelTabs'; -import { AgentStatusBar } from './AgentStatusBar'; -import { QaPanel } from './QaPanel'; -import { TasksTab } from './TasksTab'; +import { AgentChatPanel } from '../AgentChatPanel'; +import { ErrorsTab, FilesChangedTab } from '../AgentPanelTabs'; +import { AgentStatusBar } from '../AgentStatusBar'; +import { QaPanel } from '../QaPanel'; +import { TasksTab } from '../TasksTab'; // ─── Props ───────────────────────────────────────────────── @@ -79,7 +79,9 @@ function TokenUsageSection({ inputTokens, outputTokens }: TokenUsageSectionProps const costLabel = formatCost(cost); return ( - <div className="border-t px-4 py-2"> + <> + <Separator /> + <div className="px-4 py-2"> <Text className="mb-1.5 font-medium" size="sm">Token Usage</Text> <div className="flex items-center gap-4"> <div className="flex items-center gap-1.5"> @@ -102,6 +104,7 @@ function TokenUsageSection({ inputTokens, outputTokens }: TokenUsageSectionProps ) : null} </div> </div> + </> ); } @@ -139,14 +142,17 @@ export function AgentPanelExpanded({ </CardHeader> {agent.taskName !== undefined && agent.taskName.length > 0 ? ( - <div className="border-t px-4 py-2"> - <Text className="text-xs text-muted-foreground"> - Task: {agent.taskName} - {agent.branch !== undefined && agent.branch.length > 0 ? ( - <span className="ml-2">Branch: {agent.branch}</span> - ) : null} - </Text> - </div> + <> + <Separator /> + <div className="px-4 py-2"> + <Text className="text-xs text-muted-foreground"> + Task: {agent.taskName} + {agent.branch !== undefined && agent.branch.length > 0 ? ( + <span className="ml-2">Branch: {agent.branch}</span> + ) : null} + </Text> + </div> + </> ) : null} <TokenUsageSection @@ -206,9 +212,12 @@ export function AgentPanelExpanded({ </Tabs> {agent.taskId === undefined ? null : ( - <div className="border-t p-3"> - <QaPanel taskId={agent.taskId} /> - </div> + <> + <Separator /> + <div className="p-3"> + <QaPanel taskId={agent.taskId} /> + </div> + </> )} </Card> ); diff --git a/src/renderer/features/agent-dashboard/components/AgentPanelExpanded/index.ts b/src/renderer/features/agent-dashboard/components/AgentPanelExpanded/index.ts new file mode 100644 index 00000000..7829348e --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentPanelExpanded/index.ts @@ -0,0 +1 @@ +export { AgentPanelExpanded } from './AgentPanelExpanded'; diff --git a/src/renderer/features/agent-dashboard/components/AgentPanelPopup.tsx b/src/renderer/features/agent-dashboard/components/AgentPanelPopup/AgentPanelPopup.tsx similarity index 96% rename from src/renderer/features/agent-dashboard/components/AgentPanelPopup.tsx rename to src/renderer/features/agent-dashboard/components/AgentPanelPopup/AgentPanelPopup.tsx index b91489b1..3becd138 100644 --- a/src/renderer/features/agent-dashboard/components/AgentPanelPopup.tsx +++ b/src/renderer/features/agent-dashboard/components/AgentPanelPopup/AgentPanelPopup.tsx @@ -36,10 +36,10 @@ import { TabsTrigger, } from '@ui'; -import { AgentChatPanel } from './AgentChatPanel'; -import { AgentStatusBar } from './AgentStatusBar'; -import { QaPanel } from './QaPanel'; -import { TasksTab } from './TasksTab'; +import { AgentChatPanel } from '../AgentChatPanel'; +import { AgentStatusBar } from '../AgentStatusBar'; +import { QaPanel } from '../QaPanel'; +import { TasksTab } from '../TasksTab'; // ─── Props ───────────────────────────────────────────────── @@ -285,9 +285,12 @@ export function AgentPanelPopup({ </Tabs> {agent.taskId === undefined ? null : ( - <div className="border-t px-6 py-4"> - <QaPanel taskId={agent.taskId} /> - </div> + <> + <Separator /> + <div className="px-6 py-4"> + <QaPanel taskId={agent.taskId} /> + </div> + </> )} </DialogContent> </Dialog> diff --git a/src/renderer/features/agent-dashboard/components/AgentPanelPopup/index.ts b/src/renderer/features/agent-dashboard/components/AgentPanelPopup/index.ts new file mode 100644 index 00000000..e82574af --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentPanelPopup/index.ts @@ -0,0 +1 @@ +export { AgentPanelPopup } from './AgentPanelPopup'; diff --git a/src/renderer/features/agent-dashboard/components/AgentPanelTabs.tsx b/src/renderer/features/agent-dashboard/components/AgentPanelTabs/AgentPanelTabs.tsx similarity index 100% rename from src/renderer/features/agent-dashboard/components/AgentPanelTabs.tsx rename to src/renderer/features/agent-dashboard/components/AgentPanelTabs/AgentPanelTabs.tsx diff --git a/src/renderer/features/agent-dashboard/components/AgentPanelTabs/index.ts b/src/renderer/features/agent-dashboard/components/AgentPanelTabs/index.ts new file mode 100644 index 00000000..5c0fe0ff --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentPanelTabs/index.ts @@ -0,0 +1 @@ +export { FilesChangedTab, ErrorsTab } from './AgentPanelTabs'; diff --git a/src/renderer/features/agent-dashboard/components/AgentStatusBar.tsx b/src/renderer/features/agent-dashboard/components/AgentStatusBar/AgentStatusBar.tsx similarity index 100% rename from src/renderer/features/agent-dashboard/components/AgentStatusBar.tsx rename to src/renderer/features/agent-dashboard/components/AgentStatusBar/AgentStatusBar.tsx diff --git a/src/renderer/features/agent-dashboard/components/AgentStatusBar/index.ts b/src/renderer/features/agent-dashboard/components/AgentStatusBar/index.ts new file mode 100644 index 00000000..4ba70333 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/AgentStatusBar/index.ts @@ -0,0 +1 @@ +export { AgentStatusBar } from './AgentStatusBar'; diff --git a/src/renderer/features/agent-dashboard/components/QaPanel.tsx b/src/renderer/features/agent-dashboard/components/QaPanel/QaPanel.tsx similarity index 96% rename from src/renderer/features/agent-dashboard/components/QaPanel.tsx rename to src/renderer/features/agent-dashboard/components/QaPanel/QaPanel.tsx index d4142632..57f6c517 100644 --- a/src/renderer/features/agent-dashboard/components/QaPanel.tsx +++ b/src/renderer/features/agent-dashboard/components/QaPanel/QaPanel.tsx @@ -18,8 +18,7 @@ import { cn } from '@renderer/shared/lib/utils'; import { Badge, Card, CardContent, ScrollArea } from '@ui'; -import { useQaSession } from '../api/useQaSession'; -import { useQaEvents } from '../hooks/useQaEvents'; +import { useQaPanel } from './useQaPanel'; // ─── Props ───────────────────────────────────────────────── @@ -109,9 +108,7 @@ function IssuesList({ issues }: { issues: QaDashboardIssue[] }) { // ─── Component ───────────────────────────────────────────── export function QaPanel({ taskId, className }: QaPanelProps) { - const { data: session, isLoading } = useQaSession(taskId); - - useQaEvents(); + const { session, isLoading } = useQaPanel({ taskId }); if (taskId === undefined) { return ( diff --git a/src/renderer/features/agent-dashboard/components/QaPanel/index.ts b/src/renderer/features/agent-dashboard/components/QaPanel/index.ts new file mode 100644 index 00000000..0188ae96 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/QaPanel/index.ts @@ -0,0 +1 @@ +export { QaPanel } from './QaPanel'; diff --git a/src/renderer/features/agent-dashboard/components/QaPanel/useQaPanel.ts b/src/renderer/features/agent-dashboard/components/QaPanel/useQaPanel.ts new file mode 100644 index 00000000..14f437df --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/QaPanel/useQaPanel.ts @@ -0,0 +1,16 @@ +import { useQaSession } from '../../api/useQaSession'; +import { useAsyncRender } from '../../hooks/useAsyncRender'; +import { useQaEvents } from '../../hooks/useQaEvents'; + +interface UseQaPanelParams { + taskId?: string; +} + +export function useQaPanel({ taskId }: UseQaPanelParams) { + const query = useQaSession(taskId); + const { data: session, isLoading, isEmpty } = useAsyncRender(query); + + useQaEvents(); + + return { session, isLoading, isEmpty }; +} diff --git a/src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel.tsx b/src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel/RunningWorkflowsPanel.tsx similarity index 95% rename from src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel.tsx rename to src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel/RunningWorkflowsPanel.tsx index a7e8db7c..c6731620 100644 --- a/src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel.tsx +++ b/src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel/RunningWorkflowsPanel.tsx @@ -24,7 +24,7 @@ import { TooltipTrigger, } from '@ui'; -import { useStopWorkflow, useWorkflowRuns } from '../api/useWorkflowEngine'; +import { useRunningWorkflowsPanel } from './useRunningWorkflowsPanel'; import type { z } from 'zod'; @@ -55,12 +55,7 @@ const TERMINAL_STATES: ReadonlySet<WorkflowState> = new Set(['DONE', 'ERROR']); // ─── Component ─────────────────────────────────────────────── export function RunningWorkflowsPanel() { - const { data: runs, isLoading, isError } = useWorkflowRuns(); - const stopWorkflow = useStopWorkflow(); - - function handleStop(runId: string) { - stopWorkflow.mutate(runId); - } + const { runs, isLoading, isError, stopWorkflow, handleStop } = useRunningWorkflowsPanel(); function renderBody() { if (isLoading) { diff --git a/src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel/index.ts b/src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel/index.ts new file mode 100644 index 00000000..ce20204f --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel/index.ts @@ -0,0 +1 @@ +export { RunningWorkflowsPanel } from './RunningWorkflowsPanel'; diff --git a/src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel/useRunningWorkflowsPanel.ts b/src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel/useRunningWorkflowsPanel.ts new file mode 100644 index 00000000..162af746 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/RunningWorkflowsPanel/useRunningWorkflowsPanel.ts @@ -0,0 +1,14 @@ +import { useStopWorkflow, useWorkflowRuns } from '../../api/useWorkflowEngine'; +import { useAsyncRender } from '../../hooks/useAsyncRender'; + +export function useRunningWorkflowsPanel() { + const query = useWorkflowRuns(); + const { data: runs, isLoading, isError, isEmpty } = useAsyncRender(query); + const stopWorkflow = useStopWorkflow(); + + function handleStop(runId: string) { + stopWorkflow.mutate(runId); + } + + return { runs, isLoading, isError, isEmpty, stopWorkflow, handleStop }; +} diff --git a/src/renderer/features/agent-dashboard/components/TasksTab.tsx b/src/renderer/features/agent-dashboard/components/TasksTab/TasksTab.tsx similarity index 94% rename from src/renderer/features/agent-dashboard/components/TasksTab.tsx rename to src/renderer/features/agent-dashboard/components/TasksTab/TasksTab.tsx index f776418e..53a63858 100644 --- a/src/renderer/features/agent-dashboard/components/TasksTab.tsx +++ b/src/renderer/features/agent-dashboard/components/TasksTab/TasksTab.tsx @@ -11,10 +11,9 @@ import type { PhaseStatus, TaskCriterion, TaskPhase } from '@shared/types/agent- import { cn } from '@renderer/shared/lib/utils'; -import { Progress, ScrollArea } from '@ui'; +import { Heading, Progress, ScrollArea } from '@ui'; -import { useTasksForFeature } from '../api/useTaskProgress'; -import { useProgressEvents } from '../hooks/useProgressEvents'; +import { useTasksTab } from './useTasksTab'; // ─── Props ───────────────────────────────────────────────── @@ -102,9 +101,7 @@ function AcceptanceCriteria({ criteria }: { criteria: TaskCriterion[] }) { export function TasksTab({ taskId, featureSlug, className }: TasksTabProps) { const slug = featureSlug ?? deriveFeatureSlug(); - const { data: tasks, isLoading } = useTasksForFeature(slug); - - useProgressEvents(); + const { tasks, isLoading } = useTasksTab({ featureSlug: slug, taskId }); if (taskId === undefined) { return ( @@ -144,7 +141,7 @@ export function TasksTab({ taskId, featureSlug, className }: TasksTabProps) { <div className="space-y-4 p-4"> {/* Task header + progress */} <div className="space-y-2"> - <h3 className="text-sm font-medium text-foreground">{task.taskName}</h3> + <Heading as="h3" className="text-sm font-medium">{task.taskName}</Heading> <div className="flex items-center gap-2"> <Progress className="flex-1" size="sm" value={progressPercent} /> <span className="shrink-0 text-xs text-muted-foreground"> diff --git a/src/renderer/features/agent-dashboard/components/TasksTab/index.ts b/src/renderer/features/agent-dashboard/components/TasksTab/index.ts new file mode 100644 index 00000000..797519d0 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/TasksTab/index.ts @@ -0,0 +1 @@ +export { TasksTab } from './TasksTab'; diff --git a/src/renderer/features/agent-dashboard/components/TasksTab/useTasksTab.ts b/src/renderer/features/agent-dashboard/components/TasksTab/useTasksTab.ts new file mode 100644 index 00000000..1c4bde52 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/TasksTab/useTasksTab.ts @@ -0,0 +1,17 @@ +import { useTasksForFeature } from '../../api/useTaskProgress'; +import { useAsyncRender } from '../../hooks/useAsyncRender'; +import { useProgressEvents } from '../../hooks/useProgressEvents'; + +interface UseTasksTabParams { + featureSlug: string; + taskId?: string; +} + +export function useTasksTab({ featureSlug }: UseTasksTabParams) { + const query = useTasksForFeature(featureSlug); + const { data: tasks, isLoading, isEmpty } = useAsyncRender(query); + + useProgressEvents(); + + return { tasks, isLoading, isEmpty }; +} diff --git a/src/renderer/features/agent-dashboard/components/TemplateEditorPanel.tsx b/src/renderer/features/agent-dashboard/components/TemplateEditorPanel/TemplateEditorPanel.tsx similarity index 80% rename from src/renderer/features/agent-dashboard/components/TemplateEditorPanel.tsx rename to src/renderer/features/agent-dashboard/components/TemplateEditorPanel/TemplateEditorPanel.tsx index 2376e48a..3eac586d 100644 --- a/src/renderer/features/agent-dashboard/components/TemplateEditorPanel.tsx +++ b/src/renderer/features/agent-dashboard/components/TemplateEditorPanel/TemplateEditorPanel.tsx @@ -6,8 +6,6 @@ * Opens in a Dialog driven by store state. */ -import { useEffect, useState } from 'react'; - import type { WorkflowTemplate } from '@shared/ipc/workflow-templates/schemas'; import { @@ -18,6 +16,7 @@ import { DialogFooter, DialogHeader, DialogTitle, + Heading, Input, Label, ScrollArea, @@ -32,109 +31,30 @@ import { Textarea, } from '@ui'; -import { useAgentDefinitions } from '../api/useWorkflowEngine'; -import { useCreateTemplate, useUpdateTemplate, useWorkflowTemplate } from '../api/useWorkflowTemplates'; -import { useAgentDashboardStore } from '../store'; +import { useTemplateEditorPanel } from './useTemplateEditorPanel'; -// ─── Default form values ───────────────────────────────────── +import type { useAgentDefinitions } from '../../api/useWorkflowEngine'; -type TemplateFormValues = Omit<WorkflowTemplate, 'id' | 'createdAt' | 'updatedAt' | 'isBuiltin'>; -const DEFAULTS: TemplateFormValues = { - name: '', - description: '', - mode: 'standard', - branching: { - featurePrefix: 'feature', - workPrefix: 'work', - useWorktrees: true, - }, - team: { - maxConcurrentAgents: 3, - spawnQaPerTask: true, - enableGuardian: true, - roles: [], - }, - qa: { - runLint: true, - runTypecheck: true, - runBuild: false, - runTests: false, - maxRounds: 2, - }, - permissions: { - allowPush: false, - allowCreatePr: false, - allowDeleteBranch: false, - allowShellExec: false, - }, - guardian: { - blockingRules: [], - warningRules: [], - maxFileSizeLines: 300, - }, - phases: [ - { name: 'brainstorming', strategy: 'skip', prompt: '', summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] } }, - { name: 'planning', strategy: 'skip', prompt: '', summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] } }, - { name: 'implementation', strategy: 'skip', prompt: '', summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] } }, - ], -}; +// ─── Types ───────────────────────────────────────────────── + +type TemplateFormValues = Omit<WorkflowTemplate, 'id' | 'createdAt' | 'updatedAt' | 'isBuiltin'>; // ─── Component ─────────────────────────────────────────────── export function TemplateEditorPanel() { - const isEditorOpen = useAgentDashboardStore((s) => s.isEditorOpen); - const editingTemplateId = useAgentDashboardStore((s) => s.editingTemplateId); - const closeEditor = useAgentDashboardStore((s) => s.closeEditor); - - const isNew = editingTemplateId === null; - const { data: existing, isLoading } = useWorkflowTemplate(editingTemplateId); - const createTemplate = useCreateTemplate(); - const updateTemplate = useUpdateTemplate(); - - const [values, setValues] = useState<TemplateFormValues>(DEFAULTS); - - // Populate form when existing template loads - useEffect(() => { - if (existing !== undefined) { - setValues({ - name: existing.name, - description: existing.description, - mode: existing.mode, - branching: { ...existing.branching }, - team: { ...existing.team, roles: [...existing.team.roles] }, - qa: { ...existing.qa }, - permissions: { ...existing.permissions }, - guardian: { - ...existing.guardian, - blockingRules: [...existing.guardian.blockingRules], - warningRules: [...existing.guardian.warningRules], - }, - phases: existing.phases.map((p) => ({ ...p, summarySpec: { ...p.summarySpec } })), - }); - } else if (isNew) { - setValues(DEFAULTS); - } - }, [existing, isNew]); - - function handleClose() { - closeEditor(); - setValues(DEFAULTS); - } - - function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) { - e.preventDefault(); - if (isNew) { - createTemplate.mutate(values, { onSuccess: () => handleClose() }); - } else { - updateTemplate.mutate( - { id: editingTemplateId, updates: values }, - { onSuccess: () => handleClose() }, - ); - } - } - - const isPending = createTemplate.isPending || updateTemplate.isPending; + const { + isEditorOpen, + isNew, + isLoading, + values, + setValues, + isPending, + agentDefs, + isLoadingDefs, + handleClose, + handleSubmit, + } = useTemplateEditorPanel(); function renderContent() { if (!isNew && isLoading) { @@ -151,7 +71,7 @@ export function TemplateEditorPanel() { <Separator /> <BranchingSection values={values} onChange={setValues} /> <Separator /> - <TeamSection values={values} onChange={setValues} /> + <TeamSection agentDefs={agentDefs} isLoadingDefs={isLoadingDefs} values={values} onChange={setValues} /> <Separator /> <QaSection values={values} onChange={setValues} /> <Separator /> @@ -200,7 +120,7 @@ interface SectionProps { } function SectionHeading({ children }: { children: React.ReactNode }) { - return <h3 className="mb-3 text-sm font-semibold text-foreground">{children}</h3>; + return <Heading as="h3" className="mb-3 text-sm">{children}</Heading>; } function FieldRow({ children, className }: { children: React.ReactNode; className?: string }) { @@ -322,9 +242,13 @@ function BranchingSection({ values, onChange }: SectionProps) { // ─── Team ──────────────────────────────────────────────────── -function TeamSection({ values, onChange }: SectionProps) { +interface TeamSectionProps extends SectionProps { + agentDefs: ReturnType<typeof useAgentDefinitions>['data']; + isLoadingDefs: boolean; +} + +function TeamSection({ values, onChange, agentDefs, isLoadingDefs }: TeamSectionProps) { const { team } = values; - const { data: agentDefs, isLoading: isLoadingDefs } = useAgentDefinitions(); function update(patch: Partial<typeof team>) { onChange({ ...values, team: { ...team, ...patch } }); diff --git a/src/renderer/features/agent-dashboard/components/TemplateEditorPanel/index.ts b/src/renderer/features/agent-dashboard/components/TemplateEditorPanel/index.ts new file mode 100644 index 00000000..475b2cce --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/TemplateEditorPanel/index.ts @@ -0,0 +1 @@ +export { TemplateEditorPanel } from './TemplateEditorPanel'; diff --git a/src/renderer/features/agent-dashboard/components/TemplateEditorPanel/useTemplateEditorPanel.ts b/src/renderer/features/agent-dashboard/components/TemplateEditorPanel/useTemplateEditorPanel.ts new file mode 100644 index 00000000..d86d6236 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/TemplateEditorPanel/useTemplateEditorPanel.ts @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react'; + +import type { WorkflowTemplate } from '@shared/ipc/workflow-templates/schemas'; + +import { useAgentDefinitions } from '../../api/useWorkflowEngine'; +import { useCreateTemplate, useUpdateTemplate, useWorkflowTemplate } from '../../api/useWorkflowTemplates'; +import { useAgentDashboardStore } from '../../store'; + +// ─── Types ─────────────────────────────────────────────────── + +type TemplateFormValues = Omit<WorkflowTemplate, 'id' | 'createdAt' | 'updatedAt' | 'isBuiltin'>; + +const DEFAULTS: TemplateFormValues = { + name: '', + description: '', + mode: 'standard', + branching: { + featurePrefix: 'feature', + workPrefix: 'work', + useWorktrees: true, + }, + team: { + maxConcurrentAgents: 3, + spawnQaPerTask: true, + enableGuardian: true, + roles: [], + }, + qa: { + runLint: true, + runTypecheck: true, + runBuild: false, + runTests: false, + maxRounds: 2, + }, + permissions: { + allowPush: false, + allowCreatePr: false, + allowDeleteBranch: false, + allowShellExec: false, + }, + guardian: { + blockingRules: [], + warningRules: [], + maxFileSizeLines: 300, + }, + phases: [ + { name: 'brainstorming', strategy: 'skip', prompt: '', summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] } }, + { name: 'planning', strategy: 'skip', prompt: '', summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] } }, + { name: 'implementation', strategy: 'skip', prompt: '', summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] } }, + ], +}; + +interface UseTemplateEditorPanelReturn { + isEditorOpen: boolean; + editingTemplateId: string | null; + isNew: boolean; + isLoading: boolean; + values: TemplateFormValues; + setValues: React.Dispatch<React.SetStateAction<TemplateFormValues>>; + isPending: boolean; + agentDefs: ReturnType<typeof useAgentDefinitions>['data']; + isLoadingDefs: boolean; + handleClose: () => void; + handleSubmit: (e: React.SyntheticEvent<HTMLFormElement>) => void; +} + +export function useTemplateEditorPanel(): UseTemplateEditorPanelReturn { + const isEditorOpen = useAgentDashboardStore((s) => s.isEditorOpen); + const editingTemplateId = useAgentDashboardStore((s) => s.editingTemplateId); + const closeEditor = useAgentDashboardStore((s) => s.closeEditor); + + const isNew = editingTemplateId === null; + const { data: existing, isLoading } = useWorkflowTemplate(editingTemplateId); + const createTemplate = useCreateTemplate(); + const updateTemplate = useUpdateTemplate(); + const { data: agentDefs, isLoading: isLoadingDefs } = useAgentDefinitions(); + + const [values, setValues] = useState<TemplateFormValues>(DEFAULTS); + + useEffect(() => { + if (existing !== undefined) { + setValues({ + name: existing.name, + description: existing.description, + mode: existing.mode, + branching: { ...existing.branching }, + team: { ...existing.team, roles: [...existing.team.roles] }, + qa: { ...existing.qa }, + permissions: { ...existing.permissions }, + guardian: { + ...existing.guardian, + blockingRules: [...existing.guardian.blockingRules], + warningRules: [...existing.guardian.warningRules], + }, + phases: existing.phases.map((p) => ({ ...p, summarySpec: { ...p.summarySpec } })), + }); + } else if (isNew) { + setValues(DEFAULTS); + } + }, [existing, isNew]); + + function handleClose() { + closeEditor(); + setValues(DEFAULTS); + } + + function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) { + e.preventDefault(); + if (isNew) { + createTemplate.mutate(values, { onSuccess: () => handleClose() }); + } else { + updateTemplate.mutate( + { id: editingTemplateId, updates: values }, + { onSuccess: () => handleClose() }, + ); + } + } + + const isPending = createTemplate.isPending || updateTemplate.isPending; + + return { + isEditorOpen, + editingTemplateId, + isNew, + isLoading, + values, + setValues, + isPending, + agentDefs, + isLoadingDefs, + handleClose, + handleSubmit, + }; +} diff --git a/src/renderer/features/agent-dashboard/components/TemplateListPanel.tsx b/src/renderer/features/agent-dashboard/components/TemplateListPanel/TemplateListPanel.tsx similarity index 86% rename from src/renderer/features/agent-dashboard/components/TemplateListPanel.tsx rename to src/renderer/features/agent-dashboard/components/TemplateListPanel/TemplateListPanel.tsx index e4880b54..00a7cb69 100644 --- a/src/renderer/features/agent-dashboard/components/TemplateListPanel.tsx +++ b/src/renderer/features/agent-dashboard/components/TemplateListPanel/TemplateListPanel.tsx @@ -28,12 +28,7 @@ import { TooltipTrigger, } from '@ui'; -import { - useDeleteTemplate, - useDuplicateTemplate, - useWorkflowTemplates, -} from '../api/useWorkflowTemplates'; -import { useAgentDashboardStore } from '../store'; +import { useTemplateListPanel } from './useTemplateListPanel'; // ─── Mode badge variant map ────────────────────────────────── @@ -56,41 +51,21 @@ const MODE_LABEL: Record<WorkflowTemplate['mode'], string> = { // ─── Component ─────────────────────────────────────────────── export function TemplateListPanel() { - const { data: templates, isLoading, isError } = useWorkflowTemplates(); - const deleteTemplate = useDeleteTemplate(); - const duplicateTemplate = useDuplicateTemplate(); - - const selectedTemplateId = useAgentDashboardStore((s) => s.selectedTemplateId); - const setSelectedTemplateId = useAgentDashboardStore((s) => s.setSelectedTemplateId); - const openEditor = useAgentDashboardStore((s) => s.openEditor); - const openLaunchDialog = useAgentDashboardStore((s) => s.openLaunchDialog); - - function handleSelect(id: string) { - setSelectedTemplateId(selectedTemplateId === id ? null : id); - } - - function handleEdit(id: string) { - openEditor(id); - } - - function handleDuplicate(id: string) { - duplicateTemplate.mutate({ id }); - } - - function handleDelete(id: string) { - deleteTemplate.mutate(id); - } - - function handleLaunch(id: string) { - openLaunchDialog(id); - } - - function handleKeyDown(event: React.KeyboardEvent, id: string) { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleSelect(id); - } - } + const { + templates, + isLoading, + isError, + deleteTemplate, + duplicateTemplate, + selectedTemplateId, + handleSelect, + handleEdit, + handleDuplicate, + handleDelete, + handleLaunch, + handleKeyDown, + openEditor, + } = useTemplateListPanel(); function renderBody() { if (isLoading) { diff --git a/src/renderer/features/agent-dashboard/components/TemplateListPanel/index.ts b/src/renderer/features/agent-dashboard/components/TemplateListPanel/index.ts new file mode 100644 index 00000000..574c1eb1 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/TemplateListPanel/index.ts @@ -0,0 +1 @@ +export { TemplateListPanel } from './TemplateListPanel'; diff --git a/src/renderer/features/agent-dashboard/components/TemplateListPanel/useTemplateListPanel.ts b/src/renderer/features/agent-dashboard/components/TemplateListPanel/useTemplateListPanel.ts new file mode 100644 index 00000000..fbc2c902 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/TemplateListPanel/useTemplateListPanel.ts @@ -0,0 +1,63 @@ +import { + useDeleteTemplate, + useDuplicateTemplate, + useWorkflowTemplates, +} from '../../api/useWorkflowTemplates'; +import { useAsyncRender } from '../../hooks/useAsyncRender'; +import { useAgentDashboardStore } from '../../store'; + +export function useTemplateListPanel() { + const query = useWorkflowTemplates(); + const { data: templates, isLoading, isError, isEmpty } = useAsyncRender(query); + const deleteTemplate = useDeleteTemplate(); + const duplicateTemplate = useDuplicateTemplate(); + + const selectedTemplateId = useAgentDashboardStore((s) => s.selectedTemplateId); + const setSelectedTemplateId = useAgentDashboardStore((s) => s.setSelectedTemplateId); + const openEditor = useAgentDashboardStore((s) => s.openEditor); + const openLaunchDialog = useAgentDashboardStore((s) => s.openLaunchDialog); + + function handleSelect(id: string) { + setSelectedTemplateId(selectedTemplateId === id ? null : id); + } + + function handleEdit(id: string) { + openEditor(id); + } + + function handleDuplicate(id: string) { + duplicateTemplate.mutate({ id }); + } + + function handleDelete(id: string) { + deleteTemplate.mutate(id); + } + + function handleLaunch(id: string) { + openLaunchDialog(id); + } + + function handleKeyDown(event: React.KeyboardEvent, id: string) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleSelect(id); + } + } + + return { + templates, + isLoading, + isError, + isEmpty, + deleteTemplate, + duplicateTemplate, + selectedTemplateId, + handleSelect, + handleEdit, + handleDuplicate, + handleDelete, + handleLaunch, + handleKeyDown, + openEditor, + }; +} diff --git a/src/renderer/features/agent-dashboard/components/TextMessage.tsx b/src/renderer/features/agent-dashboard/components/TextMessage/TextMessage.tsx similarity index 75% rename from src/renderer/features/agent-dashboard/components/TextMessage.tsx rename to src/renderer/features/agent-dashboard/components/TextMessage/TextMessage.tsx index c4bdee71..744910c9 100644 --- a/src/renderer/features/agent-dashboard/components/TextMessage.tsx +++ b/src/renderer/features/agent-dashboard/components/TextMessage/TextMessage.tsx @@ -10,22 +10,20 @@ * Markdown element styles are applied via the .markdown-body CSS class. */ -import { useRef, useEffect, useMemo } from 'react'; - import { Send } from 'lucide-react'; import Markdown from 'react-markdown'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import remarkGfm from 'remark-gfm'; import { WORKSPACE } from '@shared/ipc/workspace/channels'; import type { AgentTextMessage } from '@shared/types/agent-dashboard'; import { ipc } from '@renderer/shared/lib/ipc'; import { cn } from '@renderer/shared/lib/utils'; -import { useLayoutStore } from '@renderer/shared/stores'; -import { Button } from '@ui/button'; +import { Button, Separator } from '@ui'; + +import { useTextMessage } from './useTextMessage'; // ─── Props ───────────────────────────────────────────────── @@ -36,38 +34,6 @@ interface TextMessageProps { showHandOff?: boolean; } -// ─── Plan File Detection ───────────────────────────────── - -/** - * Extract a plan file path from message content. - * Looks for common plan path patterns in markdown (backtick-wrapped or bare). - */ -function extractPlanPath(text: string): string | null { - // Match backtick-wrapped paths containing 'plan' and ending in .md - const backtickMatch = /`([^`]*plan[^`]*\.md)`/i.exec(text); - if (backtickMatch) return backtickMatch[1]; - - // Match bare paths like docs/features/X/plan.md - const bareMatch = /(?:^|\s)((?:[\w./-]+\/)?[\w.-]*plan[\w.-]*\.md)/im.exec(text); - if (bareMatch) return bareMatch[1]; - - return null; -} - -// ─── Structured Content Detection ────────────────────────── - -const LONG_MESSAGE_CHARS = 300; - -function isStructuredContent(text: string): boolean { - if (/\|.+\|/.test(text) && text.includes('---')) return true; - if (/^#{1,4}\s/m.test(text)) return true; - if (/```[\s\S]*?```/.test(text)) return true; - const listMatches = text.match(/^[\s]*[-*+]\s|^\s*\d+\.\s/gm); - if (listMatches && listMatches.length >= 3) return true; - if (text.length > LONG_MESSAGE_CHARS) return true; - return false; -} - // ─── Code Block Renderer ─────────────────────────────────── function CodeBlock({ @@ -177,23 +143,14 @@ const markdownComponents: React.ComponentProps<typeof Markdown>['components'] = // ─── Component ───────────────────────────────────────────── export function TextMessage({ message, className, showHandOff = true }: TextMessageProps) { - const endRef = useRef<HTMLDivElement>(null); - const plugins = useMemo(() => [remarkGfm], []); - const structured = isStructuredContent(message.content); - const activeProjectId = useLayoutStore((s) => s.activeProjectId); - - // Detect plan file path in assistant messages (not user, not streaming) - const planPath = - message.role === 'assistant' && message.isStreaming !== true - ? extractPlanPath(message.content) - : null; - const shouldShowHandOff = showHandOff && planPath !== null && activeProjectId !== null; - - useEffect(() => { - if (message.isStreaming === true) { - endRef.current?.scrollIntoView({ behavior: 'smooth' }); - } - }, [message.content, message.isStreaming]); + const { + endRef, + plugins, + structured, + activeProjectId, + planPath, + shouldShowHandOff, + } = useTextMessage({ message, showHandOff }); const markdownContent = ( <Markdown components={markdownComponents} remarkPlugins={plugins}> @@ -207,8 +164,9 @@ export function TextMessage({ message, className, showHandOff = true }: TextMess </div> ); - const handOffButton = shouldShowHandOff ? ( - <div className="border-border/30 mt-2 border-t pt-2"> + const handOffButton = (shouldShowHandOff && activeProjectId !== null && planPath !== null) ? ( + <div className="mt-2"> + <Separator className="mb-2 opacity-30" /> <Button className="h-7 gap-1.5 text-xs" size="sm" diff --git a/src/renderer/features/agent-dashboard/components/TextMessage/index.ts b/src/renderer/features/agent-dashboard/components/TextMessage/index.ts new file mode 100644 index 00000000..c60a0dcf --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/TextMessage/index.ts @@ -0,0 +1 @@ +export { TextMessage } from './TextMessage'; diff --git a/src/renderer/features/agent-dashboard/components/TextMessage/useTextMessage.ts b/src/renderer/features/agent-dashboard/components/TextMessage/useTextMessage.ts new file mode 100644 index 00000000..4cf24949 --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/TextMessage/useTextMessage.ts @@ -0,0 +1,66 @@ +import { useEffect, useMemo, useRef } from 'react'; + +import remarkGfm from 'remark-gfm'; + +import type { AgentTextMessage } from '@shared/types/agent-dashboard'; + +import { useLayoutStore } from '@renderer/shared/stores'; + +interface UseTextMessageParams { + message: AgentTextMessage; + showHandOff?: boolean; +} + +type RemarkPlugins = Array<typeof remarkGfm>; + +interface UseTextMessageReturn { + endRef: React.RefObject<HTMLDivElement | null>; + plugins: RemarkPlugins; + structured: boolean; + activeProjectId: string | null; + planPath: string | null; + shouldShowHandOff: boolean; +} + +// ─── Helpers ──────────────────────────────────────────────── + +const LONG_MESSAGE_CHARS = 300; + +function isStructuredContent(text: string): boolean { + if (/\|.+\|/.test(text) && text.includes('---')) return true; + if (/^#{1,4}\s/m.test(text)) return true; + if (/```[\s\S]*?```/.test(text)) return true; + const listMatches = text.match(/^[\s]*[-*+]\s|^\s*\d+\.\s/gm); + if (listMatches && listMatches.length >= 3) return true; + if (text.length > LONG_MESSAGE_CHARS) return true; + return false; +} + +function extractPlanPath(text: string): string | null { + const backtickMatch = /`([^`]*plan[^`]*\.md)`/i.exec(text); + if (backtickMatch) return backtickMatch[1]; + const bareMatch = /(?:^|\s)((?:[\w./-]+\/)?[\w.-]*plan[\w.-]*\.md)/im.exec(text); + if (bareMatch) return bareMatch[1]; + return null; +} + +export function useTextMessage({ message, showHandOff = true }: UseTextMessageParams): UseTextMessageReturn { + const endRef = useRef<HTMLDivElement>(null); + const plugins: RemarkPlugins = useMemo(() => [remarkGfm], []); + const structured = isStructuredContent(message.content); + const activeProjectId = useLayoutStore((s) => s.activeProjectId); + + const planPath = + message.role === 'assistant' && message.isStreaming !== true + ? extractPlanPath(message.content) + : null; + const shouldShowHandOff = showHandOff && planPath !== null && activeProjectId !== null; + + useEffect(() => { + if (message.isStreaming === true) { + endRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [message.content, message.isStreaming]); + + return { endRef, plugins, structured, activeProjectId, planPath, shouldShowHandOff }; +} diff --git a/src/renderer/features/agent-dashboard/components/ToolCallCard.tsx b/src/renderer/features/agent-dashboard/components/ToolCallCard/ToolCallCard.tsx similarity index 100% rename from src/renderer/features/agent-dashboard/components/ToolCallCard.tsx rename to src/renderer/features/agent-dashboard/components/ToolCallCard/ToolCallCard.tsx diff --git a/src/renderer/features/agent-dashboard/components/ToolCallCard/index.ts b/src/renderer/features/agent-dashboard/components/ToolCallCard/index.ts new file mode 100644 index 00000000..0b570c3f --- /dev/null +++ b/src/renderer/features/agent-dashboard/components/ToolCallCard/index.ts @@ -0,0 +1 @@ +export { ToolCallCard } from './ToolCallCard'; diff --git a/src/renderer/features/agent-dashboard/hooks/useAgentEvents.ts b/src/renderer/features/agent-dashboard/hooks/useAgentEvents.ts index 6ffef12e..78c0386e 100644 --- a/src/renderer/features/agent-dashboard/hooks/useAgentEvents.ts +++ b/src/renderer/features/agent-dashboard/hooks/useAgentEvents.ts @@ -16,58 +16,38 @@ import { useIpcEvent } from '@renderer/shared/hooks'; import { agentDashboardKeys } from '../api/queryKeys'; -export function useAgentDashboardEvents() { - const queryClient = useQueryClient(); +import { rule, useQueryInvalidator } from './useQueryInvalidator'; - // ── Session lifecycle ── - - /** New session spawned or detected -> refresh session list */ - useIpcEvent(AGENT_DASHBOARD_EVENTS.SESSION.STARTED, (session) => { - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.sessions(), - }); - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.session(session.id), - }); - }); +const INVALIDATION_RULES = [ + rule({ + channel: AGENT_DASHBOARD_EVENTS.SESSION.STARTED, + queryKeys: (session) => [agentDashboardKeys.sessions(), agentDashboardKeys.session(session.id)], + }), + rule({ + channel: AGENT_DASHBOARD_EVENTS.SESSION.ENDED, + queryKeys: (p) => [agentDashboardKeys.sessions(), agentDashboardKeys.session(p.sessionId)], + }), + rule({ + channel: AGENT_DASHBOARD_EVENTS.SESSION['STATUS-CHANGED'], + queryKeys: (p) => [agentDashboardKeys.sessions(), agentDashboardKeys.session(p.sessionId)], + }), + rule({ + channel: AGENT_DASHBOARD_EVENTS.TEAMMATE.JOINED, + queryKeys: () => [agentDashboardKeys.sessions()], + }), + rule({ + channel: AGENT_DASHBOARD_EVENTS.TEAMMATE.LEFT, + queryKeys: () => [agentDashboardKeys.sessions()], + }), +]; - /** Session ended -> refresh session list and detail */ - useIpcEvent(AGENT_DASHBOARD_EVENTS.SESSION.ENDED, (payload) => { - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.sessions(), - }); - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.session(payload.sessionId), - }); - }); - - /** Session status changed -> refresh that session's detail */ - useIpcEvent(AGENT_DASHBOARD_EVENTS.SESSION['STATUS-CHANGED'], (payload) => { - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.sessions(), - }); - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.session(payload.sessionId), - }); - }); - - // ── Team membership ── - - /** Teammate joined -> refresh session list */ - useIpcEvent(AGENT_DASHBOARD_EVENTS.TEAMMATE.JOINED, (_member) => { - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.sessions(), - }); - }); +export function useAgentDashboardEvents() { + const queryClient = useQueryClient(); - /** Teammate left -> refresh session list */ - useIpcEvent(AGENT_DASHBOARD_EVENTS.TEAMMATE.LEFT, (_payload) => { - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.sessions(), - }); - }); + // ── Declarative invalidation rules ── + useQueryInvalidator(INVALIDATION_RULES); - // ── Messages ── + // ── Messages (uses setQueryData, not invalidation) ── /** New message received -> append to that session's message cache */ useIpcEvent(AGENT_DASHBOARD_EVENTS.MESSAGE.RECEIVED, (message) => { diff --git a/src/renderer/features/agent-dashboard/hooks/useAsyncRender.ts b/src/renderer/features/agent-dashboard/hooks/useAsyncRender.ts new file mode 100644 index 00000000..9aa3659a --- /dev/null +++ b/src/renderer/features/agent-dashboard/hooks/useAsyncRender.ts @@ -0,0 +1,29 @@ +/** + * useAsyncRender — Unified loading/error/empty state derivation. + * + * Extracts the repetitive isLoading/isError/isEmpty branching found in + * component hooks that consume React Query results. Returns a flat + * object the component can destructure for conditional rendering. + */ + +interface AsyncQuery<T> { + isLoading: boolean; + isError: boolean; + data: T | undefined; +} + +interface AsyncRenderState<T> { + isLoading: boolean; + isError: boolean; + isEmpty: boolean; + data: T | undefined; +} + +export function useAsyncRender<T>(query: AsyncQuery<T>): AsyncRenderState<T> { + return { + isLoading: query.isLoading, + isError: query.isError, + isEmpty: query.data === undefined || query.data === null || (Array.isArray(query.data) && query.data.length === 0), + data: query.data, + }; +} diff --git a/src/renderer/features/agent-dashboard/hooks/useProgressEvents.ts b/src/renderer/features/agent-dashboard/hooks/useProgressEvents.ts index 041605fc..b31696eb 100644 --- a/src/renderer/features/agent-dashboard/hooks/useProgressEvents.ts +++ b/src/renderer/features/agent-dashboard/hooks/useProgressEvents.ts @@ -5,24 +5,23 @@ * invalidates the relevant task queries in React Query cache. */ -import { useQueryClient } from '@tanstack/react-query'; - import { AGENT_DASHBOARD_EVENTS } from '@shared/ipc/agent-dashboard/channels'; -import { useIpcEvent } from '@renderer/shared/hooks'; - import { agentDashboardKeys } from '../api/queryKeys'; +import { rule, useQueryInvalidator } from './useQueryInvalidator'; + +const INVALIDATION_RULES = [ + rule({ + channel: AGENT_DASHBOARD_EVENTS.TASK.UPDATED, + queryKeys: (event) => [ + agentDashboardKeys.tasks(event.featureSlug), + agentDashboardKeys.task(event.featureSlug, event.task.taskNumber), + ], + }), +]; + /** Subscribe to task update events and invalidate task queries */ export function useProgressEvents() { - const queryClient = useQueryClient(); - - useIpcEvent(AGENT_DASHBOARD_EVENTS.TASK.UPDATED, (event) => { - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.tasks(event.featureSlug), - }); - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.task(event.featureSlug, event.task.taskNumber), - }); - }); + useQueryInvalidator(INVALIDATION_RULES); } diff --git a/src/renderer/features/agent-dashboard/hooks/useQaEvents.ts b/src/renderer/features/agent-dashboard/hooks/useQaEvents.ts index 8b6e0961..8c0e55ff 100644 --- a/src/renderer/features/agent-dashboard/hooks/useQaEvents.ts +++ b/src/renderer/features/agent-dashboard/hooks/useQaEvents.ts @@ -5,24 +5,23 @@ * invalidates the relevant QA queries in React Query cache. */ -import { useQueryClient } from '@tanstack/react-query'; - import { AGENT_DASHBOARD_EVENTS } from '@shared/ipc/agent-dashboard/channels'; -import { useIpcEvent } from '@renderer/shared/hooks'; - import { agentDashboardKeys } from '../api/queryKeys'; +import { rule, useQueryInvalidator } from './useQueryInvalidator'; + +const INVALIDATION_RULES = [ + rule({ + channel: AGENT_DASHBOARD_EVENTS.QA['SESSION-UPDATED'], + queryKeys: (session) => [ + agentDashboardKeys.qaSession(session.taskId), + agentDashboardKeys.qaSessions(), + ], + }), +]; + /** Subscribe to QA session update events and invalidate QA queries */ export function useQaEvents() { - const queryClient = useQueryClient(); - - useIpcEvent(AGENT_DASHBOARD_EVENTS.QA['SESSION-UPDATED'], (session) => { - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.qaSession(session.taskId), - }); - void queryClient.invalidateQueries({ - queryKey: agentDashboardKeys.qaSessions(), - }); - }); + useQueryInvalidator(INVALIDATION_RULES); } diff --git a/src/renderer/features/agent-dashboard/hooks/useQueryInvalidator.ts b/src/renderer/features/agent-dashboard/hooks/useQueryInvalidator.ts new file mode 100644 index 00000000..2890fdb3 --- /dev/null +++ b/src/renderer/features/agent-dashboard/hooks/useQueryInvalidator.ts @@ -0,0 +1,47 @@ +/** + * useQueryInvalidator — Declarative event-to-invalidation wiring. + * + * Replaces repetitive useIpcEvent + queryClient.invalidateQueries patterns + * with a data-driven rules array. Each rule maps an IPC event channel to + * one or more query keys that should be invalidated when the event fires. + */ + +import { useQueryClient } from '@tanstack/react-query'; + +import type { EventChannel, EventPayload } from '@shared/ipc-contract'; + +import { useIpcEvent } from '@renderer/shared/hooks'; + +export interface InvalidationRule<T extends EventChannel = EventChannel> { + channel: T; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- readonly tuples from query key factories + queryKeys: (payload: EventPayload<T>) => ReadonlyArray<readonly any[]>; +} + +/** Helper to create a typed rule — infers the channel's payload type. */ +export function rule<T extends EventChannel>(r: InvalidationRule<T>): InvalidationRule<T> { + return r; +} + +/** + * Subscribe to IPC events and invalidate the specified query keys. + * + * @example + * useQueryInvalidator([ + * rule({ channel: EVENTS.SESSION.STARTED, queryKeys: (s) => [keys.sessions(), keys.session(s.id)] }), + * rule({ channel: EVENTS.TASK.UPDATED, queryKeys: (e) => [keys.tasks(e.featureSlug)] }), + * ]); + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous rule array +export function useQueryInvalidator(rules: Array<InvalidationRule<any>>) { + const queryClient = useQueryClient(); + + for (const rule of rules) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- rules array is static per call-site + useIpcEvent(rule.channel, (payload) => { + for (const key of rule.queryKeys(payload)) { + void queryClient.invalidateQueries({ queryKey: key as unknown[] }); + } + }); + } +} diff --git a/src/renderer/features/agents/components/AgentDashboard.tsx b/src/renderer/features/agents/components/AgentDashboard/AgentDashboard.tsx similarity index 85% rename from src/renderer/features/agents/components/AgentDashboard.tsx rename to src/renderer/features/agents/components/AgentDashboard/AgentDashboard.tsx index d42849fe..cd60ef70 100644 --- a/src/renderer/features/agents/components/AgentDashboard.tsx +++ b/src/renderer/features/agents/components/AgentDashboard/AgentDashboard.tsx @@ -8,8 +8,7 @@ import { cn, formatRelativeTime } from '@renderer/shared/lib/utils'; import { Button, EmptyState, PageContent, PageHeader, PageLayout } from '@ui'; -import { useAllAgents, useStopAgent } from '../api/useAgents'; -import { useAgentEvents } from '../hooks/useAgentEvents'; +import { useAgentDashboard } from './useAgentDashboard'; const statusColors: Record<string, string> = { running: 'text-amber-400', @@ -28,10 +27,7 @@ const statusLabels: Record<string, string> = { }; export function AgentDashboard() { - const { data: sessions, isLoading } = useAllAgents(); - const stopAgent = useStopAgent(); - - useAgentEvents(); + const { sessions, isLoading, stopAgent, projectNameMap } = useAgentDashboard(); if (isLoading) { return ( @@ -54,7 +50,11 @@ export function AgentDashboard() { <PageContent> {sessionList.length > 0 ? ( <div className="space-y-3"> - {sessionList.map((session) => ( + {sessionList.map((session) => { + const projectName = session.projectId + ? (projectNameMap.get(session.projectId) ?? 'Unknown Project') + : null; + return ( <div key={session.id} className="border-border flex items-center justify-between rounded-lg border p-4" @@ -63,9 +63,11 @@ export function AgentDashboard() { <Bot className={cn('h-5 w-5', statusColors[session.status] ?? 'text-zinc-400')} /> <div> <p className="text-sm font-medium"> - {session.name} — {session.type} + {projectName ?? session.name} </p> <p className="text-muted-foreground text-xs"> + {session.type} + {' · '} {statusLabels[session.status] ?? session.status} {session.model ? ` · ${session.model}` : ''} </p> @@ -91,7 +93,8 @@ export function AgentDashboard() { ) : null} </div> </div> - ))} + ); + })} </div> ) : ( <EmptyState diff --git a/src/renderer/features/agents/components/AgentDashboard/index.ts b/src/renderer/features/agents/components/AgentDashboard/index.ts new file mode 100644 index 00000000..721eb434 --- /dev/null +++ b/src/renderer/features/agents/components/AgentDashboard/index.ts @@ -0,0 +1 @@ +export { AgentDashboard } from './AgentDashboard'; diff --git a/src/renderer/features/agents/components/AgentDashboard/useAgentDashboard.ts b/src/renderer/features/agents/components/AgentDashboard/useAgentDashboard.ts new file mode 100644 index 00000000..f8a37568 --- /dev/null +++ b/src/renderer/features/agents/components/AgentDashboard/useAgentDashboard.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; + +import { useProjects } from '@features/projects/api/useProjects'; + +import { useAllAgents, useStopAgent } from '../../api/useAgents'; +import { useAgentEvents } from '../../hooks/useAgentEvents'; + +interface UseAgentDashboardReturn { + sessions: ReturnType<typeof useAllAgents>['data']; + isLoading: boolean; + stopAgent: ReturnType<typeof useStopAgent>; + projectNameMap: Map<string, string>; +} + +export function useAgentDashboard(): UseAgentDashboardReturn { + const { data: sessions, isLoading } = useAllAgents(); + const stopAgent = useStopAgent(); + const { data: projects } = useProjects(); + + useAgentEvents(); + + const projectNameMap = useMemo(() => { + const map = new Map<string, string>(); + if (projects) { + for (const p of projects) { + map.set(p.id, p.name); + } + } + return map; + }, [projects]); + + return { sessions, isLoading, stopAgent, projectNameMap }; +} diff --git a/src/renderer/features/personal/alerts/api/queryKeys.ts b/src/renderer/features/alerts/api/queryKeys.ts similarity index 100% rename from src/renderer/features/personal/alerts/api/queryKeys.ts rename to src/renderer/features/alerts/api/queryKeys.ts diff --git a/src/renderer/features/personal/alerts/api/useAlertMutations.ts b/src/renderer/features/alerts/api/useAlertMutations.ts similarity index 80% rename from src/renderer/features/personal/alerts/api/useAlertMutations.ts rename to src/renderer/features/alerts/api/useAlertMutations.ts index e512d852..1f3b8662 100644 --- a/src/renderer/features/personal/alerts/api/useAlertMutations.ts +++ b/src/renderer/features/alerts/api/useAlertMutations.ts @@ -4,8 +4,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { ALERTS } from '@shared/ipc/misc/alerts.channels'; -import type { UpdateAlertInput } from '@shared/ipc/misc/alerts.contract'; +import { ALERTS } from '@shared/ipc/alerts'; +import type { UpdateAlertInput } from '@shared/ipc/alerts'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/personal/alerts/api/useAlerts.ts b/src/renderer/features/alerts/api/useAlerts.ts similarity index 96% rename from src/renderer/features/personal/alerts/api/useAlerts.ts rename to src/renderer/features/alerts/api/useAlerts.ts index c0896378..df6e22b5 100644 --- a/src/renderer/features/personal/alerts/api/useAlerts.ts +++ b/src/renderer/features/alerts/api/useAlerts.ts @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { ALERTS } from '@shared/ipc/misc/alerts.channels'; +import { ALERTS } from '@shared/ipc/alerts'; import type { RecurringConfig, AlertLinkedTo } from '@shared/types'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/alerts/components/AlertEditDialog/AlertEditDialog.tsx b/src/renderer/features/alerts/components/AlertEditDialog/AlertEditDialog.tsx new file mode 100644 index 00000000..401cda54 --- /dev/null +++ b/src/renderer/features/alerts/components/AlertEditDialog/AlertEditDialog.tsx @@ -0,0 +1,169 @@ +/** + * AlertEditDialog — Edit alert message, triggerAt, recurring config, and linkedTo + */ + +import { Link } from 'lucide-react'; + +import type { Alert } from '@shared/types'; + +import { + Badge, + Button, + Checkbox, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@ui'; + +import { useAlertEditDialog } from './useAlertEditDialog'; + +interface AlertEditDialogProps { + alert: Alert | null; + onClose: () => void; +} + +export function AlertEditDialog({ alert, onClose }: AlertEditDialogProps) { + const { + isOpen, + message, + setMessage, + triggerAt, + setTriggerAt, + enableRecurring, + setEnableRecurring, + recurringFrequency, + setRecurringFrequency, + recurringTime, + setRecurringTime, + handleSubmit, + handleOpenChange, + isPending, + linkedTo, + } = useAlertEditDialog({ alert, onClose }); + + return ( + <Dialog open={isOpen} onOpenChange={handleOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Edit Alert</DialogTitle> + </DialogHeader> + + <div className="space-y-4 py-2"> + {linkedTo === undefined ? null : ( + <div className="flex items-center gap-2"> + <span className="text-muted-foreground text-xs">Linked to:</span> + <Badge variant="secondary"> + {linkedTo.type}: {linkedTo.id} + </Badge> + </div> + )} + + <div className="space-y-1.5"> + <Label htmlFor="edit-alert-message">Message</Label> + <Input + id="edit-alert-message" + placeholder="Alert message" + type="text" + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </div> + + <div className="space-y-1.5"> + <Label htmlFor="edit-alert-trigger-at">Trigger At</Label> + <Input + id="edit-alert-trigger-at" + type="datetime-local" + value={triggerAt} + onChange={(e) => setTriggerAt(e.target.value)} + /> + </div> + + <div className="space-y-3"> + <Label + className="flex cursor-pointer items-center gap-2" + htmlFor="edit-alert-recurring-toggle" + > + <Checkbox + checked={enableRecurring} + id="edit-alert-recurring-toggle" + onCheckedChange={(checked) => setEnableRecurring(checked === true)} + /> + <span>Recurring</span> + </Label> + + {enableRecurring ? ( + <div className="space-y-3 pl-6"> + <div className="space-y-1.5"> + <Label htmlFor="edit-alert-frequency">Frequency</Label> + <Select + value={recurringFrequency} + onValueChange={(value) => + setRecurringFrequency(value as 'daily' | 'weekly' | 'monthly') + } + > + <SelectTrigger id="edit-alert-frequency"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="daily">Daily</SelectItem> + <SelectItem value="weekly">Weekly</SelectItem> + <SelectItem value="monthly">Monthly</SelectItem> + </SelectContent> + </Select> + </div> + + <div className="space-y-1.5"> + <Label htmlFor="edit-alert-time">Time</Label> + <Input + id="edit-alert-time" + type="time" + value={recurringTime} + onChange={(e) => setRecurringTime(e.target.value)} + /> + </div> + </div> + ) : null} + </div> + </div> + + <DialogFooter> + <Button variant="ghost" onClick={onClose}> + Cancel + </Button> + <Button + disabled={message.trim().length === 0 || isPending} + onClick={handleSubmit} + > + {isPending ? 'Saving...' : 'Save Changes'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +/** Inline badge to display linkedTo on alert cards */ +interface LinkedToBadgeProps { + linkedTo: Alert['linkedTo']; +} + +export function LinkedToBadge({ linkedTo }: LinkedToBadgeProps) { + if (linkedTo === undefined) return null; + + return ( + <Badge className="mt-1 text-xs" variant="outline"> + <Link className="mr-1 h-2.5 w-2.5" /> + {linkedTo.type} + </Badge> + ); +} diff --git a/src/renderer/features/alerts/components/AlertEditDialog/index.ts b/src/renderer/features/alerts/components/AlertEditDialog/index.ts new file mode 100644 index 00000000..3c38f327 --- /dev/null +++ b/src/renderer/features/alerts/components/AlertEditDialog/index.ts @@ -0,0 +1 @@ +export { AlertEditDialog, LinkedToBadge } from './AlertEditDialog'; diff --git a/src/renderer/features/alerts/components/AlertEditDialog/useAlertEditDialog.ts b/src/renderer/features/alerts/components/AlertEditDialog/useAlertEditDialog.ts new file mode 100644 index 00000000..11b08502 --- /dev/null +++ b/src/renderer/features/alerts/components/AlertEditDialog/useAlertEditDialog.ts @@ -0,0 +1,113 @@ +/** + * useAlertEditDialog — all logic for AlertEditDialog + */ + +import type { Alert, RecurringConfig } from '@shared/types'; + +import { useDialogWithMutation } from '@renderer/shared/hooks/useDialogWithMutation'; +import { useModalFormState } from '@renderer/shared/hooks/useModalFormState'; + +import { useUpdateAlert } from '../../api/useAlertMutations'; + +function toDatetimeLocalValue(isoString: string): string { + try { + const date = new Date(isoString); + if (Number.isNaN(date.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; + } catch { + return ''; + } +} + +interface AlertFormValues { + message: string; + triggerAt: string; + enableRecurring: boolean; + recurringFrequency: 'daily' | 'weekly' | 'monthly'; + recurringTime: string; +} + +const ALERT_FORM_DEFAULTS: AlertFormValues = { + message: '', + triggerAt: '', + enableRecurring: false, + recurringFrequency: 'daily', + recurringTime: '09:00', +}; + +interface UseAlertEditDialogParams { + alert: Alert | null; + onClose: () => void; +} + +export function useAlertEditDialog({ alert, onClose }: UseAlertEditDialogParams) { + const updateAlert = useUpdateAlert(); + const isOpen = alert !== null; + + const entityValues: Partial<AlertFormValues> | undefined = alert + ? { + message: alert.message, + triggerAt: toDatetimeLocalValue(alert.triggerAt), + enableRecurring: alert.recurring !== undefined, + recurringFrequency: alert.recurring?.frequency ?? 'daily', + recurringTime: alert.recurring?.time ?? '09:00', + } + : undefined; + + const { values, update } = useModalFormState<AlertFormValues>( + isOpen, + ALERT_FORM_DEFAULTS, + entityValues, + ); + + const { handleSubmit: submitMutation, isPending } = useDialogWithMutation(updateAlert, { + onClose, + }); + + function handleSubmit() { + if (alert === null) return; + const trimmed = values.message.trim(); + if (trimmed.length === 0) return; + + const recurring: RecurringConfig | null = values.enableRecurring + ? { frequency: values.recurringFrequency, time: values.recurringTime } + : null; + + const updatedMessage = trimmed === alert.message ? undefined : trimmed; + const updatedTriggerAt = + values.triggerAt.length > 0 ? new Date(values.triggerAt).toISOString() : undefined; + + submitMutation({ + id: alert.id, + message: updatedMessage, + triggerAt: updatedTriggerAt, + recurring, + linkedTo: alert.linkedTo, + }); + } + + function handleOpenChange(open: boolean) { + if (open) return; + onClose(); + } + + return { + isOpen, + message: values.message, + setMessage: (v: string) => update('message', v), + triggerAt: values.triggerAt, + setTriggerAt: (v: string) => update('triggerAt', v), + enableRecurring: values.enableRecurring, + setEnableRecurring: (v: boolean) => update('enableRecurring', v), + recurringFrequency: values.recurringFrequency, + setRecurringFrequency: (v: 'daily' | 'weekly' | 'monthly') => + update('recurringFrequency', v), + recurringTime: values.recurringTime, + setRecurringTime: (v: string) => update('recurringTime', v), + handleSubmit, + handleOpenChange, + isPending, + linkedTo: isOpen ? alert.linkedTo : undefined, + }; +} diff --git a/src/renderer/features/personal/alerts/components/AlertNotification.tsx b/src/renderer/features/alerts/components/AlertNotification/AlertNotification.tsx similarity index 75% rename from src/renderer/features/personal/alerts/components/AlertNotification.tsx rename to src/renderer/features/alerts/components/AlertNotification/AlertNotification.tsx index 35969721..e45d5dcb 100644 --- a/src/renderer/features/personal/alerts/components/AlertNotification.tsx +++ b/src/renderer/features/alerts/components/AlertNotification/AlertNotification.tsx @@ -8,23 +8,15 @@ import { cn } from '@renderer/shared/lib/utils'; import { Button } from '@ui'; -import { useDismissAlert } from '../api/useAlerts'; -import { useAlertStore } from '../store'; +import { useAlertNotification } from './useAlertNotification'; export function AlertNotification() { - const notifications = useAlertStore((s) => s.notifications); - const dismissNotification = useAlertStore((s) => s.dismissNotification); - const dismissAlert = useDismissAlert(); + const { notifications, handleDismiss } = useAlertNotification(); if (notifications.length === 0) { return null; } - function handleDismiss(alertId: string) { - dismissNotification(alertId); - dismissAlert.mutate(alertId); - } - return ( <div className="fixed top-4 right-4 z-50 flex flex-col gap-2"> {notifications.map((notification) => ( diff --git a/src/renderer/features/alerts/components/AlertNotification/index.ts b/src/renderer/features/alerts/components/AlertNotification/index.ts new file mode 100644 index 00000000..c1f1af98 --- /dev/null +++ b/src/renderer/features/alerts/components/AlertNotification/index.ts @@ -0,0 +1 @@ +export { AlertNotification } from './AlertNotification'; diff --git a/src/renderer/features/alerts/components/AlertNotification/useAlertNotification.ts b/src/renderer/features/alerts/components/AlertNotification/useAlertNotification.ts new file mode 100644 index 00000000..4261f753 --- /dev/null +++ b/src/renderer/features/alerts/components/AlertNotification/useAlertNotification.ts @@ -0,0 +1,31 @@ +/** + * useAlertNotification — all logic for AlertNotification + */ + +import { useDismissAlert } from '../../api/useAlerts'; +import { useAlertStore } from '../../store'; + +interface AlertNotificationItem { + alertId: string; + message: string; + receivedAt: number; +} + +export function useAlertNotification(): { + notifications: AlertNotificationItem[]; + handleDismiss: (alertId: string) => void; +} { + const notifications = useAlertStore((s) => s.notifications); + const dismissNotification = useAlertStore((s) => s.dismissNotification); + const dismissAlert = useDismissAlert(); + + function handleDismiss(alertId: string) { + dismissNotification(alertId); + dismissAlert.mutate(alertId); + } + + return { + notifications, + handleDismiss, + }; +} diff --git a/src/renderer/features/personal/alerts/components/AlertsPage.tsx b/src/renderer/features/alerts/components/AlertsPage/AlertsPage.tsx similarity index 75% rename from src/renderer/features/personal/alerts/components/AlertsPage.tsx rename to src/renderer/features/alerts/components/AlertsPage/AlertsPage.tsx index 11580373..7cdbb130 100644 --- a/src/renderer/features/personal/alerts/components/AlertsPage.tsx +++ b/src/renderer/features/alerts/components/AlertsPage/AlertsPage.tsx @@ -2,14 +2,11 @@ * AlertsPage — List of all alerts with management controls */ -import { useState } from 'react'; - import { Bell, Check, Clock, Pencil, Plus, Repeat, Trash2 } from 'lucide-react'; import type { Alert } from '@shared/types'; import { RelativeTime } from '@renderer/shared/components/RelativeTime'; -import { useDebounce } from '@renderer/shared/hooks/useDebounce'; import { cn } from '@renderer/shared/lib/utils'; import { @@ -28,17 +25,11 @@ import { Text, } from '@ui'; -import { useAlerts, useDeleteAlert, useDismissAlert } from '../api/useAlerts'; -import { useAlertEvents } from '../hooks/useAlertEvents'; -import { useAlertStore } from '../store'; - -import { AlertEditDialog } from './AlertEditDialog'; -import { CreateAlertModal } from './CreateAlertModal'; -import { RecurringAlerts } from './RecurringAlerts'; +import { AlertEditDialog } from '../AlertEditDialog'; +import { CreateAlertModal } from '../CreateAlertModal'; +import { RecurringAlerts } from '../RecurringAlerts'; -type TabId = 'active' | 'dismissed' | 'recurring'; -type SortField = 'triggerAt' | 'createdAt'; -type AlertTypeFilter = 'all' | Alert['type']; +import { useAlertsPage } from './useAlertsPage'; function getAlertIcon(type: Alert['type']) { switch (type) { @@ -73,60 +64,25 @@ function formatTriggerTime(triggerAt: string): string { }); } -function applyFilters( - alertList: Alert[], - searchQuery: string, - typeFilter: AlertTypeFilter, - sortField: SortField, -): Alert[] { - let result = alertList; - - if (searchQuery.length > 0) { - const lower = searchQuery.toLowerCase(); - result = result.filter((a) => a.message.toLowerCase().includes(lower)); - } - - if (typeFilter !== 'all') { - result = result.filter((a) => a.type === typeFilter); - } - - return [...result].sort((a, b) => { - const aVal = new Date(a[sortField]).getTime(); - const bVal = new Date(b[sortField]).getTime(); - return aVal - bVal; - }); -} - export function AlertsPage() { - useAlertEvents(); - - const { data: alerts = [], isLoading } = useAlerts(true); - const dismissAlert = useDismissAlert(); - const deleteAlert = useDeleteAlert(); - const openCreateModal = useAlertStore((s) => s.openCreateModal); - - const [editingAlert, setEditingAlert] = useState<Alert | null>(null); - const [searchText, setSearchText] = useState(''); - const [sortField, setSortField] = useState<SortField>('triggerAt'); - const [typeFilter, setTypeFilter] = useState<AlertTypeFilter>('all'); - - const debouncedSearch = useDebounce(searchText, 250); - - const activeAlerts = alerts.filter((a) => !a.dismissed); - const dismissedAlerts = alerts.filter((a) => a.dismissed); - - const filteredActive = applyFilters(activeAlerts, debouncedSearch, typeFilter, sortField); - const filteredDismissed = applyFilters(dismissedAlerts, debouncedSearch, typeFilter, sortField); - - const tabs: Array<{ id: TabId; label: string; count: number }> = [ - { id: 'active', label: 'Active', count: activeAlerts.length }, - { id: 'dismissed', label: 'Dismissed', count: dismissedAlerts.length }, - { - id: 'recurring', - label: 'Recurring', - count: alerts.filter((a) => a.recurring !== undefined).length, - }, - ]; + const { + alerts, + isLoading, + dismissAlert, + deleteAlert, + openCreateModal, + editingAlert, + setEditingAlert, + searchText, + setSearchText, + sortField, + setSortField, + typeFilter, + setTypeFilter, + filteredActive, + filteredDismissed, + tabs, + } = useAlertsPage(); function renderAlertList(alertList: Alert[]) { if (alertList.length === 0) { @@ -260,7 +216,7 @@ export function AlertsPage() { <Select value={typeFilter} - onValueChange={(v) => setTypeFilter(v as AlertTypeFilter)} + onValueChange={(v) => setTypeFilter(v as Parameters<typeof setTypeFilter>[0])} > <SelectTrigger aria-label="Filter by type" className="w-36"> <SelectValue placeholder="All types" /> @@ -276,7 +232,7 @@ export function AlertsPage() { <Select value={sortField} - onValueChange={(v) => setSortField(v as SortField)} + onValueChange={(v) => setSortField(v as Parameters<typeof setSortField>[0])} > <SelectTrigger aria-label="Sort by" className="w-40"> <SelectValue placeholder="Sort by" /> diff --git a/src/renderer/features/alerts/components/AlertsPage/index.ts b/src/renderer/features/alerts/components/AlertsPage/index.ts new file mode 100644 index 00000000..32956604 --- /dev/null +++ b/src/renderer/features/alerts/components/AlertsPage/index.ts @@ -0,0 +1 @@ +export { AlertsPage } from './AlertsPage'; diff --git a/src/renderer/features/alerts/components/AlertsPage/useAlertsPage.ts b/src/renderer/features/alerts/components/AlertsPage/useAlertsPage.ts new file mode 100644 index 00000000..8281293c --- /dev/null +++ b/src/renderer/features/alerts/components/AlertsPage/useAlertsPage.ts @@ -0,0 +1,92 @@ +/** + * useAlertsPage — all logic for AlertsPage + */ + +import { useState } from 'react'; + +import type { Alert } from '@shared/types'; + +import { useDebounce } from '@renderer/shared/hooks/useDebounce'; + +import { useAlerts, useDeleteAlert, useDismissAlert } from '../../api/useAlerts'; +import { useAlertEvents } from '../../hooks/useAlertEvents'; +import { useAlertStore } from '../../store'; + +export type TabId = 'active' | 'dismissed' | 'recurring'; +export type SortField = 'triggerAt' | 'createdAt'; +export type AlertTypeFilter = 'all' | Alert['type']; + +function applyFilters( + alertList: Alert[], + searchQuery: string, + typeFilter: AlertTypeFilter, + sortField: SortField, +): Alert[] { + let result = alertList; + + if (searchQuery.length > 0) { + const lower = searchQuery.toLowerCase(); + result = result.filter((a) => a.message.toLowerCase().includes(lower)); + } + + if (typeFilter !== 'all') { + result = result.filter((a) => a.type === typeFilter); + } + + return [...result].sort((a, b) => { + const aVal = new Date(a[sortField]).getTime(); + const bVal = new Date(b[sortField]).getTime(); + return aVal - bVal; + }); +} + +export function useAlertsPage() { + useAlertEvents(); + + const { data: alerts = [], isLoading } = useAlerts(true); + const dismissAlert = useDismissAlert(); + const deleteAlert = useDeleteAlert(); + const openCreateModal = useAlertStore((s) => s.openCreateModal); + + const [editingAlert, setEditingAlert] = useState<Alert | null>(null); + const [searchText, setSearchText] = useState(''); + const [sortField, setSortField] = useState<SortField>('triggerAt'); + const [typeFilter, setTypeFilter] = useState<AlertTypeFilter>('all'); + + const debouncedSearch = useDebounce(searchText, 250); + + const activeAlerts = alerts.filter((a) => !a.dismissed); + const dismissedAlerts = alerts.filter((a) => a.dismissed); + + const filteredActive = applyFilters(activeAlerts, debouncedSearch, typeFilter, sortField); + const filteredDismissed = applyFilters(dismissedAlerts, debouncedSearch, typeFilter, sortField); + + const tabs: Array<{ id: TabId; label: string; count: number }> = [ + { id: 'active', label: 'Active', count: activeAlerts.length }, + { id: 'dismissed', label: 'Dismissed', count: dismissedAlerts.length }, + { + id: 'recurring', + label: 'Recurring', + count: alerts.filter((a) => a.recurring !== undefined).length, + }, + ]; + + return { + alerts, + isLoading, + dismissAlert, + deleteAlert, + openCreateModal, + editingAlert, + setEditingAlert, + searchText, + setSearchText, + sortField, + setSortField, + typeFilter, + setTypeFilter, + filteredActive, + filteredDismissed, + tabs, + }; +} diff --git a/src/renderer/features/alerts/components/CreateAlertModal/CreateAlertModal.tsx b/src/renderer/features/alerts/components/CreateAlertModal/CreateAlertModal.tsx new file mode 100644 index 00000000..f705cfdb --- /dev/null +++ b/src/renderer/features/alerts/components/CreateAlertModal/CreateAlertModal.tsx @@ -0,0 +1,160 @@ +/** + * CreateAlertModal — Natural language input with parsed preview + */ + +import { Bell, Calendar, Clock, Repeat, X } from 'lucide-react'; + +import { Button, Checkbox, Heading, Input, Label } from '@ui'; + +import { useCreateAlertModal } from './useCreateAlertModal'; + +export function CreateAlertModal() { + const { + showCreateModal, + closeCreateModal, + message, + setMessage, + timeInput, + alertType, + setAlertType, + parsePreview, + manualDate, + setManualDate, + useManualDate, + setUseManualDate, + handleTimeInputChange, + handleSubmit, + isPending, + } = useCreateAlertModal(); + + if (!showCreateModal) { + return null; + } + + const typeOptions = [ + { value: 'reminder' as const, label: 'Reminder', icon: Bell }, + { value: 'deadline' as const, label: 'Deadline', icon: Calendar }, + { value: 'notification' as const, label: 'Notification', icon: Clock }, + { value: 'recurring' as const, label: 'Recurring', icon: Repeat }, + ]; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center"> + {/* Backdrop */} + <div + aria-label="Close modal" + className="absolute inset-0 bg-black/50" + role="button" + tabIndex={0} + onClick={closeCreateModal} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') closeCreateModal(); + }} + /> + + {/* Modal */} + <div className="bg-card border-border relative z-10 w-full max-w-md rounded-lg border p-6 shadow-xl"> + <div className="mb-4 flex items-center justify-between"> + <Heading as="h2" className="text-lg">Create Alert</Heading> + <Button + className="h-7 w-7 text-muted-foreground" + size="icon" + variant="ghost" + onClick={closeCreateModal} + > + <X className="h-5 w-5" /> + </Button> + </div> + + {/* Alert type */} + <div className="mb-4"> + <span className="text-foreground mb-1.5 block text-sm font-medium">Type</span> + <div className="flex gap-2"> + {typeOptions.map((option) => ( + <Button + key={option.value} + className="gap-1.5" + size="sm" + variant={alertType === option.value ? 'primary' : 'ghost'} + onClick={() => setAlertType(option.value)} + > + <option.icon className="h-3.5 w-3.5" /> + {option.label} + </Button> + ))} + </div> + </div> + + {/* Message */} + <div className="mb-4"> + <Label className="mb-1.5 block" htmlFor="alert-message"> + Message + </Label> + <Input + id="alert-message" + placeholder="What should you be reminded about?" + type="text" + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </div> + + {/* Time input — NLP */} + <div className="mb-4"> + <Label className="mb-1.5 block" htmlFor="alert-time"> + When (natural language) + </Label> + <Input + disabled={useManualDate} + id="alert-time" + placeholder='e.g. "tomorrow at 3pm", "in 2 hours", "every Monday at 9am"' + type="text" + value={timeInput} + onChange={(e) => handleTimeInputChange(e.target.value)} + /> + {parsePreview === null ? null : ( + <div className="bg-muted mt-2 rounded-md p-2 text-xs"> + <span className="text-muted-foreground"> + {parsePreview.isRecurring ? 'Recurring: ' : 'One-time: '} + {parsePreview.date} + </span> + </div> + )} + </div> + + {/* Manual date fallback */} + <div className="mb-4"> + <Label className="text-muted-foreground flex items-center gap-2 text-sm" htmlFor="alert-manual-toggle"> + <Checkbox + checked={useManualDate} + id="alert-manual-toggle" + onCheckedChange={(checked) => setUseManualDate(checked === true)} + /> + Use manual date/time + </Label> + {useManualDate ? ( + <Input + className="mt-2" + type="datetime-local" + value={manualDate} + onChange={(e) => setManualDate(e.target.value)} + /> + ) : null} + </div> + + {/* Actions */} + <div className="flex justify-end gap-2"> + <Button variant="ghost" onClick={closeCreateModal}> + Cancel + </Button> + <Button + disabled={message.trim().length === 0 || isPending} + onClick={handleSubmit} + > + {isPending ? 'Creating...' : 'Create Alert'} + </Button> + </div> + </div> + </div> + ); +} diff --git a/src/renderer/features/alerts/components/CreateAlertModal/index.ts b/src/renderer/features/alerts/components/CreateAlertModal/index.ts new file mode 100644 index 00000000..112ad12c --- /dev/null +++ b/src/renderer/features/alerts/components/CreateAlertModal/index.ts @@ -0,0 +1 @@ +export { CreateAlertModal } from './CreateAlertModal'; diff --git a/src/renderer/features/alerts/components/CreateAlertModal/useCreateAlertModal.ts b/src/renderer/features/alerts/components/CreateAlertModal/useCreateAlertModal.ts new file mode 100644 index 00000000..002440c9 --- /dev/null +++ b/src/renderer/features/alerts/components/CreateAlertModal/useCreateAlertModal.ts @@ -0,0 +1,123 @@ +/** + * useCreateAlertModal — all logic for CreateAlertModal + */ + +import { useState } from 'react'; + +import { useDialogWithMutation } from '@renderer/shared/hooks/useDialogWithMutation'; +import { useModalFormState } from '@renderer/shared/hooks/useModalFormState'; + +import { useCreateAlert } from '../../api/useAlerts'; +import { useAlertStore } from '../../store'; + +interface ParsePreview { + date: string; + isRecurring: boolean; + confidence: number; +} + +type AlertType = 'reminder' | 'deadline' | 'notification' | 'recurring'; + +interface CreateAlertFormValues { + message: string; + timeInput: string; + alertType: AlertType; + manualDate: string; + useManualDate: boolean; +} + +const CREATE_ALERT_DEFAULTS: CreateAlertFormValues = { + message: '', + timeInput: '', + alertType: 'reminder', + manualDate: '', + useManualDate: false, +}; + +export function useCreateAlertModal() { + const showCreateModal = useAlertStore((s) => s.showCreateModal); + const closeCreateModal = useAlertStore((s) => s.closeCreateModal); + const createAlert = useCreateAlert(); + + const { values, update, reset } = useModalFormState<CreateAlertFormValues>( + showCreateModal, + CREATE_ALERT_DEFAULTS, + ); + + const [parsePreview, setParsePreview] = useState<ParsePreview | null>(null); + + const { handleSubmit: submitMutation, isPending } = useDialogWithMutation(createAlert, { + onClose: closeCreateModal, + resetForm: () => { + reset(); + setParsePreview(null); + }, + }); + + function handleTimeInputChange(value: string) { + update('timeInput', value); + if (value.trim().length === 0) { + setParsePreview(null); + return; + } + + try { + const date = new Date(value); + if (!Number.isNaN(date.getTime())) { + setParsePreview({ + date: date.toLocaleString(), + isRecurring: false, + confidence: 0.9, + }); + return; + } + } catch { + // Fall through to keyword check + } + + const isRecurring = /^every\s/i.test(value); + setParsePreview({ + date: 'Will be parsed on creation', + isRecurring, + confidence: 0.5, + }); + } + + function handleSubmit() { + if (values.message.trim().length === 0) return; + + let triggerAt: string; + const type = values.alertType; + + if (values.useManualDate && values.manualDate.length > 0) { + triggerAt = new Date(values.manualDate).toISOString(); + } else if (values.timeInput.trim().length > 0) { + const directDate = new Date(values.timeInput); + triggerAt = Number.isNaN(directDate.getTime()) + ? new Date(Date.now() + 3_600_000).toISOString() + : directDate.toISOString(); + } else { + triggerAt = new Date(Date.now() + 3_600_000).toISOString(); + } + + submitMutation({ type, message: values.message.trim(), triggerAt }); + } + + return { + showCreateModal, + closeCreateModal, + message: values.message, + setMessage: (v: string) => update('message', v), + timeInput: values.timeInput, + alertType: values.alertType, + setAlertType: (v: AlertType) => update('alertType', v), + parsePreview, + manualDate: values.manualDate, + setManualDate: (v: string) => update('manualDate', v), + useManualDate: values.useManualDate, + setUseManualDate: (v: boolean) => update('useManualDate', v), + handleTimeInputChange, + handleSubmit, + isPending, + }; +} diff --git a/src/renderer/features/personal/alerts/components/RecurringAlerts.tsx b/src/renderer/features/alerts/components/RecurringAlerts/RecurringAlerts.tsx similarity index 91% rename from src/renderer/features/personal/alerts/components/RecurringAlerts.tsx rename to src/renderer/features/alerts/components/RecurringAlerts/RecurringAlerts.tsx index e5a7b5ad..23213867 100644 --- a/src/renderer/features/personal/alerts/components/RecurringAlerts.tsx +++ b/src/renderer/features/alerts/components/RecurringAlerts/RecurringAlerts.tsx @@ -10,7 +10,7 @@ import { cn } from '@renderer/shared/lib/utils'; import { Button, EmptyState } from '@ui'; -import { useDeleteAlert } from '../api/useAlerts'; +import { useRecurringAlerts } from './useRecurringAlerts'; interface RecurringAlertsProps { alerts: Alert[]; @@ -40,9 +40,7 @@ function formatRecurringSchedule(alert: Alert): string { } export function RecurringAlerts({ alerts }: RecurringAlertsProps) { - const deleteAlert = useDeleteAlert(); - - const recurringAlerts = alerts.filter((a) => a.type === 'recurring' || a.recurring !== undefined); + const { recurringAlerts, deleteAlert } = useRecurringAlerts({ alerts }); if (recurringAlerts.length === 0) { return ( diff --git a/src/renderer/features/alerts/components/RecurringAlerts/index.ts b/src/renderer/features/alerts/components/RecurringAlerts/index.ts new file mode 100644 index 00000000..0792cd77 --- /dev/null +++ b/src/renderer/features/alerts/components/RecurringAlerts/index.ts @@ -0,0 +1 @@ +export { RecurringAlerts } from './RecurringAlerts'; diff --git a/src/renderer/features/alerts/components/RecurringAlerts/useRecurringAlerts.ts b/src/renderer/features/alerts/components/RecurringAlerts/useRecurringAlerts.ts new file mode 100644 index 00000000..2cdc815a --- /dev/null +++ b/src/renderer/features/alerts/components/RecurringAlerts/useRecurringAlerts.ts @@ -0,0 +1,24 @@ +/** + * useRecurringAlerts — all logic for RecurringAlerts + */ + +import type { Alert } from '@shared/types'; + +import { useDeleteAlert } from '../../api/useAlerts'; + +interface UseRecurringAlertsParams { + alerts: Alert[]; +} + +export function useRecurringAlerts({ alerts }: UseRecurringAlertsParams) { + const deleteAlert = useDeleteAlert(); + + const recurringAlerts = alerts.filter( + (a) => a.type === 'recurring' || a.recurring !== undefined, + ); + + return { + recurringAlerts, + deleteAlert, + }; +} diff --git a/src/renderer/features/personal/alerts/hooks/useAlertEvents.ts b/src/renderer/features/alerts/hooks/useAlertEvents.ts similarity index 91% rename from src/renderer/features/personal/alerts/hooks/useAlertEvents.ts rename to src/renderer/features/alerts/hooks/useAlertEvents.ts index c193cecc..d5e8e039 100644 --- a/src/renderer/features/personal/alerts/hooks/useAlertEvents.ts +++ b/src/renderer/features/alerts/hooks/useAlertEvents.ts @@ -4,7 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'; -import { ALERTS_EVENTS } from '@shared/ipc/misc/alerts.channels'; +import { ALERTS_EVENTS } from '@shared/ipc/alerts'; import { useIpcEvent } from '@renderer/shared/hooks'; diff --git a/src/renderer/features/alerts/index.ts b/src/renderer/features/alerts/index.ts new file mode 100644 index 00000000..1d02c19d --- /dev/null +++ b/src/renderer/features/alerts/index.ts @@ -0,0 +1,10 @@ +/** + * Alerts feature — public API + */ + +export { useAlerts, useCreateAlert, useDismissAlert, useDeleteAlert } from './api/useAlerts'; +export { alertKeys } from './api/queryKeys'; +export { AlertsPage } from './components/AlertsPage/index'; +export { AlertNotification } from './components/AlertNotification/index'; +export { useAlertEvents } from './hooks/useAlertEvents'; +export { useAlertStore } from './store'; diff --git a/src/renderer/features/personal/alerts/store.ts b/src/renderer/features/alerts/store.ts similarity index 100% rename from src/renderer/features/personal/alerts/store.ts rename to src/renderer/features/alerts/store.ts diff --git a/src/renderer/features/assistant/api/useAssistant.ts b/src/renderer/features/assistant/api/useAssistant.ts index c3dd72fc..592d5c94 100644 --- a/src/renderer/features/assistant/api/useAssistant.ts +++ b/src/renderer/features/assistant/api/useAssistant.ts @@ -8,7 +8,6 @@ import { ASSISTANT } from '@shared/ipc/assistant/channels'; import { ipc } from '@renderer/shared/lib/ipc'; -import { setLastCommand } from '../hooks/useAssistantEvents'; import { useAssistantStore } from '../store'; import { assistantKeys } from './queryKeys'; @@ -25,7 +24,7 @@ export function useHistory(limit?: number) { /** Send a command to the assistant */ export function useSendCommand() { const queryClient = useQueryClient(); - const { setIsThinking, clearCurrentResponse } = useAssistantStore(); + const { setIsThinking, clearCurrentResponse, addUserEntry } = useAssistantStore(); return useMutation({ mutationFn: (data: { @@ -38,7 +37,7 @@ export function useSendCommand() { }); }, onMutate: (variables) => { - setLastCommand(variables.input); + addUserEntry(variables.input); setIsThinking(true); clearCurrentResponse(); }, diff --git a/src/renderer/features/assistant/components/AssistantInputBar.tsx b/src/renderer/features/assistant/components/AssistantInputBar.tsx index 7dc9b684..57a512d3 100644 --- a/src/renderer/features/assistant/components/AssistantInputBar.tsx +++ b/src/renderer/features/assistant/components/AssistantInputBar.tsx @@ -1,9 +1,9 @@ /** - * AssistantInputBar — Combined input bar with project selector, text input, - * send button, and quick action chips. + * AssistantInputBar — Combined input bar with text input, send button, + * and quick action chips. * * Layout: - * [▾ ProjectName] [Ask anything... ] [↑] + * [Ask anything... ] [↑] * +Task +Todo Status Git PRs */ @@ -17,9 +17,9 @@ import { useLayoutStore } from '@renderer/shared/stores'; import { useProjects } from '@features/projects/api/useProjects'; import { Button } from '@ui/button'; +import { Separator } from '@ui/separator'; import { Textarea } from '@ui/textarea'; -import { ProjectSelector } from './ProjectSelector'; import { QuickActionChips } from './QuickActionChips'; interface AssistantInputBarProps { @@ -32,25 +32,17 @@ export function AssistantInputBar({ onSubmit, disabled, compact }: AssistantInpu const activeProjectId = useLayoutStore((s) => s.activeProjectId); const { data: projects } = useProjects(); - const [selectedProjectId, setSelectedProjectId] = useState<string | null>(activeProjectId); - const [selectedProjectName, setSelectedProjectName] = useState(''); const [draft, setDraft] = useState(''); const textareaRef = useRef<HTMLTextAreaElement>(null); - // Sync with active project changes - useEffect(() => { - setSelectedProjectId(activeProjectId); - }, [activeProjectId]); - // Derive project name from projects list const projectName = useMemo(() => { - if (selectedProjectName) return selectedProjectName; - if (projects && selectedProjectId) { - const found = projects.find((p) => p.id === selectedProjectId); + if (projects && activeProjectId) { + const found = projects.find((p) => p.id === activeProjectId); if (found) return found.name; } return 'project'; - }, [projects, selectedProjectId, selectedProjectName]); + }, [projects, activeProjectId]); const adjustHeight = useCallback(() => { const el = textareaRef.current; @@ -78,41 +70,31 @@ export function AssistantInputBar({ onSubmit, disabled, compact }: AssistantInpu } } - function handleProjectSelect(projectId: string, name: string) { - setSelectedProjectId(projectId); - setSelectedProjectName(name); - } - function handlePrefill(text: string) { setDraft(text); textareaRef.current?.focus(); } return ( - <div className={cn('border-border border-t', compact ? 'space-y-0' : 'space-y-0.5')}> - {/* Row 1: Project selector + textarea + send */} + <div className={cn('shrink-0', compact ? 'space-y-0' : 'space-y-0.5')}> + <Separator /> + {/* Row 1: textarea + send */} <div className={cn('flex items-end gap-1', compact ? 'p-1.5' : 'p-2')}> - <div className="flex flex-1 flex-col gap-1"> - <ProjectSelector - selectedProjectId={selectedProjectId} - onSelect={handleProjectSelect} - /> - <Textarea - ref={textareaRef} - aria-label="Message assistant" - disabled={disabled} - placeholder="Ask anything..." - resize="none" - rows={1} - value={draft} - className={cn( - 'min-h-0 flex-1 px-2 py-1.5 text-xs', - compact ? 'max-h-16' : 'max-h-20', - )} - onChange={(e) => setDraft(e.target.value)} - onKeyDown={handleKeyDown} - /> - </div> + <Textarea + ref={textareaRef} + aria-label="Message assistant" + disabled={disabled} + placeholder="Ask anything..." + resize="none" + rows={1} + value={draft} + className={cn( + 'min-h-0 flex-1 px-2 py-1.5 text-xs', + compact ? 'max-h-16' : 'max-h-20', + )} + onChange={(e) => setDraft(e.target.value)} + onKeyDown={handleKeyDown} + /> <Button aria-label="Send message" className="h-7 w-7 shrink-0 p-1.5" diff --git a/src/renderer/features/assistant/components/AssistantPage.tsx b/src/renderer/features/assistant/components/AssistantPage.tsx index bf2191b6..2fb93a62 100644 --- a/src/renderer/features/assistant/components/AssistantPage.tsx +++ b/src/renderer/features/assistant/components/AssistantPage.tsx @@ -12,7 +12,7 @@ import { ROUTES } from '@shared/constants/routes'; import { useLayoutStore } from '@renderer/shared/stores'; -import { Button, PageContent, PageHeader, PageLayout, PageHeaderTitle, PageHeaderActions, PageHeaderRow } from '@ui'; +import { Button, PageContent, PageHeader, PageLayout, PageHeaderTitle, PageHeaderActions, PageHeaderRow, Separator } from '@ui'; import { useClearHistory, useSendCommand } from '../api/useAssistant'; import { useAssistantEvents } from '../hooks/useAssistantEvents'; @@ -31,7 +31,6 @@ export function AssistantPage() { const pathname = useRouterState({ select: (s) => s.location.pathname }); function getActiveView(path: string): string { - if (path.includes('/roadmap')) return 'roadmap'; if (path.startsWith(ROUTES.PLANNER)) return 'planner'; if (path.includes('/ideation')) return 'ideation'; if (path.startsWith(ROUTES.NOTES)) return 'notes'; @@ -73,7 +72,8 @@ export function AssistantPage() { <PageContent className="flex flex-col gap-0 overflow-hidden p-0"> <div className="flex min-h-0 flex-1 flex-col"> <WidgetMessageArea /> - <div className="border-border border-t p-3"> + <Separator /> + <div className="p-3"> <AssistantInputBar disabled={sendCommand.isPending} onSubmit={handleSendCommand} diff --git a/src/renderer/features/assistant/components/QuickActions.tsx b/src/renderer/features/assistant/components/QuickActions.tsx index 4fa66bcf..4f158b1f 100644 --- a/src/renderer/features/assistant/components/QuickActions.tsx +++ b/src/renderer/features/assistant/components/QuickActions.tsx @@ -2,9 +2,9 @@ * QuickActions — Common action buttons for the assistant */ -import { Bell, Lightbulb, Map, StickyNote } from 'lucide-react'; +import { Bell, Lightbulb, StickyNote } from 'lucide-react'; -import { Button } from '@ui'; +import { Button, Separator } from '@ui'; interface QuickAction { label: string; @@ -24,12 +24,6 @@ const quickActions: QuickAction[] = [ command: 'Create a new feature idea for my project. Use the create_idea tool with a descriptive title and description.', }, - { - label: 'New Milestone', - icon: Map, - command: - 'Create a new milestone for my project roadmap with a target date 4 weeks from today. Use the create_milestone tool.', - }, { label: 'Plan Today', icon: Bell, @@ -45,23 +39,26 @@ interface QuickActionsProps { export function QuickActions({ disabled, onAction }: QuickActionsProps) { return ( - <div className="border-border border-b px-4 py-3"> - <div className="flex flex-wrap gap-2"> - {quickActions.map((action) => ( - <Button - key={action.label} - className="text-muted-foreground text-xs" - disabled={disabled} - size="sm" - type="button" - variant="outline" - onClick={() => onAction(action.command)} - > - <action.icon className="h-3.5 w-3.5" /> - <span>{action.label}</span> - </Button> - ))} + <> + <div className="px-4 py-3"> + <div className="flex flex-wrap gap-2"> + {quickActions.map((action) => ( + <Button + key={action.label} + className="text-muted-foreground text-xs" + disabled={disabled} + size="sm" + type="button" + variant="outline" + onClick={() => onAction(action.command)} + > + <action.icon className="h-3.5 w-3.5" /> + <span>{action.label}</span> + </Button> + ))} + </div> </div> - </div> + <Separator /> + </> ); } diff --git a/src/renderer/features/assistant/components/ResponseStream.tsx b/src/renderer/features/assistant/components/ResponseStream.tsx index 61c5ba12..ce6f8631 100644 --- a/src/renderer/features/assistant/components/ResponseStream.tsx +++ b/src/renderer/features/assistant/components/ResponseStream.tsx @@ -8,11 +8,11 @@ import { AlertCircle } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; -import { ThinkingIndicator } from '@ui'; +import { MarkdownMessage, ThinkingIndicator } from '@ui'; import { useAssistantStore } from '../store'; -import type { ResponseEntry } from '../store'; +import type { ChatEntry, ResponseEntry } from '../store'; const RESPONSE_STYLES: Record<ResponseEntry['type'], string> = { text: 'bg-muted/50', @@ -45,29 +45,30 @@ export function ResponseStream() { return ( <div ref={scrollRef} className="flex-1 overflow-y-auto p-4"> <div className="mx-auto max-w-3xl space-y-4"> - {responseHistory.map((entry) => ( - <div key={entry.id} className="space-y-2"> - {/* User command */} - <div className="flex justify-end"> + {responseHistory.map((entry: ChatEntry) => ( + entry.kind === 'user' ? ( + <div key={entry.id} className="flex justify-end"> <div className="bg-primary text-primary-foreground max-w-[80%] rounded-lg px-3 py-2 text-sm"> {entry.input} </div> </div> - - {/* Assistant response */} - <div className="flex gap-2"> - <ResponseIcon type={entry.type} /> - <div - className={cn( - 'max-w-[80%] rounded-lg px-3 py-2', - 'text-foreground text-sm leading-relaxed whitespace-pre-wrap', - RESPONSE_STYLES[entry.type], - )} - > - {entry.response} + ) : ( + <div key={entry.id} className="space-y-2"> + {/* Assistant response */} + <div className="flex gap-2"> + <ResponseIcon type={entry.type} /> + <div + className={cn( + 'max-w-[80%] rounded-lg px-3 py-2', + 'text-foreground', + RESPONSE_STYLES[entry.type], + )} + > + <MarkdownMessage>{entry.response}</MarkdownMessage> + </div> </div> </div> - </div> + ) ))} {isThinking ? ( diff --git a/src/renderer/features/assistant/components/SidebarAssistantButton.tsx b/src/renderer/features/assistant/components/SidebarAssistantButton.tsx index e4c02089..5f69ec8f 100644 --- a/src/renderer/features/assistant/components/SidebarAssistantButton.tsx +++ b/src/renderer/features/assistant/components/SidebarAssistantButton.tsx @@ -15,7 +15,9 @@ import { cn } from '@renderer/shared/lib/utils'; import { useAssistantWidgetStore, useLayoutStore } from '@renderer/shared/stores'; import { Button } from '@ui/button'; +import { Separator } from '@ui/separator'; import { + SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -95,9 +97,11 @@ export function SidebarAssistantButton() { // ── Expanded sidebar + inline mode → embedded chat ────── return ( - <div className="assistant-inline border-border flex flex-1 flex-col border-t"> + <div className="assistant-inline flex min-h-0 flex-1 flex-col overflow-hidden"> + <Separator className="bg-muted-foreground/30" /> + {/* Header bar */} - <div className="flex items-center justify-between px-2 py-1.5"> + <div className="flex shrink-0 items-center justify-between px-2 py-1.5"> <ProjectSelector selectedProjectId={activeProjectId} onSelect={(id) => setActiveProject(id)} @@ -124,12 +128,14 @@ export function SidebarAssistantButton() { </div> </div> - {/* Message area — constrained height */} - <div className="border-border min-h-0 flex-1 border-t"> + <Separator /> + + {/* Message area — scrolls, input stays pinned */} + <SidebarGroupContent className="flex min-h-0 flex-1 flex-col overflow-hidden"> <WidgetMessageArea /> - </div> + </SidebarGroupContent> - {/* Input */} + {/* Input — sticky bottom */} <AssistantInputBar compact disabled={isThinking} onSubmit={handleSend} /> </div> ); diff --git a/src/renderer/features/assistant/components/WidgetInput.tsx b/src/renderer/features/assistant/components/WidgetInput.tsx index 91e37f2e..75f29ba2 100644 --- a/src/renderer/features/assistant/components/WidgetInput.tsx +++ b/src/renderer/features/assistant/components/WidgetInput.tsx @@ -15,6 +15,7 @@ import { cn } from '@renderer/shared/lib/utils'; import { VoiceButton } from '@features/settings'; import { Button } from '@ui/button'; +import { Separator } from '@ui/separator'; import { Textarea } from '@ui/textarea'; interface WidgetInputProps { @@ -60,36 +61,39 @@ export function WidgetInput({ disabled, onSubmit }: WidgetInputProps) { } return ( - <div className="border-border flex items-end gap-2 border-t p-2.5"> - <Textarea - ref={textareaRef} - aria-label="Message assistant" - disabled={disabled} - placeholder="Ask anything..." - resize="none" - rows={1} - value={draft} - className={cn( - 'max-h-20 min-h-0 flex-1 px-2.5 py-1.5 text-xs', - )} - onChange={(e) => setDraft(e.target.value)} - onKeyDown={handleKeyDown} - /> - <VoiceButton - disabled={disabled} - size="sm" - onTranscript={handleVoiceTranscript} - /> - <Button - aria-label="Send message" - className="h-7 w-7 shrink-0 p-1.5" - disabled={disabled === true || draft.trim().length === 0} - size="icon" - variant="primary" - onClick={handleSubmit} - > - <ArrowUp className="h-3.5 w-3.5" /> - </Button> - </div> + <> + <Separator /> + <div className="flex items-end gap-2 p-2.5"> + <Textarea + ref={textareaRef} + aria-label="Message assistant" + disabled={disabled} + placeholder="Ask anything..." + resize="none" + rows={1} + value={draft} + className={cn( + 'max-h-20 min-h-0 flex-1 px-2.5 py-1.5 text-xs', + )} + onChange={(e) => setDraft(e.target.value)} + onKeyDown={handleKeyDown} + /> + <VoiceButton + disabled={disabled} + size="sm" + onTranscript={handleVoiceTranscript} + /> + <Button + aria-label="Send message" + className="h-7 w-7 shrink-0 p-1.5" + disabled={disabled === true || draft.trim().length === 0} + size="icon" + variant="primary" + onClick={handleSubmit} + > + <ArrowUp className="h-3.5 w-3.5" /> + </Button> + </div> + </> ); } diff --git a/src/renderer/features/assistant/components/WidgetMessageArea.tsx b/src/renderer/features/assistant/components/WidgetMessageArea.tsx index 5107cd7a..64c252c2 100644 --- a/src/renderer/features/assistant/components/WidgetMessageArea.tsx +++ b/src/renderer/features/assistant/components/WidgetMessageArea.tsx @@ -13,13 +13,13 @@ import { AlertCircle, MessageSquare } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; import { useAssistantWidgetStore } from '@renderer/shared/stores/assistant-widget-store'; -import { ThinkingIndicator } from '@ui'; +import { MarkdownMessage, ThinkingIndicator } from '@ui'; import { useSpeechSynthesis } from '@features/settings'; import { useAssistantStore } from '../store'; -import type { ResponseEntry } from '../store'; +import type { ChatEntry, ResponseEntry } from '../store'; const RESPONSE_STYLES: Record<ResponseEntry['type'], string> = { text: 'bg-muted/50', @@ -46,30 +46,27 @@ export function WidgetMessageArea() { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); }, [responseHistory.length, isThinking]); - // Speak new responses when voice output is enabled + // Speak new response entries when voice output is enabled useEffect(() => { if (!voiceOutputEnabled || responseHistory.length === 0) { return; } const latest = responseHistory.at(-1); - if (!latest || latest.id === lastSpokenIdRef.current) { + if (latest?.kind !== 'response' || latest.id === lastSpokenIdRef.current) { return; } - // Only speak text and action responses, not errors if (latest.type === 'error') { return; } lastSpokenIdRef.current = latest.id; - // Truncate long responses for TTS const content = latest.response.length > TTS_MAX_LENGTH ? `${latest.response.slice(0, TTS_MAX_LENGTH)}... see full response in chat` : latest.response; - // Cancel any ongoing speech before speaking new response cancel(); speak(content); }, [voiceOutputEnabled, responseHistory, speak, cancel]); @@ -79,7 +76,7 @@ export function WidgetMessageArea() { <div aria-label="Assistant messages" aria-live="polite" - className="text-muted-foreground flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center" + className="text-muted-foreground flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-6 text-center" role="log" > <MessageSquare className="h-8 w-8 opacity-30" /> @@ -93,33 +90,31 @@ export function WidgetMessageArea() { ref={scrollRef} aria-label="Assistant messages" aria-live="polite" - className="flex-1 overflow-y-auto p-3" + className="min-h-0 flex-1 overflow-y-auto p-3" role="log" > <div className="space-y-3"> - {responseHistory.map((entry) => ( - <div key={entry.id} className="space-y-1.5"> - {/* User message */} - <div className="flex justify-end"> + {responseHistory.map((entry: ChatEntry) => ( + entry.kind === 'user' ? ( + <div key={entry.id} className="flex justify-end"> <div className="bg-primary text-primary-foreground max-w-[85%] rounded-lg px-2.5 py-1.5 text-xs"> {entry.input} </div> </div> - - {/* Assistant response */} - <div className="flex gap-1.5"> + ) : ( + <div key={entry.id} className="flex gap-1.5"> <ResponseIcon type={entry.type} /> <div className={cn( 'max-w-[85%] rounded-lg px-2.5 py-1.5', - 'text-foreground text-xs leading-relaxed whitespace-pre-wrap', + 'text-foreground', RESPONSE_STYLES[entry.type], )} > - {entry.response} + <MarkdownMessage compact>{entry.response}</MarkdownMessage> </div> </div> - </div> + ) ))} {isThinking ? ( diff --git a/src/renderer/features/assistant/components/WidgetPanel.tsx b/src/renderer/features/assistant/components/WidgetPanel.tsx index f6073643..1bc1f299 100644 --- a/src/renderer/features/assistant/components/WidgetPanel.tsx +++ b/src/renderer/features/assistant/components/WidgetPanel.tsx @@ -14,13 +14,14 @@ import { Trash2, Volume2, VolumeX, X } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; import { useAssistantWidgetStore, useLayoutStore } from '@renderer/shared/stores'; -import { Button, Heading } from '@ui'; +import { Button, Heading, Separator } from '@ui'; import { useClearHistory, useSendCommand } from '../api/useAssistant'; import { useAssistantStore } from '../store'; import { AssistantInputBar } from './AssistantInputBar'; +import { ProjectSelector } from './ProjectSelector'; import { WidgetMessageArea } from './WidgetMessageArea'; interface WidgetPanelProps { @@ -35,6 +36,7 @@ export function WidgetPanel({ onClose }: WidgetPanelProps) { const voiceOutputEnabled = useAssistantWidgetStore((s) => s.voiceOutputEnabled); const toggleVoiceOutput = useAssistantWidgetStore((s) => s.toggleVoiceOutput); const activeProjectId = useLayoutStore((s) => s.activeProjectId); + const setActiveProject = useLayoutStore((s) => s.setActiveProject); const pathname = useRouterState({ select: (s) => s.location.pathname }); useEffect(() => { @@ -47,7 +49,6 @@ export function WidgetPanel({ onClose }: WidgetPanelProps) { }, []); function getActiveView(path: string): string { - if (path.includes('/roadmap')) return 'roadmap'; if (path.includes('/planner')) return 'planner'; if (path.includes('/ideation')) return 'ideation'; if (path.includes('/notes')) return 'notes'; @@ -82,8 +83,14 @@ export function WidgetPanel({ onClose }: WidgetPanelProps) { )} > {/* Header */} - <div className="border-border flex items-center justify-between border-b px-3 py-2"> - <Heading as="h2" className="text-sm">Assistant</Heading> + <div className="flex items-center justify-between px-3 py-2"> + <div className="flex items-center gap-2"> + <Heading as="h2" className="text-sm">Assistant</Heading> + <ProjectSelector + selectedProjectId={activeProjectId} + onSelect={(id) => setActiveProject(id)} + /> + </div> <div className="flex items-center gap-1"> <Button aria-label={voiceOutputEnabled ? 'Disable voice output' : 'Enable voice output'} @@ -123,6 +130,7 @@ export function WidgetPanel({ onClose }: WidgetPanelProps) { </Button> </div> </div> + <Separator /> {/* Message area */} <WidgetMessageArea /> diff --git a/src/renderer/features/assistant/hooks/useAssistantEvents.ts b/src/renderer/features/assistant/hooks/useAssistantEvents.ts index 1863ffa4..bfab05d4 100644 --- a/src/renderer/features/assistant/hooks/useAssistantEvents.ts +++ b/src/renderer/features/assistant/hooks/useAssistantEvents.ts @@ -14,16 +14,11 @@ import { useAssistantWidgetStore } from '@renderer/shared/stores'; import { assistantKeys } from '../api/queryKeys'; import { useAssistantStore } from '../store'; -/** Ref shared between useAssistantEvents and useSendCommand to track the last input */ -let _lastCommand = ''; -export function setLastCommand(input: string): void { - _lastCommand = input; -} - export function useAssistantEvents() { const queryClient = useQueryClient(); const { addResponseEntry, incrementUnread, setCurrentResponse, setIsThinking } = useAssistantStore(); const { isOpen, open } = useAssistantWidgetStore(); + useIpcEvent(ASSISTANT_EVENTS.SESSION.AUTOSTART, () => { open(); }); @@ -31,7 +26,6 @@ export function useAssistantEvents() { useIpcEvent(ASSISTANT_EVENTS.MESSAGE.RESPONSE, (payload) => { setCurrentResponse(payload.content); addResponseEntry({ - input: _lastCommand, response: payload.content, type: payload.type, }); diff --git a/src/renderer/features/assistant/index.ts b/src/renderer/features/assistant/index.ts index 537ba2f1..bb31ca21 100644 --- a/src/renderer/features/assistant/index.ts +++ b/src/renderer/features/assistant/index.ts @@ -9,7 +9,7 @@ export { SidebarAssistantButton } from './components/SidebarAssistantButton'; // Store export { useAssistantStore } from './store'; -export type { ResponseEntry } from './store'; +export type { ChatEntry, ResponseEntry, UserEntry } from './store'; // API export { assistantKeys } from './api/queryKeys'; diff --git a/src/renderer/features/assistant/store.ts b/src/renderer/features/assistant/store.ts index a33ae90a..7e1bcda4 100644 --- a/src/renderer/features/assistant/store.ts +++ b/src/renderer/features/assistant/store.ts @@ -4,30 +4,41 @@ import { create } from 'zustand'; -interface ResponseEntry { +interface UserEntry { + kind: 'user'; id: string; input: string; + timestamp: string; +} + +interface ResponseEntry { + kind: 'response'; + id: string; response: string; type: 'text' | 'error'; timestamp: string; } +type ChatEntry = UserEntry | ResponseEntry; + interface AssistantState { isThinking: boolean; currentResponse: string; commandDraft: string; - responseHistory: ResponseEntry[]; + responseHistory: ChatEntry[]; unreadCount: number; setIsThinking: (isThinking: boolean) => void; setCurrentResponse: (response: string) => void; clearCurrentResponse: () => void; setCommandDraft: (draft: string) => void; - addResponseEntry: (entry: Omit<ResponseEntry, 'id' | 'timestamp'>) => void; + addUserEntry: (input: string) => void; + addResponseEntry: (entry: { response: string; type: 'text' | 'error' }) => void; + clearHistory: () => void; incrementUnread: () => void; resetUnread: () => void; } -export type { ResponseEntry }; +export type { ChatEntry, ResponseEntry, UserEntry }; export const useAssistantStore = create<AssistantState>((set) => ({ isThinking: false, @@ -44,18 +55,35 @@ export const useAssistantStore = create<AssistantState>((set) => ({ setCommandDraft: (draft) => set({ commandDraft: draft }), - addResponseEntry: (entry) => + addUserEntry: (input) => + set((state) => ({ + responseHistory: [ + ...state.responseHistory, + { + kind: 'user' as const, + id: crypto.randomUUID(), + input, + timestamp: new Date().toISOString(), + }, + ], + })), + + addResponseEntry: ({ response, type }) => set((state) => ({ responseHistory: [ ...state.responseHistory, { - ...entry, + kind: 'response' as const, id: crypto.randomUUID(), + response, + type, timestamp: new Date().toISOString(), }, ], })), + clearHistory: () => set({ responseHistory: [] }), + incrementUnread: () => set((state) => ({ unreadCount: state.unreadCount + 1 })), resetUnread: () => set({ unreadCount: 0 }), diff --git a/src/renderer/features/auth/api/queryKeys.ts b/src/renderer/features/auth/api/queryKeys.ts deleted file mode 100644 index 41364426..00000000 --- a/src/renderer/features/auth/api/queryKeys.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Auth query key factory - */ - -export const authKeys = { - all: ['auth'] as const, - me: () => [...authKeys.all, 'me'] as const, -}; diff --git a/src/renderer/features/auth/api/useAuth.ts b/src/renderer/features/auth/api/useAuth.ts deleted file mode 100644 index 8a71c3cf..00000000 --- a/src/renderer/features/auth/api/useAuth.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * React Query hooks for auth operations - */ - -import { useCallback } from 'react'; - -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; - -import { AUTH } from '@shared/ipc/auth/channels'; -import type { LoginInput, RegisterInput } from '@shared/types/auth'; - -import { ipc } from '@renderer/shared/lib/ipc'; -import { useLayoutStore } from '@renderer/shared/stores'; - -import { useAuthStore } from '../store'; - -import { authKeys } from './queryKeys'; - -/** Login mutation — stores tokens + user on success */ -export function useLogin() { - const setAuth = useAuthStore((s) => s.setAuth); - const setExpiresAt = useAuthStore((s) => s.setExpiresAt); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (data: LoginInput) => ipc(AUTH.LOGIN.USER, data), - onSuccess: (result) => { - setAuth(result.user, result.tokens); - setExpiresAt(Date.now() + result.tokens.expiresIn * 1000); - void queryClient.invalidateQueries({ queryKey: authKeys.me() }); - }, - }); -} - -/** Register mutation — stores tokens + user on success */ -export function useRegister() { - const setAuth = useAuthStore((s) => s.setAuth); - const setExpiresAt = useAuthStore((s) => s.setExpiresAt); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (data: RegisterInput) => ipc(AUTH.REGISTER.USER, data), - onSuccess: (result) => { - setAuth(result.user, result.tokens); - setExpiresAt(Date.now() + result.tokens.expiresIn * 1000); - void queryClient.invalidateQueries({ queryKey: authKeys.me() }); - }, - }); -} - -/** Logout mutation — clears auth store and all query cache */ -export function useLogout() { - const clearAuth = useAuthStore((s) => s.clearAuth); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: () => ipc(AUTH.LOGOUT.USER, {}), - onSuccess: () => { - clearAuth(); - useLayoutStore.getState().clearLayout(); - queryClient.clear(); - }, - }); -} - -/** Refresh token mutation — updates tokens in store */ -export function useRefreshToken() { - const updateTokens = useAuthStore((s) => s.updateTokens); - const setExpiresAt = useAuthStore((s) => s.setExpiresAt); - const refreshToken = useAuthStore((s) => s.refreshToken); - - return useMutation({ - mutationFn: () => ipc(AUTH.REFRESH.TOKEN, { refreshToken: refreshToken ?? '' }), - onSuccess: (result) => { - updateTokens(result.tokens); - setExpiresAt(Date.now() + result.tokens.expiresIn * 1000); - }, - }); -} - -/** - * Force-logout helper — clears both main process tokens (via IPC) and - * renderer state. Used when token refresh fails and we need to ensure - * stale tokens are removed from both layers. - * - * The IPC call may fail if the Hub is unreachable — that's OK, we still - * clear the renderer state to force re-login. - */ -export function useForceLogout(): () => Promise<void> { - const clearAuth = useAuthStore((s) => s.clearAuth); - - return useCallback(async () => { - try { - await ipc(AUTH.LOGOUT.USER, {}); - } catch { - // Hub unreachable — ignore, we'll clear local state regardless - } - clearAuth(); - useLayoutStore.getState().clearLayout(); - }, [clearAuth]); -} - -/** Fetch current user — only runs when authenticated, staleTime: 5 minutes */ -export function useCurrentUser() { - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - - return useQuery({ - queryKey: authKeys.me(), - queryFn: () => ipc(AUTH.GET.USER, {}), - enabled: isAuthenticated, - staleTime: 300_000, - }); -} diff --git a/src/renderer/features/auth/components/AuthGuard.tsx b/src/renderer/features/auth/components/AuthGuard.tsx deleted file mode 100644 index 7c82791c..00000000 --- a/src/renderer/features/auth/components/AuthGuard.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/** - * AuthGuard — protects authenticated routes. - * - * Reads isAuthenticated and isInitializing from the auth store. While init is - * in progress, shows a spinner. Once init completes, redirects unauthenticated - * users to /login. Calls useAuthInit() to restore session via auth.restore IPC. - */ - -import { useEffect } from 'react'; - -import { Outlet, useNavigate } from '@tanstack/react-router'; - -import { ROUTES } from '@shared/constants'; - -import { useThemeSync } from '@renderer/shared/hooks'; - -import { Spinner } from '@ui'; - -import { useAuthInit } from '../hooks/useAuthEvents'; -import { useSessionEvents } from '../hooks/useSessionEvents'; -import { useTokenRefresh } from '../hooks/useTokenRefresh'; -import { useAuthStore } from '../store'; - -export function AuthGuard() { - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - const isInitializing = useAuthStore((s) => s.isInitializing); - const navigate = useNavigate(); - - // Apply theme before RootLayout mounts (AuthGuard wraps it) - useThemeSync(); - - // Restore session via auth.restore IPC on app startup - useAuthInit(); - // Clear query cache on session changes (login/logout/switch user) - useSessionEvents(); - // Proactively refresh tokens before expiry - useTokenRefresh(); - - useEffect(() => { - if (isInitializing) return; - - if (!isAuthenticated) { - void navigate({ to: ROUTES.LOGIN }); - } - }, [isInitializing, isAuthenticated, navigate]); - - // Show loading spinner during initial auth check - if (isInitializing) { - return ( - <div className="bg-background flex h-screen items-center justify-center"> - <Spinner className="text-muted-foreground" size="lg" /> - </div> - ); - } - - // Not authenticated — will redirect via the effect above - if (!isAuthenticated) { - return null; - } - - return <Outlet />; -} diff --git a/src/renderer/features/auth/components/LoginPage.tsx b/src/renderer/features/auth/components/LoginPage.tsx deleted file mode 100644 index 8cab2a03..00000000 --- a/src/renderer/features/auth/components/LoginPage.tsx +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Login page — full-page centered form with email/password and link to register. - * Includes client-side rate limiting: after 5 consecutive failed attempts, - * the submit button is disabled for 30 seconds. - * - * Uses TanStack Form + Zod validation + design system FormInput primitives. - */ - -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { useForm } from '@tanstack/react-form'; -import { X } from 'lucide-react'; -import { z } from 'zod'; - -import { Button, Card, CardContent, CardFooter, CardHeader, CardTitle, Form, FormInput, InlineAlert, Spinner } from '@ui'; - -import { useLogin } from '../api/useAuth'; -import { useSavedLogins } from '../hooks/useSavedLogins'; - -const MAX_ATTEMPTS = 5; -const COOLDOWN_SECONDS = 30; - -const loginSchema = z.object({ - email: z.email('Enter a valid email address'), - password: z.string().min(1, 'Password is required'), -}); - -interface LoginPageProps { - onNavigateToHubSetup: () => void; - onNavigateToRegister: () => void; - onSkip?: () => void; - onSuccess: () => void; -} - -export function LoginPage({ onNavigateToHubSetup, onNavigateToRegister, onSkip, onSuccess }: LoginPageProps) { - const [failedAttempts, setFailedAttempts] = useState(0); - const [cooldownRemaining, setCooldownRemaining] = useState(0); - const cooldownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); - const login = useLogin(); - const { logins: savedLogins, saveLogin, removeLogin } = useSavedLogins(); - - const isCoolingDown = cooldownRemaining > 0; - - const form = useForm({ - defaultValues: { - email: window.appInfo.devMode ? window.appInfo.devEmail : '', - password: window.appInfo.devMode ? window.appInfo.devPassword : '', - }, - validators: { - onChange: loginSchema, - }, - onSubmit: ({ value }) => { - if (isCoolingDown) return; - - login.mutate(value, { - onSuccess: () => { - saveLogin(value.email); - onSuccess(); - }, - onError: () => { - setFailedAttempts((prev) => { - const next = prev + 1; - if (next >= MAX_ATTEMPTS) { - startCooldown(); - } - return next; - }); - }, - }); - }, - }); - - // Cleanup interval on unmount - useEffect(() => { - return () => { - if (cooldownTimerRef.current !== null) { - clearInterval(cooldownTimerRef.current); - } - }; - }, []); - - const startCooldown = useCallback(() => { - setCooldownRemaining(COOLDOWN_SECONDS); - - if (cooldownTimerRef.current !== null) { - clearInterval(cooldownTimerRef.current); - } - - cooldownTimerRef.current = setInterval(() => { - setCooldownRemaining((prev) => { - if (prev <= 1) { - if (cooldownTimerRef.current !== null) { - clearInterval(cooldownTimerRef.current); - cooldownTimerRef.current = null; - } - setFailedAttempts(0); - return 0; - } - return prev - 1; - }); - }, 1000); - }, []); - - function handleFormSubmit(e: React.SyntheticEvent<HTMLFormElement>) { - e.preventDefault(); - void form.handleSubmit(); - } - - return ( - <div className="flex min-h-screen items-center justify-center bg-background"> - <Card className="w-full max-w-md"> - <CardHeader className="space-y-2 text-center"> - <div className="mx-auto mb-2 flex size-12 items-center justify-center rounded-lg bg-primary/10"> - <span className="text-xl font-bold text-primary">C</span> - </div> - <CardTitle className="text-2xl font-bold">Sign In</CardTitle> - <p className="text-sm text-muted-foreground"> - Enter your credentials to continue - </p> - </CardHeader> - - <CardContent className="space-y-4"> - {savedLogins.length > 0 ? ( - <div className="space-y-2"> - <p className="text-xs text-muted-foreground">Previous logins</p> - <div className="flex flex-wrap gap-2"> - {savedLogins.map((saved) => ( - <div - key={saved.email} - className="flex items-center gap-1 rounded-full border border-border bg-muted pl-3 pr-1 py-1 text-xs" - > - <Button - className="h-auto p-0 text-xs font-normal text-foreground hover:text-primary" - type="button" - variant="link" - onClick={() => { - form.setFieldValue('email', saved.email); - }} - > - {saved.email} - </Button> - <Button - aria-label={`Remove saved login for ${saved.email}`} - className="ml-1 size-4 rounded-full p-0 text-muted-foreground hover:bg-destructive/20 hover:text-destructive" - size="icon" - type="button" - variant="ghost" - onClick={() => { removeLogin(saved.email); }} - > - <X size={10} /> - </Button> - </div> - ))} - </div> - </div> - ) : null} - - <Form className="space-y-4" onSubmit={handleFormSubmit}> - <form.Field name="email"> - {(field) => ( - <FormInput - required - field={field} - label="Email" - placeholder="you@example.com" - type="email" - /> - )} - </form.Field> - - <form.Field name="password"> - {(field) => ( - <FormInput - required - field={field} - label="Password" - placeholder="Enter your password" - type="password" - /> - )} - </form.Field> - - {login.isError && !isCoolingDown ? ( - <InlineAlert variant="error"> - {login.error instanceof Error ? login.error.message : 'Login failed'} - {failedAttempts >= 3 && failedAttempts < MAX_ATTEMPTS ? ( - <span className="mt-1 block text-xs opacity-80"> - {MAX_ATTEMPTS - failedAttempts} attempt{MAX_ATTEMPTS - failedAttempts === 1 ? '' : 's'} remaining before lockout - </span> - ) : null} - </InlineAlert> - ) : null} - - {isCoolingDown ? ( - <InlineAlert variant="warning"> - Too many attempts. Try again in {cooldownRemaining}s - </InlineAlert> - ) : null} - - <form.Subscribe selector={(state) => [state.canSubmit]}> - {([canSubmit]) => ( - <Button - className="w-full" - disabled={!canSubmit || login.isPending || isCoolingDown} - type="submit" - variant="primary" - > - {login.isPending ? ( - <span className="flex items-center justify-center gap-2"> - <Spinner size="sm" /> - Signing in... - </span> - ) : ( - 'Sign In' - )} - </Button> - )} - </form.Subscribe> - </Form> - </CardContent> - - <CardFooter className="flex-col gap-2"> - <p className="text-center text-sm text-muted-foreground"> - Don't have an account?{' '} - <Button - className="h-auto p-0 font-medium" - type="button" - variant="link" - onClick={onNavigateToRegister} - > - Sign up - </Button> - </p> - - <p className="text-center text-sm text-muted-foreground"> - <Button - className="h-auto p-0 font-medium text-muted-foreground hover:text-foreground" - type="button" - variant="link" - onClick={onNavigateToHubSetup} - > - Change Hub server - </Button> - </p> - - {onSkip ? ( - <p className="text-center text-sm text-muted-foreground"> - <Button - className="h-auto p-0 font-medium text-muted-foreground hover:text-foreground" - type="button" - variant="link" - onClick={onSkip} - > - Skip — use locally without Hub - </Button> - </p> - ) : null} - </CardFooter> - </Card> - </div> - ); -} diff --git a/src/renderer/features/auth/components/RegisterPage.tsx b/src/renderer/features/auth/components/RegisterPage.tsx deleted file mode 100644 index 7b82ddff..00000000 --- a/src/renderer/features/auth/components/RegisterPage.tsx +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Register page — display name, email, password, confirm password. - * - * Uses TanStack Form + Zod validation + design system FormInput primitives. - * Password confirmation is validated via a form-level Zod refinement. - */ - -import { useState } from 'react'; - -import { useForm } from '@tanstack/react-form'; -import { z } from 'zod'; - -import { Button, Card, CardContent, CardFooter, CardHeader, CardTitle, Form, FormInput, InlineAlert, Spinner } from '@ui'; - -import { useRegister } from '../api/useAuth'; - -const MIN_PASSWORD_LENGTH = 8; - -const registerSchema = z - .object({ - displayName: z.string().min(1, 'Display name is required'), - email: z.email('Enter a valid email address'), - password: z - .string() - .min(MIN_PASSWORD_LENGTH, `Password must be at least ${String(MIN_PASSWORD_LENGTH)} characters`), - confirmPassword: z.string().min(1, 'Please confirm your password'), - }) - .refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match', - path: ['confirmPassword'], - }); - -interface RegisterPageProps { - onNavigateToHubSetup: () => void; - onNavigateToLogin: () => void; - onSuccess: () => void; -} - -export function RegisterPage({ onNavigateToHubSetup, onNavigateToLogin, onSuccess }: RegisterPageProps) { - const [serverError, setServerError] = useState<string | null>(null); - const register = useRegister(); - - const form = useForm({ - defaultValues: { - displayName: '', - email: '', - password: '', - confirmPassword: '', - }, - validators: { - onChange: registerSchema, - }, - onSubmit: ({ value }) => { - setServerError(null); - register.mutate( - { email: value.email, password: value.password, displayName: value.displayName }, - { - onSuccess, - onError: (error: unknown) => { - setServerError(error instanceof Error ? error.message : 'Registration failed'); - }, - }, - ); - }, - }); - - function handleFormSubmit(e: React.SyntheticEvent<HTMLFormElement>) { - e.preventDefault(); - void form.handleSubmit(); - } - - return ( - <div className="flex min-h-screen items-center justify-center bg-background"> - <Card className="w-full max-w-md"> - <CardHeader className="space-y-2 text-center"> - <div className="mx-auto mb-2 flex size-12 items-center justify-center rounded-lg bg-primary/10"> - <span className="text-xl font-bold text-primary">C</span> - </div> - <CardTitle className="text-2xl font-bold">Create Account</CardTitle> - <p className="text-sm text-muted-foreground"> - Fill in the details to get started - </p> - </CardHeader> - - <CardContent> - <Form className="space-y-4" onSubmit={handleFormSubmit}> - <form.Field name="displayName"> - {(field) => ( - <FormInput - required - field={field} - label="Display Name" - placeholder="Your name" - type="text" - /> - )} - </form.Field> - - <form.Field name="email"> - {(field) => ( - <FormInput - required - field={field} - label="Email" - placeholder="you@example.com" - type="email" - /> - )} - </form.Field> - - <form.Field name="password"> - {(field) => ( - <FormInput - required - field={field} - label="Password" - placeholder="Choose a password (min 8 characters)" - type="password" - /> - )} - </form.Field> - - <form.Field name="confirmPassword"> - {(field) => ( - <FormInput - required - field={field} - label="Confirm Password" - placeholder="Re-enter your password" - type="password" - /> - )} - </form.Field> - - {serverError === null ? null : ( - <InlineAlert variant="error">{serverError}</InlineAlert> - )} - - <form.Subscribe selector={(state) => [state.canSubmit]}> - {([canSubmit]) => ( - <Button - className="w-full" - disabled={!canSubmit || register.isPending} - type="submit" - variant="primary" - > - {register.isPending ? ( - <span className="flex items-center justify-center gap-2"> - <Spinner size="sm" /> - Creating account... - </span> - ) : ( - 'Create Account' - )} - </Button> - )} - </form.Subscribe> - </Form> - </CardContent> - - <CardFooter className="flex-col gap-2"> - <p className="text-center text-sm text-muted-foreground"> - Already have an account?{' '} - <Button - className="h-auto p-0 font-medium" - type="button" - variant="link" - onClick={onNavigateToLogin} - > - Sign in - </Button> - </p> - - <p className="text-center text-sm text-muted-foreground"> - <Button - className="h-auto p-0 font-medium text-muted-foreground hover:text-foreground" - type="button" - variant="link" - onClick={onNavigateToHubSetup} - > - Change Hub server - </Button> - </p> - </CardFooter> - </Card> - </div> - ); -} diff --git a/src/renderer/features/auth/hooks/useAuthEvents.ts b/src/renderer/features/auth/hooks/useAuthEvents.ts deleted file mode 100644 index e35fe7d6..00000000 --- a/src/renderer/features/auth/hooks/useAuthEvents.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Auth event hooks — session restoration and auth lifecycle - * - * On app startup, calls `auth.restore` IPC channel to ask the main process - * to restore the session from its encrypted token store. If successful, - * populates the auth store. If not, redirects to login. - */ - -import { useEffect, useRef } from 'react'; - -import { useQueryClient } from '@tanstack/react-query'; - -import { AUTH } from '@shared/ipc/auth/channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { authKeys } from '../api/queryKeys'; -import { useAuthStore } from '../store'; - -/** - * Restores auth session from the main process encrypted token store. - * - * Calls `auth.restore` IPC — the main process checks its encrypted - * TokenStore for a stored refresh token, refreshes via Hub, and returns - * a discriminated union: `{ restored: true, user, tokens }` or - * `{ restored: false }`. - * - * If the user is already authenticated (e.g., from a manual login during - * this session), the restore is skipped. - * - * Call this once at the app root level (AuthGuard). - */ -export function useAuthInit(): void { - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - const isInitializing = useAuthStore((s) => s.isInitializing); - const setAuth = useAuthStore((s) => s.setAuth); - const setExpiresAt = useAuthStore((s) => s.setExpiresAt); - const clearAuth = useAuthStore((s) => s.clearAuth); - const setInitializing = useAuthStore((s) => s.setInitializing); - const queryClient = useQueryClient(); - const hasRun = useRef(false); - - useEffect(() => { - if (hasRun.current) return; - hasRun.current = true; - - // Already authenticated from manual login — skip restore - if (isAuthenticated && !isInitializing) return; - - void (async () => { - try { - const result = await ipc(AUTH.RESTORE.SESSION, {}); - - if (result.restored) { - setAuth(result.user, result.tokens); - setExpiresAt(Date.now() + result.tokens.expiresIn * 1000); - void queryClient.invalidateQueries({ queryKey: authKeys.me() }); - } else if (!isAuthenticated) { - // Only clear auth if we don't already have a cached session. - // If localStorage has valid auth data from a previous session, - // keep the user logged in — the token refresh hook will retry. - clearAuth(); - } - // If restore failed but isAuthenticated is true (from localStorage), - // keep the cached session. Hub may be temporarily unreachable. - } catch { - if (!isAuthenticated) { - clearAuth(); - } - // Keep cached session on network errors - } finally { - setInitializing(false); - } - })(); - }, [isAuthenticated, isInitializing, setAuth, setExpiresAt, clearAuth, setInitializing, queryClient]); -} diff --git a/src/renderer/features/auth/hooks/useSavedLogins.ts b/src/renderer/features/auth/hooks/useSavedLogins.ts deleted file mode 100644 index de49dd1f..00000000 --- a/src/renderer/features/auth/hooks/useSavedLogins.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * useSavedLogins — persists email addresses in localStorage - * so the login page can show click-to-fill badges. - * - * Passwords are intentionally NOT stored. - */ - -import { useCallback, useState } from 'react'; - -const STORAGE_KEY = 'adc:saved-logins'; - -export interface SavedLogin { - email: string; -} - -function readFromStorage(): SavedLogin[] { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw === null) return []; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (item): item is SavedLogin => - typeof item === 'object' && - item !== null && - typeof (item as Record<string, unknown>).email === 'string', - ); - } catch { - return []; - } -} - -function writeToStorage(logins: SavedLogin[]): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(logins)); -} - -export function useSavedLogins() { - const [logins, setLogins] = useState<SavedLogin[]>(readFromStorage); - - const saveLogin = useCallback((email: string): void => { - const updated = [{ email }, ...readFromStorage().filter((l) => l.email !== email)]; - writeToStorage(updated); - setLogins(updated); - }, []); - - const removeLogin = useCallback((email: string): void => { - const updated = readFromStorage().filter((l) => l.email !== email); - writeToStorage(updated); - setLogins(updated); - }, []); - - return { logins, saveLogin, removeLogin }; -} diff --git a/src/renderer/features/auth/hooks/useSessionEvents.ts b/src/renderer/features/auth/hooks/useSessionEvents.ts deleted file mode 100644 index 4c3eae6b..00000000 --- a/src/renderer/features/auth/hooks/useSessionEvents.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Session change IPC event listeners -> query cache invalidation - * - * Listens for user session changes (login/logout) from the main process - * and clears the React Query cache to ensure stale user data is not - * displayed after switching accounts. - * - * This is critical for user-scoped storage: when the main process - * reinitializes services with a new user's data directory, the renderer - * must also clear its cached queries to fetch fresh data. - */ - -import { useQueryClient } from '@tanstack/react-query'; - -import { AUTH_EVENTS } from '@shared/ipc/auth/channels'; - -import { useIpcEvent } from '@renderer/shared/hooks'; - -/** - * Clears all cached queries when the user session changes. - * - * Call this from AuthGuard to ensure it runs for all authenticated routes. - */ -export function useSessionEvents(): void { - const queryClient = useQueryClient(); - - useIpcEvent(AUTH_EVENTS.SESSION.CHANGED, () => { - // Clear the entire query cache when session changes. - // This ensures no stale data from the previous user is displayed. - queryClient.clear(); - }); -} diff --git a/src/renderer/features/auth/hooks/useTokenRefresh.ts b/src/renderer/features/auth/hooks/useTokenRefresh.ts deleted file mode 100644 index 8800e7b4..00000000 --- a/src/renderer/features/auth/hooks/useTokenRefresh.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Proactive token refresh hook — schedules a timer to refresh the - * access token before it expires, preventing silent auth expiry - * during active sessions. - */ - -import { useEffect } from 'react'; - -import { AUTH } from '@shared/ipc/auth/channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { useForceLogout } from '../api/useAuth'; -import { useAuthStore } from '../store'; - -/** Refresh 2 minutes before token expiry */ -const REFRESH_BUFFER_MS = 2 * 60 * 1000; - -/** - * Schedules a proactive token refresh before the access token expires. - * - * Reads `expiresAt` from the auth store and sets a timer to fire - * `REFRESH_BUFFER_MS` (2 minutes) before expiry. On successful refresh, - * updates tokens and reschedules. On failure, clears auth in both the - * main process (via IPC) and renderer to force re-login. - * - * Must be called within a component that renders while authenticated - * (e.g., AuthGuard). - */ -export function useTokenRefresh(): void { - const expiresAt = useAuthStore((s) => s.expiresAt); - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - const refreshToken = useAuthStore((s) => s.refreshToken); - const updateTokens = useAuthStore((s) => s.updateTokens); - const setExpiresAt = useAuthStore((s) => s.setExpiresAt); - const forceLogout = useForceLogout(); - - useEffect(() => { - if (!isAuthenticated || expiresAt === null || !refreshToken) { - return; - } - - const delay = expiresAt - Date.now() - REFRESH_BUFFER_MS; - - const refresh = async (): Promise<void> => { - try { - const result = await ipc(AUTH.REFRESH.TOKEN, { refreshToken }); - updateTokens(result.tokens); - setExpiresAt(Date.now() + result.tokens.expiresIn * 1000); - } catch { - await forceLogout(); - } - }; - - if (delay <= 0) { - void refresh(); - return; - } - - const timerId = setTimeout(() => { - void refresh(); - }, delay); - - return () => { - clearTimeout(timerId); - }; - }, [expiresAt, isAuthenticated, refreshToken, updateTokens, setExpiresAt, forceLogout]); -} diff --git a/src/renderer/features/auth/index.ts b/src/renderer/features/auth/index.ts deleted file mode 100644 index 3c907f9c..00000000 --- a/src/renderer/features/auth/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Auth feature — public API - */ - -export { - useLogin, - useRegister, - useLogout, - useRefreshToken, - useCurrentUser, - useForceLogout, -} from './api/useAuth'; -export { authKeys } from './api/queryKeys'; -export { useAuthStore } from './store'; -export { useAuthInit } from './hooks/useAuthEvents'; -export { useSessionEvents } from './hooks/useSessionEvents'; -export { useTokenRefresh } from './hooks/useTokenRefresh'; -export { AuthGuard } from './components/AuthGuard'; -export { LoginPage } from './components/LoginPage'; -export { RegisterPage } from './components/RegisterPage'; diff --git a/src/renderer/features/auth/store.ts b/src/renderer/features/auth/store.ts deleted file mode 100644 index 645fda94..00000000 --- a/src/renderer/features/auth/store.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Auth Zustand store — manages JWT tokens, user, and isAuthenticated state. - * - * Persists tokens to localStorage for session restoration on app restart. - * isAuthenticated is derived: user !== null && accessToken !== null. - */ - -import { create } from 'zustand'; - -import type { AuthTokens, User } from '@shared/types/auth'; - -const STORAGE_KEY = 'adc-auth'; - -interface StoredAuth { - accessToken: string; - refreshToken: string; - user: User; - expiresAt: number | null; -} - -function loadStoredAuth(): StoredAuth | null { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - return JSON.parse(raw) as StoredAuth; - } catch { - return null; - } -} - -function persistAuth( - user: User, - accessToken: string, - refreshToken: string, - expiresAt: number | null, -): void { - const data: StoredAuth = { accessToken, expiresAt, refreshToken, user }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); -} - -function clearPersistedAuth(): void { - localStorage.removeItem(STORAGE_KEY); -} - -interface AuthState { - user: User | null; - accessToken: string | null; - refreshToken: string | null; - expiresAt: number | null; - isAuthenticated: boolean; - isInitializing: boolean; - setAuth: (user: User, tokens: AuthTokens) => void; - clearAuth: () => void; - setUser: (user: User) => void; - updateTokens: (tokens: AuthTokens) => void; - setExpiresAt: (expiresAt: number | null) => void; - setInitializing: (value: boolean) => void; -} - -const stored = loadStoredAuth(); - -export const useAuthStore = create<AuthState>()((set) => ({ - user: stored?.user ?? null, - accessToken: stored?.accessToken ?? null, - refreshToken: stored?.refreshToken ?? null, - expiresAt: stored?.expiresAt ?? null, - isAuthenticated: stored !== null, - isInitializing: stored !== null, - - setAuth: (user, tokens) => { - persistAuth(user, tokens.accessToken, tokens.refreshToken, null); - set({ - user, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - isAuthenticated: true, - }); - }, - - clearAuth: () => { - clearPersistedAuth(); - set({ - user: null, - accessToken: null, - refreshToken: null, - expiresAt: null, - isAuthenticated: false, - }); - }, - - setUser: (user) => { - set((state) => { - if (state.accessToken && state.refreshToken) { - persistAuth(user, state.accessToken, state.refreshToken, state.expiresAt); - } - return { user }; - }); - }, - - updateTokens: (tokens) => { - set((state) => { - if (state.user) { - persistAuth(state.user, tokens.accessToken, tokens.refreshToken, state.expiresAt); - } - return { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - }; - }); - }, - - setExpiresAt: (expiresAt) => { - set((state) => { - if (state.user && state.accessToken && state.refreshToken) { - persistAuth(state.user, state.accessToken, state.refreshToken, expiresAt); - } - return { expiresAt }; - }); - }, - - setInitializing: (value) => { - set({ isInitializing: value }); - }, -})); diff --git a/src/renderer/features/personal/briefing/api/queryKeys.ts b/src/renderer/features/briefing/api/queryKeys.ts similarity index 100% rename from src/renderer/features/personal/briefing/api/queryKeys.ts rename to src/renderer/features/briefing/api/queryKeys.ts diff --git a/src/renderer/features/personal/briefing/api/useBriefing.ts b/src/renderer/features/briefing/api/useBriefing.ts similarity index 100% rename from src/renderer/features/personal/briefing/api/useBriefing.ts rename to src/renderer/features/briefing/api/useBriefing.ts diff --git a/src/renderer/features/personal/briefing/components/BriefingConfigPanel.tsx b/src/renderer/features/briefing/components/BriefingConfigPanel/BriefingConfigPanel.tsx similarity index 75% rename from src/renderer/features/personal/briefing/components/BriefingConfigPanel.tsx rename to src/renderer/features/briefing/components/BriefingConfigPanel/BriefingConfigPanel.tsx index d77a3e7a..7d4f62da 100644 --- a/src/renderer/features/personal/briefing/components/BriefingConfigPanel.tsx +++ b/src/renderer/features/briefing/components/BriefingConfigPanel/BriefingConfigPanel.tsx @@ -3,8 +3,6 @@ * Controls: enabled toggle, scheduled time, GitHub inclusion, agent activity inclusion. */ -import { useEffect, useState } from 'react'; - import { Settings } from 'lucide-react'; import { @@ -22,7 +20,7 @@ import { Text, } from '@ui'; -import { useBriefingConfig, useUpdateBriefingConfig } from '../api/useBriefing'; +import { useBriefingConfigPanel } from './useBriefingConfigPanel'; interface BriefingConfigPanelProps { open: boolean; @@ -30,34 +28,20 @@ interface BriefingConfigPanelProps { } export function BriefingConfigPanel({ open, onClose }: BriefingConfigPanelProps) { - const { data: config, isLoading } = useBriefingConfig(); - const updateConfig = useUpdateBriefingConfig(); - - const [enabled, setEnabled] = useState(false); - const [scheduledTime, setScheduledTime] = useState('08:00'); - const [includeGitHub, setIncludeGitHub] = useState(false); - const [includeAgentActivity, setIncludeAgentActivity] = useState(false); - - // Sync form state when config loads or dialog opens - useEffect(() => { - if (config !== undefined) { - setEnabled(config.enabled); - setScheduledTime(config.scheduledTime); - setIncludeGitHub(config.includeGitHub); - setIncludeAgentActivity(config.includeAgentActivity); - } - }, [config, open]); - - function handleSave() { - updateConfig.mutate( - { enabled, scheduledTime, includeGitHub, includeAgentActivity }, - { onSuccess: onClose }, - ); - } - - function handleOpenChange(isOpen: boolean) { - if (!isOpen) onClose(); - } + const { + isLoading, + enabled, + setEnabled, + scheduledTime, + setScheduledTime, + includeGitHub, + setIncludeGitHub, + includeAgentActivity, + setIncludeAgentActivity, + handleSave, + handleOpenChange, + isPending, + } = useBriefingConfigPanel({ open, onClose }); function renderBody() { if (isLoading) { @@ -160,11 +144,11 @@ export function BriefingConfigPanel({ open, onClose }: BriefingConfigPanelProps) Cancel </Button> <Button - disabled={isLoading || updateConfig.isPending} + disabled={isLoading || isPending} onClick={handleSave} > - {updateConfig.isPending ? <Spinner size="sm" /> : null} - {updateConfig.isPending ? 'Saving...' : 'Save'} + {isPending ? <Spinner size="sm" /> : null} + {isPending ? 'Saving...' : 'Save'} </Button> </DialogFooter> </DialogContent> diff --git a/src/renderer/features/briefing/components/BriefingConfigPanel/index.ts b/src/renderer/features/briefing/components/BriefingConfigPanel/index.ts new file mode 100644 index 00000000..75c4e37b --- /dev/null +++ b/src/renderer/features/briefing/components/BriefingConfigPanel/index.ts @@ -0,0 +1 @@ +export { BriefingConfigPanel } from './BriefingConfigPanel'; diff --git a/src/renderer/features/briefing/components/BriefingConfigPanel/useBriefingConfigPanel.ts b/src/renderer/features/briefing/components/BriefingConfigPanel/useBriefingConfigPanel.ts new file mode 100644 index 00000000..faff099c --- /dev/null +++ b/src/renderer/features/briefing/components/BriefingConfigPanel/useBriefingConfigPanel.ts @@ -0,0 +1,57 @@ +/** + * useBriefingConfigPanel — all logic for BriefingConfigPanel + */ + +import { useEffect, useState } from 'react'; + +import { useBriefingConfig, useUpdateBriefingConfig } from '../../api/useBriefing'; + +interface UseBriefingConfigPanelParams { + open: boolean; + onClose: () => void; +} + +export function useBriefingConfigPanel({ open, onClose }: UseBriefingConfigPanelParams) { + const { data: config, isLoading } = useBriefingConfig(); + const updateConfig = useUpdateBriefingConfig(); + + const [enabled, setEnabled] = useState(false); + const [scheduledTime, setScheduledTime] = useState('08:00'); + const [includeGitHub, setIncludeGitHub] = useState(false); + const [includeAgentActivity, setIncludeAgentActivity] = useState(false); + + useEffect(() => { + if (config !== undefined) { + setEnabled(config.enabled); + setScheduledTime(config.scheduledTime); + setIncludeGitHub(config.includeGitHub); + setIncludeAgentActivity(config.includeAgentActivity); + } + }, [config, open]); + + function handleSave() { + updateConfig.mutate( + { enabled, scheduledTime, includeGitHub, includeAgentActivity }, + { onSuccess: onClose }, + ); + } + + function handleOpenChange(isOpen: boolean) { + if (!isOpen) onClose(); + } + + return { + isLoading, + enabled, + setEnabled, + scheduledTime, + setScheduledTime, + includeGitHub, + setIncludeGitHub, + includeAgentActivity, + setIncludeAgentActivity, + handleSave, + handleOpenChange, + isPending: updateConfig.isPending, + }; +} diff --git a/src/renderer/features/personal/briefing/components/BriefingPage.tsx b/src/renderer/features/briefing/components/BriefingPage/BriefingPage.tsx similarity index 92% rename from src/renderer/features/personal/briefing/components/BriefingPage.tsx rename to src/renderer/features/briefing/components/BriefingPage/BriefingPage.tsx index 0cf80e18..d936ee47 100644 --- a/src/renderer/features/personal/briefing/components/BriefingPage.tsx +++ b/src/renderer/features/briefing/components/BriefingPage/BriefingPage.tsx @@ -2,8 +2,6 @@ * BriefingPage — Daily briefing with tasks, agents, and suggestions */ -import { useState } from 'react'; - import { AlertCircle, CheckCircle2, @@ -18,10 +16,10 @@ import { import { Button, Card, CardContent, EmptyState, Heading, MetricCard, PageContent, PageHeader, PageLayout } from '@ui'; -import { useDailyBriefing, useGenerateBriefing, useSuggestions } from '../api/useBriefing'; +import { BriefingConfigPanel } from '../BriefingConfigPanel'; +import { SuggestionCard } from '../SuggestionCard'; -import { BriefingConfigPanel } from './BriefingConfigPanel'; -import { SuggestionCard } from './SuggestionCard'; +import { useBriefingPage } from './useBriefingPage'; function formatTime(isoString: string): string { const date = new Date(isoString); @@ -37,18 +35,18 @@ function LoadingState() { } export function BriefingPage() { - const { data: briefing, isLoading: briefingLoading } = useDailyBriefing(); - const { data: suggestions } = useSuggestions(); - const generateBriefing = useGenerateBriefing(); - const [configOpen, setConfigOpen] = useState(false); + const { + briefing, + briefingLoading, + generateBriefing, + configOpen, + setConfigOpen, + displaySuggestions, + handleGenerate, + } = useBriefingPage(); - const displaySuggestions = briefing?.suggestions ?? suggestions ?? []; const hasBriefing = briefing !== null && briefing !== undefined; - function handleGenerate(): void { - generateBriefing.mutate(); - } - function renderContent() { if (briefingLoading) { return <LoadingState />; diff --git a/src/renderer/features/briefing/components/BriefingPage/index.ts b/src/renderer/features/briefing/components/BriefingPage/index.ts new file mode 100644 index 00000000..1a79027c --- /dev/null +++ b/src/renderer/features/briefing/components/BriefingPage/index.ts @@ -0,0 +1 @@ +export { BriefingPage } from './BriefingPage'; diff --git a/src/renderer/features/briefing/components/BriefingPage/useBriefingPage.ts b/src/renderer/features/briefing/components/BriefingPage/useBriefingPage.ts new file mode 100644 index 00000000..3a9ad389 --- /dev/null +++ b/src/renderer/features/briefing/components/BriefingPage/useBriefingPage.ts @@ -0,0 +1,30 @@ +/** + * useBriefingPage — all logic for BriefingPage + */ + +import { useState } from 'react'; + +import { useDailyBriefing, useGenerateBriefing, useSuggestions } from '../../api/useBriefing'; + +export function useBriefingPage() { + const { data: briefing, isLoading: briefingLoading } = useDailyBriefing(); + const { data: suggestions } = useSuggestions(); + const generateBriefing = useGenerateBriefing(); + const [configOpen, setConfigOpen] = useState(false); + + const displaySuggestions = briefing?.suggestions ?? suggestions ?? []; + + function handleGenerate(): void { + generateBriefing.mutate(); + } + + return { + briefing, + briefingLoading, + generateBriefing, + configOpen, + setConfigOpen, + displaySuggestions, + handleGenerate, + }; +} diff --git a/src/renderer/features/briefing/components/SuggestionCard/SuggestionCard.tsx b/src/renderer/features/briefing/components/SuggestionCard/SuggestionCard.tsx new file mode 100644 index 00000000..a0498765 --- /dev/null +++ b/src/renderer/features/briefing/components/SuggestionCard/SuggestionCard.tsx @@ -0,0 +1,62 @@ +/** + * SuggestionCard — Display a proactive suggestion with action + */ + +import { AlertCircle, GitBranch, Layers, Zap } from 'lucide-react'; + +import type { Suggestion, SuggestionType } from '@shared/types'; + +import { Button, Card, CardContent, Heading } from '@ui'; + +import { useSuggestionCard } from './useSuggestionCard'; + +const SUGGESTION_ICONS: Record<SuggestionType, React.ElementType> = { + stale_project: GitBranch, + parallel_tasks: Layers, + blocked_task: AlertCircle, +}; + +const SUGGESTION_COLORS: Record<SuggestionType, string> = { + stale_project: 'text-muted-foreground', + parallel_tasks: 'text-info', + blocked_task: 'text-warning', +}; + +interface SuggestionCardProps { + suggestion: Suggestion; +} + +export function SuggestionCard({ suggestion }: SuggestionCardProps) { + const { handleAction, handleKeyDown } = useSuggestionCard({ suggestion }); + const IconComponent = SUGGESTION_ICONS[suggestion.type]; + const iconColor = SUGGESTION_COLORS[suggestion.type]; + + return ( + <Card> + <CardContent className="p-4"> + <div className="flex items-start gap-3"> + <div className={`mt-0.5 ${iconColor}`}> + <IconComponent className="h-5 w-5" /> + </div> + <div className="flex-1"> + <Heading as="h4" className="text-sm">{suggestion.title}</Heading> + <p className="text-muted-foreground mt-1 text-sm">{suggestion.description}</p> + {suggestion.action === undefined ? null : ( + <Button + className="mt-3 gap-1" + size="sm" + type="button" + variant="outline" + onClick={handleAction} + onKeyDown={handleKeyDown} + > + <Zap className="h-3 w-3" /> + {suggestion.action.label} + </Button> + )} + </div> + </div> + </CardContent> + </Card> + ); +} diff --git a/src/renderer/features/briefing/components/SuggestionCard/index.ts b/src/renderer/features/briefing/components/SuggestionCard/index.ts new file mode 100644 index 00000000..31a41ca2 --- /dev/null +++ b/src/renderer/features/briefing/components/SuggestionCard/index.ts @@ -0,0 +1 @@ +export { SuggestionCard } from './SuggestionCard'; diff --git a/src/renderer/features/briefing/components/SuggestionCard/useSuggestionCard.ts b/src/renderer/features/briefing/components/SuggestionCard/useSuggestionCard.ts new file mode 100644 index 00000000..dac37dc6 --- /dev/null +++ b/src/renderer/features/briefing/components/SuggestionCard/useSuggestionCard.ts @@ -0,0 +1,41 @@ +/** + * useSuggestionCard — all logic for SuggestionCard + */ + +import { useNavigate } from '@tanstack/react-router'; + +import { ROUTES, ROUTE_PATTERNS } from '@shared/constants'; +import type { Suggestion } from '@shared/types'; + +interface UseSuggestionCardParams { + suggestion: Suggestion; +} + +export function useSuggestionCard({ suggestion }: UseSuggestionCardParams) { + const navigate = useNavigate(); + + function handleAction(): void { + if (suggestion.action === undefined) return; + + const { targetId, targetType } = suggestion.action; + if (targetId === undefined) return; + + if (targetType === 'project') { + void navigate({ to: ROUTE_PATTERNS.PROJECT_TASKS, params: { projectId: targetId } }); + } else if (targetType === 'task') { + void navigate({ to: ROUTES.MY_WORK }); + } + } + + function handleKeyDown(event: React.KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleAction(); + } + } + + return { + handleAction, + handleKeyDown, + }; +} diff --git a/src/renderer/features/personal/briefing/index.ts b/src/renderer/features/briefing/index.ts similarity index 100% rename from src/renderer/features/personal/briefing/index.ts rename to src/renderer/features/briefing/index.ts diff --git a/src/renderer/features/personal/briefing/store.ts b/src/renderer/features/briefing/store.ts similarity index 100% rename from src/renderer/features/personal/briefing/store.ts rename to src/renderer/features/briefing/store.ts diff --git a/src/renderer/features/personal/changelog/api/queryKeys.ts b/src/renderer/features/changelog/api/queryKeys.ts similarity index 100% rename from src/renderer/features/personal/changelog/api/queryKeys.ts rename to src/renderer/features/changelog/api/queryKeys.ts diff --git a/src/renderer/features/personal/changelog/api/useChangelog.ts b/src/renderer/features/changelog/api/useChangelog.ts similarity index 96% rename from src/renderer/features/personal/changelog/api/useChangelog.ts rename to src/renderer/features/changelog/api/useChangelog.ts index 35d41ff2..d4871db2 100644 --- a/src/renderer/features/personal/changelog/api/useChangelog.ts +++ b/src/renderer/features/changelog/api/useChangelog.ts @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { CHANGELOG } from '@shared/ipc/misc/changelog.channels'; +import { CHANGELOG } from '@shared/ipc/changelog'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/personal/changelog/components/CategorySection.tsx b/src/renderer/features/changelog/components/CategorySection.tsx similarity index 100% rename from src/renderer/features/personal/changelog/components/CategorySection.tsx rename to src/renderer/features/changelog/components/CategorySection.tsx diff --git a/src/renderer/features/changelog/components/ChangelogPage/ChangelogPage.tsx b/src/renderer/features/changelog/components/ChangelogPage/ChangelogPage.tsx new file mode 100644 index 00000000..84cff833 --- /dev/null +++ b/src/renderer/features/changelog/components/ChangelogPage/ChangelogPage.tsx @@ -0,0 +1,117 @@ +import { GitBranch, ScrollText, X } from 'lucide-react'; + +import { Button, Card, CardContent, EmptyState, Heading } from '@ui'; + +import { EntryPreview } from '../EntryPreview'; +import { GenerateForm } from '../GenerateForm'; +import { VersionCard } from '../VersionCard'; + +import { useChangelogPage } from './useChangelogPage'; + +export function ChangelogPage() { + const { + items, + isLoading, + showGenerateDialog, + setShowGenerateDialog, + repoPath, + setRepoPath, + version, + setVersion, + fromTag, + setFromTag, + generatedEntry, + isEditing, + editableCategories, + isPending, + isSavePending, + errorMessage, + handleGenerate, + handleSaveEntry, + handleCloseDialog, + handleRemoveItem, + handleBackToForm, + } = useChangelogPage(); + + return ( + <div className="space-y-6 p-6"> + {/* Actions */} + <div className="flex items-center gap-2"> + <Button onClick={() => setShowGenerateDialog(true)}> + <GitBranch className="h-4 w-4" /> + Generate from Git + </Button> + </div> + {/* Generate Dialog */} + {showGenerateDialog ? ( + <Card className="mb-6"> + <CardContent className="p-6"> + <div className="mb-4 flex items-center justify-between"> + <Heading as="h3">Generate Changelog from Git</Heading> + <Button + aria-label="Close" + size="icon" + type="button" + variant="ghost" + onClick={handleCloseDialog} + > + <X className="h-5 w-5" /> + </Button> + </div> + + {isEditing && generatedEntry ? ( + <EntryPreview + categories={editableCategories} + entry={generatedEntry} + isSaving={isSavePending} + onBack={handleBackToForm} + onRemoveItem={handleRemoveItem} + onSave={handleSaveEntry} + /> + ) : ( + <GenerateForm + errorMessage={errorMessage} + fromTag={fromTag} + isPending={isPending} + repoPath={repoPath} + version={version} + onFromTagChange={setFromTag} + onGenerate={handleGenerate} + onRepoPathChange={setRepoPath} + onVersionChange={setVersion} + /> + )} + </CardContent> + </Card> + ) : null} + + {/* Timeline */} + {isLoading ? ( + <div className="text-muted-foreground flex items-center justify-center py-12"> + Loading changelog... + </div> + ) : null} + + {!isLoading && items.length > 0 ? ( + <div className="relative space-y-8"> + {/* Timeline line */} + <div className="bg-border absolute top-0 bottom-0 left-[5px] w-px" /> + + {items.map((entry) => ( + <VersionCard key={entry.version} entry={entry} /> + ))} + </div> + ) : null} + + {!isLoading && items.length === 0 ? ( + <EmptyState + className="border-border rounded-lg border border-dashed" + description="Entries will appear here as releases are published" + icon={ScrollText} + size="md" + title="No changelog entries" + /> + ) : null} + </div> + ); +} diff --git a/src/renderer/features/changelog/components/ChangelogPage/index.ts b/src/renderer/features/changelog/components/ChangelogPage/index.ts new file mode 100644 index 00000000..131f9e00 --- /dev/null +++ b/src/renderer/features/changelog/components/ChangelogPage/index.ts @@ -0,0 +1,2 @@ +export { ChangelogPage } from './ChangelogPage'; +export { useChangelogPage } from './useChangelogPage'; diff --git a/src/renderer/features/changelog/components/ChangelogPage/useChangelogPage.ts b/src/renderer/features/changelog/components/ChangelogPage/useChangelogPage.ts new file mode 100644 index 00000000..50aa50b7 --- /dev/null +++ b/src/renderer/features/changelog/components/ChangelogPage/useChangelogPage.ts @@ -0,0 +1,113 @@ +import { useState } from 'react'; + +import type { ChangeCategory, ChangelogEntry, ChangeType } from '@shared/types'; + +import { useAddChangelogEntry, useChangelog, useGenerateChangelog } from '../../api/useChangelog'; + +export function useChangelogPage() { + const { data: entries, isLoading } = useChangelog(); + const generateChangelog = useGenerateChangelog(); + const addEntry = useAddChangelogEntry(); + const items = entries ?? []; + + const [showGenerateDialog, setShowGenerateDialog] = useState(false); + const [repoPath, setRepoPath] = useState(''); + const [version, setVersion] = useState(''); + const [fromTag, setFromTag] = useState(''); + const [generatedEntry, setGeneratedEntry] = useState<ChangelogEntry | null>(null); + const [isEditing, setIsEditing] = useState(false); + const [editableCategories, setEditableCategories] = useState<ChangeCategory[]>([]); + + function handleGenerate(): void { + if (!repoPath.trim() || !version.trim()) return; + + generateChangelog.mutate( + { + repoPath: repoPath.trim(), + version: version.trim(), + fromTag: fromTag.trim() || undefined, + }, + { + onSuccess: (entry) => { + setGeneratedEntry(entry); + setEditableCategories(entry.categories); + setIsEditing(true); + }, + }, + ); + } + + function handleSaveEntry(): void { + if (!generatedEntry) return; + + addEntry.mutate( + { + version: generatedEntry.version, + date: generatedEntry.date, + categories: editableCategories, + }, + { + onSuccess: () => { + setShowGenerateDialog(false); + setGeneratedEntry(null); + setIsEditing(false); + setEditableCategories([]); + setRepoPath(''); + setVersion(''); + setFromTag(''); + }, + }, + ); + } + + function handleCloseDialog(): void { + setShowGenerateDialog(false); + setGeneratedEntry(null); + setIsEditing(false); + setEditableCategories([]); + } + + function handleRemoveItem(categoryType: ChangeType, itemIndex: number): void { + setEditableCategories((prev) => + prev + .map((cat) => { + if (cat.type !== categoryType) return cat; + const newItems = cat.items.filter((_, idx) => idx !== itemIndex); + return { ...cat, items: newItems }; + }) + .filter((cat) => cat.items.length > 0), + ); + } + + function handleBackToForm(): void { + setIsEditing(false); + setGeneratedEntry(null); + setEditableCategories([]); + } + + const errorMessage = generateChangelog.isError ? generateChangelog.error.message : null; + + return { + items, + isLoading, + showGenerateDialog, + setShowGenerateDialog, + repoPath, + setRepoPath, + version, + setVersion, + fromTag, + setFromTag, + generatedEntry, + isEditing, + editableCategories, + isPending: generateChangelog.isPending, + isSavePending: addEntry.isPending, + errorMessage, + handleGenerate, + handleSaveEntry, + handleCloseDialog, + handleRemoveItem, + handleBackToForm, + }; +} diff --git a/src/renderer/features/changelog/components/EditEntryDialog/EditEntryDialog.tsx b/src/renderer/features/changelog/components/EditEntryDialog/EditEntryDialog.tsx new file mode 100644 index 00000000..19797d30 --- /dev/null +++ b/src/renderer/features/changelog/components/EditEntryDialog/EditEntryDialog.tsx @@ -0,0 +1,205 @@ +/** + * EditEntryDialog — Edit an existing changelog entry's date and categories + */ + +import { Minus, Plus } from 'lucide-react'; + +import type { ChangelogEntry, ChangeType } from '@shared/types'; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Separator, + Text, +} from '@ui'; + +import { useEditEntryDialog } from './useEditEntryDialog'; + +interface EditEntryDialogProps { + entry: ChangelogEntry; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditEntryDialog({ entry, open, onOpenChange }: EditEntryDialogProps) { + const { + version, + setVersion, + date, + setDate, + categories, + newItemTexts, + setNewItemTexts, + usedTypes, + hasAvailableTypes, + isSaveDisabled, + isPending, + changeTypes, + handleAddCategory, + handleRemoveCategory, + handleCategoryTypeChange, + handleAddItem, + handleRemoveItem, + handleNewItemKeyDown, + handleSave, + handleOpenChange, + } = useEditEntryDialog(entry, onOpenChange); + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="max-h-[80vh] max-w-lg overflow-y-auto"> + <DialogHeader> + <DialogTitle>Edit Changelog Entry</DialogTitle> + </DialogHeader> + + <div className="space-y-4 py-2"> + {/* Version */} + <div> + <Label htmlFor="edit-version">Version</Label> + <Input + className="mt-1" + id="edit-version" + placeholder="v1.0.0" + type="text" + value={version} + onChange={(e) => setVersion(e.target.value)} + /> + </div> + + {/* Date */} + <div> + <Label htmlFor="edit-date">Date</Label> + <Input + className="mt-1" + id="edit-date" + placeholder="January 2026" + type="text" + value={date} + onChange={(e) => setDate(e.target.value)} + /> + </div> + + <Separator /> + + {/* Categories */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <Label>Categories</Label> + {hasAvailableTypes ? ( + <Button size="sm" type="button" variant="outline" onClick={handleAddCategory}> + <Plus className="h-3 w-3" /> + Add Category + </Button> + ) : null} + </div> + + {categories.map((category) => ( + <div key={category.type} className="border-border rounded-md border p-3 space-y-3"> + {/* Category header */} + <div className="flex items-center gap-2"> + <Select + value={category.type} + onValueChange={(val) => + handleCategoryTypeChange(category.type, val as ChangeType) + } + > + <SelectTrigger className="w-36"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {changeTypes.map((t) => ( + <SelectItem + key={t} + disabled={usedTypes.has(t) && t !== category.type} + value={t} + > + {t.charAt(0).toUpperCase() + t.slice(1)} + </SelectItem> + ))} + </SelectContent> + </Select> + <Button + aria-label={`Remove ${category.type} category`} + className="ml-auto" + size="sm" + type="button" + variant="ghost" + onClick={() => handleRemoveCategory(category.type)} + > + <Minus className="h-3 w-3" /> + </Button> + </div> + + {/* Items */} + <ul className="space-y-1"> + {category.items.map((item, idx) => ( + <li key={`${category.type}-${item.slice(0, 30)}`} className="flex items-center gap-2"> + <Text className="flex-1" size="sm"> + {item} + </Text> + <Button + aria-label="Remove item" + className="h-6 w-6 shrink-0" + size="icon" + type="button" + variant="ghost" + onClick={() => handleRemoveItem(category.type, idx)} + > + <Minus className="h-3 w-3" /> + </Button> + </li> + ))} + </ul> + + {/* Add item input */} + <div className="flex items-center gap-2"> + <Input + className="flex-1" + placeholder="Add item..." + type="text" + value={newItemTexts[category.type] ?? ''} + onChange={(e) => + setNewItemTexts((prev) => ({ ...prev, [category.type]: e.target.value })) + } + onKeyDown={(e) => { + handleNewItemKeyDown(e, category.type); + }} + /> + <Button + aria-label="Add item" + size="icon" + type="button" + variant="outline" + onClick={() => handleAddItem(category.type)} + > + <Plus className="h-4 w-4" /> + </Button> + </div> + </div> + ))} + </div> + </div> + + <DialogFooter> + <Button type="button" variant="outline" onClick={() => handleOpenChange(false)}> + Cancel + </Button> + <Button disabled={isSaveDisabled} type="button" onClick={handleSave}> + {isPending ? 'Saving...' : 'Save Changes'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/changelog/components/EditEntryDialog/index.ts b/src/renderer/features/changelog/components/EditEntryDialog/index.ts new file mode 100644 index 00000000..e131ed94 --- /dev/null +++ b/src/renderer/features/changelog/components/EditEntryDialog/index.ts @@ -0,0 +1,2 @@ +export { EditEntryDialog } from './EditEntryDialog'; +export { useEditEntryDialog } from './useEditEntryDialog'; diff --git a/src/renderer/features/changelog/components/EditEntryDialog/useEditEntryDialog.ts b/src/renderer/features/changelog/components/EditEntryDialog/useEditEntryDialog.ts new file mode 100644 index 00000000..a77bd384 --- /dev/null +++ b/src/renderer/features/changelog/components/EditEntryDialog/useEditEntryDialog.ts @@ -0,0 +1,125 @@ +import { useState } from 'react'; + +import type { ChangeCategory, ChangelogEntry, ChangeType } from '@shared/types'; + +import { useUpdateChangelogEntry } from '../../api/useChangelog'; + +const CHANGE_TYPES: ChangeType[] = ['added', 'changed', 'fixed', 'removed', 'security', 'deprecated']; + +export function useEditEntryDialog( + entry: ChangelogEntry, + onOpenChange: (open: boolean) => void, +) { + const updateEntry = useUpdateChangelogEntry(); + + const [version, setVersion] = useState(entry.version); + const [date, setDate] = useState(entry.date); + const [categories, setCategories] = useState<ChangeCategory[]>(entry.categories); + const [newItemTexts, setNewItemTexts] = useState<Record<string, string>>({}); + + function handleAddCategory(): void { + const usedTypes = new Set(categories.map((c) => c.type)); + const nextType = CHANGE_TYPES.find((t) => !usedTypes.has(t)); + if (!nextType) return; + setCategories((prev) => [...prev, { type: nextType, items: [] }]); + } + + function handleRemoveCategory(categoryType: ChangeType): void { + setCategories((prev) => prev.filter((c) => c.type !== categoryType)); + } + + function handleCategoryTypeChange(oldType: ChangeType, newType: ChangeType): void { + setCategories((prev) => + prev.map((c) => (c.type === oldType ? { ...c, type: newType } : c)), + ); + setNewItemTexts((prev) => { + const { [oldType]: movedText, ...rest } = prev; + return movedText ? { ...rest, [newType]: movedText } : rest; + }); + } + + function handleAddItem(categoryType: ChangeType): void { + const text = (newItemTexts[categoryType] ?? '').trim(); + if (!text) return; + setCategories((prev) => + prev.map((c) => + c.type === categoryType ? { ...c, items: [...c.items, text] } : c, + ), + ); + setNewItemTexts((prev) => ({ ...prev, [categoryType]: '' })); + } + + function handleRemoveItem(categoryType: ChangeType, itemIndex: number): void { + setCategories((prev) => + prev + .map((c) => { + if (c.type !== categoryType) return c; + return { ...c, items: c.items.filter((_, idx) => idx !== itemIndex) }; + }) + .filter((c) => c.items.length > 0 || c.type === categoryType), + ); + } + + function handleNewItemKeyDown(e: React.KeyboardEvent, categoryType: ChangeType): void { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddItem(categoryType); + } + } + + function handleSave(): void { + const nonEmptyCategories = categories.filter((c) => c.items.length > 0); + updateEntry.mutate( + { + version: entry.version, + updates: { + version: version.trim() || entry.version, + date: date.trim() || entry.date, + categories: nonEmptyCategories, + }, + }, + { + onSuccess: () => { + onOpenChange(false); + }, + }, + ); + } + + function handleOpenChange(nextOpen: boolean): void { + if (!nextOpen) { + setVersion(entry.version); + setDate(entry.date); + setCategories(entry.categories); + setNewItemTexts({}); + } + onOpenChange(nextOpen); + } + + const usedTypes = new Set(categories.map((c) => c.type)); + const hasAvailableTypes = usedTypes.size < CHANGE_TYPES.length; + const isSaveDisabled = !version.trim() || !date.trim() || updateEntry.isPending; + + return { + version, + setVersion, + date, + setDate, + categories, + newItemTexts, + setNewItemTexts, + usedTypes, + hasAvailableTypes, + isSaveDisabled, + isPending: updateEntry.isPending, + changeTypes: CHANGE_TYPES, + handleAddCategory, + handleRemoveCategory, + handleCategoryTypeChange, + handleAddItem, + handleRemoveItem, + handleNewItemKeyDown, + handleSave, + handleOpenChange, + }; +} diff --git a/src/renderer/features/personal/changelog/components/EditableCategory.tsx b/src/renderer/features/changelog/components/EditableCategory.tsx similarity index 100% rename from src/renderer/features/personal/changelog/components/EditableCategory.tsx rename to src/renderer/features/changelog/components/EditableCategory.tsx diff --git a/src/renderer/features/personal/changelog/components/EntryPreview.tsx b/src/renderer/features/changelog/components/EntryPreview.tsx similarity index 100% rename from src/renderer/features/personal/changelog/components/EntryPreview.tsx rename to src/renderer/features/changelog/components/EntryPreview.tsx diff --git a/src/renderer/features/personal/changelog/components/GenerateForm.tsx b/src/renderer/features/changelog/components/GenerateForm.tsx similarity index 100% rename from src/renderer/features/personal/changelog/components/GenerateForm.tsx rename to src/renderer/features/changelog/components/GenerateForm.tsx diff --git a/src/renderer/features/personal/changelog/components/VersionCard.tsx b/src/renderer/features/changelog/components/VersionCard/VersionCard.tsx similarity index 86% rename from src/renderer/features/personal/changelog/components/VersionCard.tsx rename to src/renderer/features/changelog/components/VersionCard/VersionCard.tsx index 77da8568..4596f6c2 100644 --- a/src/renderer/features/personal/changelog/components/VersionCard.tsx +++ b/src/renderer/features/changelog/components/VersionCard/VersionCard.tsx @@ -2,8 +2,6 @@ * VersionCard — Displays a single changelog version with edit/delete actions */ -import { useState } from 'react'; - import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; import type { ChangeCategory } from '@shared/types'; @@ -22,26 +20,22 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + Heading, Text, } from '@ui'; -import { useDeleteChangelogEntry } from '../api/useChangelog'; +import { CategorySection } from '../CategorySection'; +import { EditEntryDialog } from '../EditEntryDialog'; -import { CategorySection } from './CategorySection'; -import { EditEntryDialog } from './EditEntryDialog'; +import { useVersionCard } from './useVersionCard'; interface VersionCardProps { entry: { version: string; date: string; categories: ChangeCategory[] }; } export function VersionCard({ entry }: VersionCardProps) { - const deleteEntry = useDeleteChangelogEntry(); - const [isEditOpen, setIsEditOpen] = useState(false); - const [isDeleteOpen, setIsDeleteOpen] = useState(false); - - function handleDeleteConfirm(): void { - deleteEntry.mutate({ version: entry.version }); - } + const { isEditOpen, setIsEditOpen, isDeleteOpen, setIsDeleteOpen, handleDeleteConfirm } = + useVersionCard(entry.version); const hasCategories = entry.categories.length > 0; @@ -52,10 +46,10 @@ export function VersionCard({ entry }: VersionCardProps) { {/* Version header */} <div className="mb-3 flex items-center justify-between"> - <h3 className="text-foreground text-lg font-semibold"> + <Heading as="h3"> {entry.version}{' '} <span className="text-muted-foreground text-sm font-normal">-- {entry.date}</span> - </h3> + </Heading> <DropdownMenu> <DropdownMenuTrigger asChild> diff --git a/src/renderer/features/changelog/components/VersionCard/index.ts b/src/renderer/features/changelog/components/VersionCard/index.ts new file mode 100644 index 00000000..8a04d7fe --- /dev/null +++ b/src/renderer/features/changelog/components/VersionCard/index.ts @@ -0,0 +1,2 @@ +export { VersionCard } from './VersionCard'; +export { useVersionCard } from './useVersionCard'; diff --git a/src/renderer/features/changelog/components/VersionCard/useVersionCard.ts b/src/renderer/features/changelog/components/VersionCard/useVersionCard.ts new file mode 100644 index 00000000..c63521fa --- /dev/null +++ b/src/renderer/features/changelog/components/VersionCard/useVersionCard.ts @@ -0,0 +1,21 @@ +import { useState } from 'react'; + +import { useDeleteChangelogEntry } from '../../api/useChangelog'; + +export function useVersionCard(version: string) { + const deleteEntry = useDeleteChangelogEntry(); + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + + function handleDeleteConfirm(): void { + deleteEntry.mutate({ version }); + } + + return { + isEditOpen, + setIsEditOpen, + isDeleteOpen, + setIsDeleteOpen, + handleDeleteConfirm, + }; +} diff --git a/src/renderer/features/personal/changelog/components/category-config.ts b/src/renderer/features/changelog/components/category-config.ts similarity index 100% rename from src/renderer/features/personal/changelog/components/category-config.ts rename to src/renderer/features/changelog/components/category-config.ts diff --git a/src/renderer/features/personal/changelog/index.ts b/src/renderer/features/changelog/index.ts similarity index 100% rename from src/renderer/features/personal/changelog/index.ts rename to src/renderer/features/changelog/index.ts diff --git a/src/renderer/features/personal/changelog/store.ts b/src/renderer/features/changelog/store.ts similarity index 100% rename from src/renderer/features/personal/changelog/store.ts rename to src/renderer/features/changelog/store.ts diff --git a/src/renderer/features/dashboard/components/ActiveAgents.tsx b/src/renderer/features/dashboard/components/ActiveAgents.tsx deleted file mode 100644 index 1215e168..00000000 --- a/src/renderer/features/dashboard/components/ActiveAgents.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/** - * ActiveAgents — Shows running agent sessions from the agent dashboard - * and workspace/progress systems. Reads from useWorkspaceSessions (React Query) - * and useAllAgents (agent dashboard) to provide a unified view. - */ - -import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react'; - -import { cn, formatRelativeTime } from '@renderer/shared/lib/utils'; -import { useLayoutStore } from '@renderer/shared/stores/layout-store'; - -import { Card, CardContent, EmptyState, Text } from '@ui'; - -import { useAllAgents } from '@features/agents'; -import { useWorkspaceSessions } from '@features/workspace/api/useWorkspace'; - -// ─── Status Mapping ──────────────────────────────────────── - -const STATUS_ICON = { - active: { icon: Loader2, className: 'text-info' }, - spawning: { icon: Loader2, className: 'text-info' }, - live: { icon: Loader2, className: 'text-info' }, - starting: { icon: Loader2, className: 'text-info' }, - running: { icon: Loader2, className: 'text-info' }, - idle: { icon: Loader2, className: 'text-info' }, - completed: { icon: CheckCircle2, className: 'text-success' }, - failed: { icon: XCircle, className: 'text-destructive' }, - error: { icon: XCircle, className: 'text-destructive' }, - crashed: { icon: XCircle, className: 'text-destructive' }, -} as const; - -type KnownStatus = keyof typeof STATUS_ICON; - -function getStatusConfig(status: string) { - if (status in STATUS_ICON) { - return STATUS_ICON[status as KnownStatus]; - } - return STATUS_ICON.active; -} - -function isActive(status: string): boolean { - return status === 'active' || status === 'spawning' || status === 'live' || status === 'starting' || status === 'running' || status === 'idle'; -} - -// ─── Unified Agent Entry ──────────────────────────────────── - -interface AgentEntry { - id: string; - label: string; - detail: string; - status: string; - startedAt: number | null; -} - -// ─── Component ────────────────────────────────────────────── - -export function ActiveAgents() { - // Workspace sessions via React Query (primary + team-leads from AgentManagerService) - const activeProjectId = useLayoutStore((s) => s.activeProjectId); - const { data: workspaceSessions = [], isLoading: wsLoading } = - useWorkspaceSessions(activeProjectId); - - // Agent dashboard sessions - const { data: dashboardSessions, isLoading: dashLoading } = useAllAgents(); - - const isLoading = wsLoading || dashLoading; - - // Build unified entry list - const entries: AgentEntry[] = []; - - // Workspace sessions -> entries - for (const ws of workspaceSessions) { - if (!isActive(ws.status)) continue; - const typeLabel = ws.key.type === 'primary' ? 'Primary' : `Team Lead #${String(ws.key.index)}`; - entries.push({ - id: `ws-${ws.agentSessionId}`, - label: typeLabel, - detail: ws.model, - status: ws.status, - startedAt: ws.startedAt, - }); - } - - // Dashboard sessions -> entries (deduplicate by session ID) - const wsIds = new Set(workspaceSessions.map((s) => s.agentSessionId)); - const dashList = Array.isArray(dashboardSessions) ? dashboardSessions : []; - for (const dash of dashList) { - if (wsIds.has(dash.id)) continue; // already shown via workspace - if (!isActive(dash.status)) continue; - entries.push({ - id: `dash-${dash.id}`, - label: dash.name, - detail: `${dash.type} · ${dash.model}`, - status: dash.status, - startedAt: new Date(dash.startedAt).getTime(), - }); - } - - if (isLoading && entries.length === 0) { - return ( - <Card> - <CardContent className="p-4"> - <Text className="mb-3 font-semibold" size="sm">Active Agents</Text> - <div className="flex items-center justify-center py-6"> - <Loader2 className="text-muted-foreground h-5 w-5 animate-spin" /> - </div> - </CardContent> - </Card> - ); - } - - return ( - <Card> - <CardContent className="p-4"> - <Text className="mb-3 font-semibold" size="sm">Active Agents</Text> - - {entries.length > 0 ? ( - <div className="space-y-3"> - {entries.map((entry) => { - const config = getStatusConfig(entry.status); - const StatusIcon = config.icon; - - return ( - <div key={entry.id} className="space-y-1.5"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - <StatusIcon - className={cn( - 'h-3.5 w-3.5', - config.className, - isActive(entry.status) && 'animate-spin', - )} - /> - <Text className="font-medium" size="sm">{entry.label}</Text> - </div> - <Text size="sm" variant="muted">{entry.status}</Text> - </div> - <Text className="truncate pl-5.5" size="sm" variant="muted"> - {entry.detail} - {entry.startedAt === null ? '' : ` · ${formatRelativeTime(new Date(entry.startedAt).toISOString())}`} - </Text> - </div> - ); - })} - </div> - ) : ( - <EmptyState - description="No agents running" - icon={Bot} - size="sm" - title="No active agents" - /> - )} - </CardContent> - </Card> - ); -} diff --git a/src/renderer/features/dashboard/components/ActiveAgents/ActiveAgents.tsx b/src/renderer/features/dashboard/components/ActiveAgents/ActiveAgents.tsx new file mode 100644 index 00000000..357cc00a --- /dev/null +++ b/src/renderer/features/dashboard/components/ActiveAgents/ActiveAgents.tsx @@ -0,0 +1,100 @@ +/** + * ActiveAgents — Shows running agent sessions from the agent dashboard + * and workspace/progress systems. Reads from useWorkspaceSessions (React Query) + * and useAllAgents (agent dashboard) to provide a unified view. + */ + +import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react'; + +import { cn, formatRelativeTime } from '@renderer/shared/lib/utils'; + +import { Card, CardContent, EmptyState, Text } from '@ui'; + +import { isActive, useActiveAgents } from './useActiveAgents'; + +// ─── Status Mapping ──────────────────────────────────────── + +const STATUS_ICON = { + active: { icon: Loader2, className: 'text-info' }, + spawning: { icon: Loader2, className: 'text-info' }, + live: { icon: Loader2, className: 'text-info' }, + starting: { icon: Loader2, className: 'text-info' }, + running: { icon: Loader2, className: 'text-info' }, + idle: { icon: Loader2, className: 'text-info' }, + completed: { icon: CheckCircle2, className: 'text-success' }, + failed: { icon: XCircle, className: 'text-destructive' }, + error: { icon: XCircle, className: 'text-destructive' }, + crashed: { icon: XCircle, className: 'text-destructive' }, +} as const; + +type KnownStatus = keyof typeof STATUS_ICON; + +function getStatusConfig(status: string) { + if (status in STATUS_ICON) { + return STATUS_ICON[status as KnownStatus]; + } + return STATUS_ICON.active; +} + +export function ActiveAgents() { + const { entries, isLoading } = useActiveAgents(); + + if (isLoading && entries.length === 0) { + return ( + <Card> + <CardContent className="p-4"> + <Text className="mb-3 font-semibold" size="sm">Active Agents</Text> + <div className="flex items-center justify-center py-6"> + <Loader2 className="text-muted-foreground h-5 w-5 animate-spin" /> + </div> + </CardContent> + </Card> + ); + } + + return ( + <Card> + <CardContent className="p-4"> + <Text className="mb-3 font-semibold" size="sm">Active Agents</Text> + + {entries.length > 0 ? ( + <div className="space-y-3"> + {entries.map((entry) => { + const config = getStatusConfig(entry.status); + const StatusIcon = config.icon; + + return ( + <div key={entry.id} className="space-y-1.5"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <StatusIcon + className={cn( + 'h-3.5 w-3.5', + config.className, + isActive(entry.status) && 'animate-spin', + )} + /> + <Text className="font-medium" size="sm">{entry.label}</Text> + </div> + <Text size="sm" variant="muted">{entry.status}</Text> + </div> + <Text className="truncate pl-5.5" size="sm" variant="muted"> + {entry.detail} + {entry.startedAt === null ? '' : ` · ${formatRelativeTime(new Date(entry.startedAt).toISOString())}`} + </Text> + </div> + ); + })} + </div> + ) : ( + <EmptyState + description="No agents running" + icon={Bot} + size="sm" + title="No active agents" + /> + )} + </CardContent> + </Card> + ); +} diff --git a/src/renderer/features/dashboard/components/ActiveAgents/index.ts b/src/renderer/features/dashboard/components/ActiveAgents/index.ts new file mode 100644 index 00000000..48ed5c36 --- /dev/null +++ b/src/renderer/features/dashboard/components/ActiveAgents/index.ts @@ -0,0 +1,2 @@ +export { ActiveAgents } from './ActiveAgents'; +export { useActiveAgents } from './useActiveAgents'; diff --git a/src/renderer/features/dashboard/components/ActiveAgents/useActiveAgents.ts b/src/renderer/features/dashboard/components/ActiveAgents/useActiveAgents.ts new file mode 100644 index 00000000..a2ce514a --- /dev/null +++ b/src/renderer/features/dashboard/components/ActiveAgents/useActiveAgents.ts @@ -0,0 +1,57 @@ +import { useLayoutStore } from '@renderer/shared/stores/layout-store'; + +import { useAllAgents } from '@features/agents'; +import { useWorkspaceSessions } from '@features/workspace/api/useWorkspace'; + +const ACTIVE_STATUSES = new Set(['active', 'spawning', 'live', 'starting', 'running', 'idle']); + +export function isActive(status: string): boolean { + return ACTIVE_STATUSES.has(status); +} + +export interface AgentEntry { + id: string; + label: string; + detail: string; + status: string; + startedAt: number | null; +} + +export function useActiveAgents() { + const activeProjectId = useLayoutStore((s) => s.activeProjectId); + const { data: workspaceSessions = [], isLoading: wsLoading } = + useWorkspaceSessions(activeProjectId); + const { data: dashboardSessions, isLoading: dashLoading } = useAllAgents(); + + const isLoading = wsLoading || dashLoading; + + const entries: AgentEntry[] = []; + + for (const ws of workspaceSessions) { + if (!isActive(ws.status)) continue; + const typeLabel = ws.key.type === 'primary' ? 'Primary' : `Team Lead #${String(ws.key.index)}`; + entries.push({ + id: `ws-${ws.agentSessionId}`, + label: typeLabel, + detail: ws.model, + status: ws.status, + startedAt: ws.startedAt, + }); + } + + const wsIds = new Set(workspaceSessions.map((s) => s.agentSessionId)); + const dashList = Array.isArray(dashboardSessions) ? dashboardSessions : []; + for (const dash of dashList) { + if (wsIds.has(dash.id)) continue; + if (!isActive(dash.status)) continue; + entries.push({ + id: `dash-${dash.id}`, + label: dash.name, + detail: `${dash.type} · ${dash.model}`, + status: dash.status, + startedAt: new Date(dash.startedAt).getTime(), + }); + } + + return { entries, isLoading }; +} diff --git a/src/renderer/features/dashboard/components/DailyStats.tsx b/src/renderer/features/dashboard/components/DailyStats.tsx deleted file mode 100644 index cebf36ca..00000000 --- a/src/renderer/features/dashboard/components/DailyStats.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/** - * DailyStats — Simple stats row showing daily activity - * - * Shows task completions across all projects (not just active project). - */ - -import { Card, CardContent } from '@ui'; - -import { useAllAgents } from '@features/agents'; -import { useAllTasks } from '@features/my-work'; - -import { useCaptures } from '../api/useCaptures'; - -export function DailyStats() { - const { data: captures } = useCaptures(); - const { data: allSessions } = useAllAgents(); - const { data: tasks } = useAllTasks(); - - const captureCount = captures?.length ?? 0; - const todayStr = new Date().toISOString().slice(0, 10); - - // Count tasks completed today (status 'done' with updatedAt starting with today's date) - const tasksCompleted = - tasks?.filter((task) => task.status === 'done' && task.updatedAt.startsWith(todayStr)).length ?? - 0; - - // Count agent sessions started today - const sessionList = Array.isArray(allSessions) ? allSessions : []; - const agentsRan = sessionList.filter((s) => s.startedAt.startsWith(todayStr)).length; - - return ( - <Card> - <CardContent className="px-4 py-3"> - <p className="text-muted-foreground text-xs"> - <span className="text-foreground font-medium">{tasksCompleted}</span> tasks completed - {' \u00B7 '} - <span className="text-foreground font-medium">{agentsRan}</span> agents ran - {' \u00B7 '} - <span className="text-foreground font-medium">{captureCount}</span> captures - </p> - </CardContent> - </Card> - ); -} diff --git a/src/renderer/features/dashboard/components/DailyStats/DailyStats.tsx b/src/renderer/features/dashboard/components/DailyStats/DailyStats.tsx new file mode 100644 index 00000000..846a9ae1 --- /dev/null +++ b/src/renderer/features/dashboard/components/DailyStats/DailyStats.tsx @@ -0,0 +1,27 @@ +/** + * DailyStats — Simple stats row showing daily activity + * + * Shows task completions across all projects (not just active project). + */ + +import { Card, CardContent } from '@ui'; + +import { useDailyStats } from './useDailyStats'; + +export function DailyStats() { + const { captureCount, tasksCompleted, agentsRan } = useDailyStats(); + + return ( + <Card> + <CardContent className="px-4 py-3"> + <p className="text-muted-foreground text-xs"> + <span className="text-foreground font-medium">{tasksCompleted}</span> tasks completed + {' \u00B7 '} + <span className="text-foreground font-medium">{agentsRan}</span> agents ran + {' \u00B7 '} + <span className="text-foreground font-medium">{captureCount}</span> captures + </p> + </CardContent> + </Card> + ); +} diff --git a/src/renderer/features/dashboard/components/DailyStats/index.ts b/src/renderer/features/dashboard/components/DailyStats/index.ts new file mode 100644 index 00000000..db38717b --- /dev/null +++ b/src/renderer/features/dashboard/components/DailyStats/index.ts @@ -0,0 +1,2 @@ +export { DailyStats } from './DailyStats'; +export { useDailyStats } from './useDailyStats'; diff --git a/src/renderer/features/dashboard/components/DailyStats/useDailyStats.ts b/src/renderer/features/dashboard/components/DailyStats/useDailyStats.ts new file mode 100644 index 00000000..25d331ae --- /dev/null +++ b/src/renderer/features/dashboard/components/DailyStats/useDailyStats.ts @@ -0,0 +1,24 @@ +import { useToday } from '@renderer/shared/hooks/useToday'; + +import { useAllAgents } from '@features/agents'; +import { useAllTasks } from '@features/my-work'; + +import { useCaptures } from '../../api/useCaptures'; + +export function useDailyStats() { + const { data: captures } = useCaptures(); + const { data: allSessions } = useAllAgents(); + const { data: tasks } = useAllTasks(); + + const captureCount = captures?.length ?? 0; + const todayStr = useToday(); + + const tasksCompleted = + tasks?.filter((task) => task.status === 'done' && task.updatedAt.startsWith(todayStr)).length ?? + 0; + + const sessionList = Array.isArray(allSessions) ? allSessions : []; + const agentsRan = sessionList.filter((s) => s.startedAt.startsWith(todayStr)).length; + + return { captureCount, tasksCompleted, agentsRan }; +} diff --git a/src/renderer/features/dashboard/components/DashboardPage.tsx b/src/renderer/features/dashboard/components/DashboardPage/DashboardPage.tsx similarity index 76% rename from src/renderer/features/dashboard/components/DashboardPage.tsx rename to src/renderer/features/dashboard/components/DashboardPage/DashboardPage.tsx index 74e0ffd1..853c9086 100644 --- a/src/renderer/features/dashboard/components/DashboardPage.tsx +++ b/src/renderer/features/dashboard/components/DashboardPage/DashboardPage.tsx @@ -4,14 +4,13 @@ import { PageContent, PageHeader, PageLayout } from '@ui'; -import { useDashboardEvents } from '../hooks/useDashboardEvents'; - -import { ActiveAgents } from './ActiveAgents'; -import { DailyStats } from './DailyStats'; -import { GreetingHeader } from './GreetingHeader'; -import { QuickCapture } from './QuickCapture'; -import { RecentProjects } from './RecentProjects'; -import { TodayView } from './TodayView'; +import { useDashboardEvents } from '../../hooks/useDashboardEvents'; +import { ActiveAgents } from '../ActiveAgents'; +import { DailyStats } from '../DailyStats'; +import { GreetingHeader } from '../GreetingHeader'; +import { QuickCapture } from '../QuickCapture'; +import { RecentProjects } from '../RecentProjects'; +import { TodayView } from '../TodayView'; export function DashboardPage() { useDashboardEvents(); diff --git a/src/renderer/features/dashboard/components/DashboardPage/index.ts b/src/renderer/features/dashboard/components/DashboardPage/index.ts new file mode 100644 index 00000000..d1e7bf22 --- /dev/null +++ b/src/renderer/features/dashboard/components/DashboardPage/index.ts @@ -0,0 +1 @@ +export { DashboardPage } from './DashboardPage'; diff --git a/src/renderer/features/dashboard/components/GreetingHeader.tsx b/src/renderer/features/dashboard/components/GreetingHeader.tsx deleted file mode 100644 index 3dc9cefd..00000000 --- a/src/renderer/features/dashboard/components/GreetingHeader.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/** - * GreetingHeader — Time-aware greeting with current date - */ - -import { useEffect, useState } from 'react'; - -import { Heading, Text } from '@ui'; - -function getGreeting(): string { - const hour = new Date().getHours(); - if (hour < 12) return 'Good morning'; - if (hour < 18) return 'Good afternoon'; - return 'Good evening'; -} - -function formatDate(): string { - return new Date().toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric', - }); -} - -export function GreetingHeader() { - const [greeting, setGreeting] = useState(getGreeting); - const [date, setDate] = useState(formatDate); - - useEffect(() => { - // Update greeting and date every minute - const interval = setInterval(() => { - setGreeting(getGreeting()); - setDate(formatDate()); - }, 60_000); - - return () => clearInterval(interval); - }, []); - - return ( - <div className="mb-6"> - <Heading as="h1">{greeting}</Heading> - <Text className="mt-1" size="sm" variant="muted">{date}</Text> - </div> - ); -} diff --git a/src/renderer/features/dashboard/components/GreetingHeader/GreetingHeader.tsx b/src/renderer/features/dashboard/components/GreetingHeader/GreetingHeader.tsx new file mode 100644 index 00000000..508f7760 --- /dev/null +++ b/src/renderer/features/dashboard/components/GreetingHeader/GreetingHeader.tsx @@ -0,0 +1,18 @@ +/** + * GreetingHeader — Time-aware greeting with current date + */ + +import { Heading, Text } from '@ui'; + +import { useGreetingHeader } from './useGreetingHeader'; + +export function GreetingHeader() { + const { greeting, date } = useGreetingHeader(); + + return ( + <div className="mb-6"> + <Heading as="h1">{greeting}</Heading> + <Text className="mt-1" size="sm" variant="muted">{date}</Text> + </div> + ); +} diff --git a/src/renderer/features/dashboard/components/GreetingHeader/index.ts b/src/renderer/features/dashboard/components/GreetingHeader/index.ts new file mode 100644 index 00000000..0bbb806c --- /dev/null +++ b/src/renderer/features/dashboard/components/GreetingHeader/index.ts @@ -0,0 +1,2 @@ +export { GreetingHeader } from './GreetingHeader'; +export { useGreetingHeader } from './useGreetingHeader'; diff --git a/src/renderer/features/dashboard/components/GreetingHeader/useGreetingHeader.ts b/src/renderer/features/dashboard/components/GreetingHeader/useGreetingHeader.ts new file mode 100644 index 00000000..765eb56d --- /dev/null +++ b/src/renderer/features/dashboard/components/GreetingHeader/useGreetingHeader.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; + +function getGreeting(): string { + const hour = new Date().getHours(); + if (hour < 12) return 'Good morning'; + if (hour < 18) return 'Good afternoon'; + return 'Good evening'; +} + +function formatDate(): string { + return new Date().toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + }); +} + +export function useGreetingHeader() { + const [greeting, setGreeting] = useState(getGreeting); + const [date, setDate] = useState(formatDate); + + useEffect(() => { + const interval = setInterval(() => { + setGreeting(getGreeting()); + setDate(formatDate()); + }, 60_000); + + return () => clearInterval(interval); + }, []); + + return { greeting, date }; +} diff --git a/src/renderer/features/dashboard/components/QuickCapture.tsx b/src/renderer/features/dashboard/components/QuickCapture.tsx deleted file mode 100644 index 23fa8b87..00000000 --- a/src/renderer/features/dashboard/components/QuickCapture.tsx +++ /dev/null @@ -1,288 +0,0 @@ -/** - * QuickCapture — Quick text input for ideas, tasks, and notes - * - * Persists captures via IPC (dashboard.captures.*) so they survive restarts. - * Supports inline editing, search filter, show-all toggle, and per-capture - * kebab menu with Convert to Task / Convert to Note / Delete actions. - */ - -import { useRef, useState } from 'react'; - -import { MoreHorizontal, Plus } from 'lucide-react'; - -import { RelativeTime } from '@renderer/shared/components/RelativeTime'; -import { useToastStore } from '@renderer/shared/stores'; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, - Button, - Card, - CardContent, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - Input, - SearchInput, - Text, -} from '@ui'; - -import { useCreateNote } from '@features/personal/notes'; -import { useCreateProgressTask } from '@features/tasks'; - -import { useCaptureMutations, useCaptures, useUpdateCapture } from '../api/useCaptures'; - -interface EditState { - id: string; - value: string; -} - -export function QuickCapture() { - const [inputValue, setInputValue] = useState(''); - const [searchQuery, setSearchQuery] = useState(''); - const [showAll, setShowAll] = useState(false); - const [editState, setEditState] = useState<EditState | null>(null); - const editInputRef = useRef<HTMLInputElement>(null); - - const { data: captures } = useCaptures(); - const { createCapture, deleteCapture } = useCaptureMutations(); - const updateCapture = useUpdateCapture(); - const createProgressTask = useCreateProgressTask(); - const createNote = useCreateNote(); - const { addToast } = useToastStore(); - - function handleSubmit() { - const trimmed = inputValue.trim(); - if (trimmed.length === 0) return; - createCapture.mutate(trimmed); - setInputValue(''); - } - - function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { - if (e.key === 'Enter') { - handleSubmit(); - } - } - - function handleEditStart(id: string, currentText: string) { - setEditState({ id, value: currentText }); - // Focus the input on next tick after render - setTimeout(() => { - editInputRef.current?.focus(); - editInputRef.current?.select(); - }, 0); - } - - function handleEditSave() { - if (editState === null) return; - const trimmed = editState.value.trim(); - if (trimmed.length > 0) { - updateCapture.mutate({ id: editState.id, text: trimmed }); - } - setEditState(null); - } - - function handleEditKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { - if (e.key === 'Enter') { - handleEditSave(); - } else if (e.key === 'Escape') { - setEditState(null); - } - } - - function handleConvertToTask(text: string, captureId: string) { - createProgressTask.mutate( - { title: text, description: '' }, - { - onSuccess() { - deleteCapture.mutate(captureId); - addToast('Converted to task', 'success'); - }, - }, - ); - } - - function handleConvertToNote(text: string, captureId: string) { - createNote.mutate( - { title: text, content: '' }, - { - onSuccess() { - deleteCapture.mutate(captureId); - addToast('Converted to note', 'success'); - }, - }, - ); - } - - const allCaptures = captures ?? []; - - const filteredCaptures = - searchQuery.trim().length > 0 - ? allCaptures.filter((c) => - c.text.toLowerCase().includes(searchQuery.toLowerCase()), - ) - : allCaptures; - - const LIMIT = 5; - const visibleCaptures = showAll ? filteredCaptures : filteredCaptures.slice(0, LIMIT); - const hasMore = filteredCaptures.length > LIMIT; - - return ( - <Card> - <CardContent className="p-4"> - <Text className="mb-3 font-semibold">Quick Capture</Text> - - <div className="flex gap-2"> - <Input - className="flex-1" - placeholder="Quick idea, task, or note..." - type="text" - value={inputValue} - onChange={(e) => setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - /> - <Button - disabled={inputValue.trim().length === 0} - size="md" - type="button" - onClick={handleSubmit} - > - <Plus className="h-4 w-4" /> - </Button> - </div> - - {(allCaptures.length > 0) ? ( - <div className="mt-3"> - <SearchInput - className="mb-2" - placeholder="Filter captures..." - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - /> - - {(visibleCaptures.length > 0) ? ( - <ul className="space-y-1.5"> - {visibleCaptures.map((capture) => ( - <li - key={capture.id} - className="border-border flex items-start gap-2 rounded-md border px-3 py-2" - > - {(editState !== null && editState.id === capture.id) ? ( - <Input - ref={editInputRef} - className="h-6 flex-1 border-0 p-0 text-xs focus-visible:ring-0" - value={editState.value} - onBlur={handleEditSave} - onKeyDown={handleEditKeyDown} - onChange={(e) => - setEditState({ ...editState, value: e.target.value }) - } - /> - ) : ( - <Button - className="text-foreground min-w-0 h-auto flex-1 cursor-text justify-start px-0 py-0 text-left text-xs font-normal" - type="button" - variant="ghost" - onClick={() => handleEditStart(capture.id, capture.text)} - > - {capture.text} - </Button> - )} - <RelativeTime - className="text-muted-foreground shrink-0 text-xs" - value={capture.createdAt} - /> - - <AlertDialog> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Capture actions" - className="text-muted-foreground hover:text-foreground h-auto shrink-0 p-0" - size="icon" - type="button" - variant="ghost" - > - <MoreHorizontal className="h-3 w-3" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onSelect={() => - handleConvertToTask(capture.text, capture.id) - } - > - Convert to Task - </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => - handleConvertToNote(capture.text, capture.id) - } - > - Convert to Note - </DropdownMenuItem> - <DropdownMenuSeparator /> - <AlertDialogTrigger asChild> - <DropdownMenuItem - className="text-destructive focus:text-destructive" - onSelect={(e) => e.preventDefault()} - > - Delete - </DropdownMenuItem> - </AlertDialogTrigger> - </DropdownMenuContent> - </DropdownMenu> - - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>Delete capture?</AlertDialogTitle> - <AlertDialogDescription> - This will permanently delete this capture. This action cannot - be undone. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> - <AlertDialogAction - onClick={() => deleteCapture.mutate(capture.id)} - > - Delete - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - </li> - ))} - </ul> - ) : ( - <Text size="sm" variant="muted">No captures match your search.</Text> - )} - - {hasMore ? ( - <Button - className="mt-2 w-full" - size="sm" - type="button" - variant="ghost" - onClick={() => setShowAll(!showAll)} - > - {showAll - ? 'Show less' - : `Show all (${filteredCaptures.length - LIMIT} more)`} - </Button> - ) : null} - </div> - ) : null} - </CardContent> - </Card> - ); -} diff --git a/src/renderer/features/dashboard/components/QuickCapture/QuickCapture.tsx b/src/renderer/features/dashboard/components/QuickCapture/QuickCapture.tsx new file mode 100644 index 00000000..9f3e9663 --- /dev/null +++ b/src/renderer/features/dashboard/components/QuickCapture/QuickCapture.tsx @@ -0,0 +1,213 @@ +/** + * QuickCapture — Quick text input for ideas, tasks, and notes + * + * Persists captures via IPC (dashboard.captures.*) so they survive restarts. + * Supports inline editing, search filter, show-all toggle, and per-capture + * kebab menu with Convert to Task / Convert to Note / Delete actions. + */ + +import { MoreHorizontal, Plus } from 'lucide-react'; + +import { RelativeTime } from '@renderer/shared/components/RelativeTime'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + Button, + Card, + CardContent, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Input, + SearchInput, + Text, +} from '@ui'; + +import { useQuickCapture } from './useQuickCapture'; + +export function QuickCapture() { + const { + inputValue, + setInputValue, + searchQuery, + setSearchQuery, + showAll, + setShowAll, + editState, + setEditState, + editInputRef, + allCaptures, + filteredCaptures, + visibleCaptures, + hasMore, + deleteCapture, + handleSubmit, + handleKeyDown, + handleEditStart, + handleEditSave, + handleEditKeyDown, + handleConvertToTask, + handleConvertToNote, + LIMIT, + } = useQuickCapture(); + + return ( + <Card> + <CardContent className="p-4"> + <Text className="mb-3 font-semibold">Quick Capture</Text> + + <div className="flex gap-2"> + <Input + className="flex-1" + placeholder="Quick idea, task, or note..." + type="text" + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + /> + <Button + disabled={inputValue.trim().length === 0} + size="md" + type="button" + onClick={handleSubmit} + > + <Plus className="h-4 w-4" /> + </Button> + </div> + + {allCaptures.length > 0 ? ( + <div className="mt-3"> + <SearchInput + className="mb-2" + placeholder="Filter captures..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + + {visibleCaptures.length > 0 ? ( + <ul className="space-y-1.5"> + {visibleCaptures.map((capture) => ( + <li + key={capture.id} + className="border-border flex items-start gap-2 rounded-md border px-3 py-2" + > + {editState !== null && editState.id === capture.id ? ( + <Input + ref={editInputRef} + className="h-6 flex-1 border-0 p-0 text-xs focus-visible:ring-0" + value={editState.value} + onBlur={handleEditSave} + onKeyDown={handleEditKeyDown} + onChange={(e) => + setEditState({ ...editState, value: e.target.value }) + } + /> + ) : ( + <Button + className="text-foreground min-w-0 h-auto flex-1 cursor-text justify-start px-0 py-0 text-left text-xs font-normal" + type="button" + variant="ghost" + onClick={() => handleEditStart(capture.id, capture.text)} + > + {capture.text} + </Button> + )} + <RelativeTime + className="text-muted-foreground shrink-0 text-xs" + value={capture.createdAt} + /> + + <AlertDialog> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Capture actions" + className="text-muted-foreground hover:text-foreground h-auto shrink-0 p-0" + size="icon" + type="button" + variant="ghost" + > + <MoreHorizontal className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onSelect={() => + handleConvertToTask(capture.text, capture.id) + } + > + Convert to Task + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => + handleConvertToNote(capture.text, capture.id) + } + > + Convert to Note + </DropdownMenuItem> + <DropdownMenuSeparator /> + <AlertDialogTrigger asChild> + <DropdownMenuItem + className="text-destructive focus:text-destructive" + onSelect={(e) => e.preventDefault()} + > + Delete + </DropdownMenuItem> + </AlertDialogTrigger> + </DropdownMenuContent> + </DropdownMenu> + + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete capture?</AlertDialogTitle> + <AlertDialogDescription> + This will permanently delete this capture. This action cannot + be undone. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={() => deleteCapture.mutate(capture.id)} + > + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </li> + ))} + </ul> + ) : ( + <Text size="sm" variant="muted">No captures match your search.</Text> + )} + + {hasMore ? ( + <Button + className="mt-2 w-full" + size="sm" + type="button" + variant="ghost" + onClick={() => setShowAll(!showAll)} + > + {showAll + ? 'Show less' + : `Show all (${filteredCaptures.length - LIMIT} more)`} + </Button> + ) : null} + </div> + ) : null} + </CardContent> + </Card> + ); +} diff --git a/src/renderer/features/dashboard/components/QuickCapture/index.ts b/src/renderer/features/dashboard/components/QuickCapture/index.ts new file mode 100644 index 00000000..53b21c3e --- /dev/null +++ b/src/renderer/features/dashboard/components/QuickCapture/index.ts @@ -0,0 +1,2 @@ +export { QuickCapture } from './QuickCapture'; +export { useQuickCapture } from './useQuickCapture'; diff --git a/src/renderer/features/dashboard/components/QuickCapture/useQuickCapture.ts b/src/renderer/features/dashboard/components/QuickCapture/useQuickCapture.ts new file mode 100644 index 00000000..4fa88c37 --- /dev/null +++ b/src/renderer/features/dashboard/components/QuickCapture/useQuickCapture.ts @@ -0,0 +1,129 @@ +import { useRef, useState } from 'react'; + +import { useToastStore } from '@renderer/shared/stores'; + +import { useCreateNote } from '@features/notes'; +import { useCreateProgressTask } from '@features/tasks'; + +import { useCaptureMutations, useCaptures, useUpdateCapture } from '../../api/useCaptures'; + +interface EditState { + id: string; + value: string; +} + +const LIMIT = 5; + +export function useQuickCapture() { + const [inputValue, setInputValue] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [showAll, setShowAll] = useState(false); + const [editState, setEditState] = useState<EditState | null>(null); + const editInputRef = useRef<HTMLInputElement>(null); + + const { data: captures } = useCaptures(); + const { createCapture, deleteCapture } = useCaptureMutations(); + const updateCapture = useUpdateCapture(); + const createProgressTask = useCreateProgressTask(); + const createNote = useCreateNote(); + const { addToast } = useToastStore(); + + function handleSubmit(): void { + const trimmed = inputValue.trim(); + if (trimmed.length === 0) return; + createCapture.mutate(trimmed); + setInputValue(''); + } + + function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>): void { + if (e.key === 'Enter') { + handleSubmit(); + } + } + + function handleEditStart(id: string, currentText: string): void { + setEditState({ id, value: currentText }); + setTimeout(() => { + editInputRef.current?.focus(); + editInputRef.current?.select(); + }, 0); + } + + function handleEditSave(): void { + if (editState === null) return; + const trimmed = editState.value.trim(); + if (trimmed.length > 0) { + updateCapture.mutate({ id: editState.id, text: trimmed }); + } + setEditState(null); + } + + function handleEditKeyDown(e: React.KeyboardEvent<HTMLInputElement>): void { + if (e.key === 'Enter') { + handleEditSave(); + } else if (e.key === 'Escape') { + setEditState(null); + } + } + + function handleConvertToTask(text: string, captureId: string): void { + createProgressTask.mutate( + { title: text, description: '' }, + { + onSuccess() { + deleteCapture.mutate(captureId); + addToast('Converted to task', 'success'); + }, + }, + ); + } + + function handleConvertToNote(text: string, captureId: string): void { + createNote.mutate( + { title: text, content: '' }, + { + onSuccess() { + deleteCapture.mutate(captureId); + addToast('Converted to note', 'success'); + }, + }, + ); + } + + const allCaptures = captures ?? []; + + const filteredCaptures = + searchQuery.trim().length > 0 + ? allCaptures.filter((c) => + c.text.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : allCaptures; + + const visibleCaptures = showAll ? filteredCaptures : filteredCaptures.slice(0, LIMIT); + const hasMore = filteredCaptures.length > LIMIT; + + return { + inputValue, + setInputValue, + searchQuery, + setSearchQuery, + showAll, + setShowAll, + editState, + setEditState, + editInputRef, + allCaptures, + filteredCaptures, + visibleCaptures, + hasMore, + deleteCapture, + handleSubmit, + handleKeyDown, + handleEditStart, + handleEditSave, + handleEditKeyDown, + handleConvertToTask, + handleConvertToNote, + LIMIT, + }; +} diff --git a/src/renderer/features/dashboard/components/RecentProjects.tsx b/src/renderer/features/dashboard/components/RecentProjects.tsx deleted file mode 100644 index 8473d632..00000000 --- a/src/renderer/features/dashboard/components/RecentProjects.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/** - * RecentProjects — Grid of recently used project cards - */ - -import { useState } from 'react'; - -import { useNavigate } from '@tanstack/react-router'; -import { FolderOpen, Loader2, Sparkles, Wand2 } from 'lucide-react'; - -import { PROJECT_VIEWS, projectViewPath } from '@shared/constants'; - -import { cn, formatRelativeTime, truncate } from '@renderer/shared/lib/utils'; -import { useLayoutStore, useToastStore } from '@renderer/shared/stores'; - -import { Button, Card, CardContent, EmptyState } from '@ui'; - -import { CreateProjectWizard, ProjectInitWizard, useProjects } from '@features/projects'; - -export function RecentProjects() { - const navigate = useNavigate(); - const { data: projects, isLoading } = useProjects(); - const { addProjectTab } = useLayoutStore(); - const { addToast } = useToastStore(); - - // Dialog state - const [wizardOpen, setWizardOpen] = useState(false); - const [createWizardOpen, setCreateWizardOpen] = useState(false); - - function handleOpenProject(projectId: string) { - addProjectTab(projectId); - void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); - } - - function handleWizardSetupStarted(projectId: string) { - setWizardOpen(false); - addProjectTab(projectId); - addToast('Project created — setup running in background', 'success'); - void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); - } - - function handleProjectCreated(projectId: string) { - setCreateWizardOpen(false); - addProjectTab(projectId); - addToast('Project created — setup running in background', 'success'); - void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); - } - - if (isLoading) { - return ( - <Card className="flex items-center justify-center p-8"> - <Loader2 className="text-muted-foreground h-5 w-5 animate-spin" /> - </Card> - ); - } - - const projectList = projects ?? []; - - return ( - <Card> - <CardContent className="p-4"> - <p className="text-foreground mb-3 text-sm font-semibold">Recent Projects</p> - - {projectList.length > 0 ? ( - <div className="grid grid-cols-1 gap-2 lg:grid-cols-2"> - {projectList.map((project) => ( - <Button - key={project.id} - type="button" - variant="ghost" - className={cn( - 'border-border flex h-auto items-start gap-3 rounded-md border p-3 text-left', - 'hover:bg-accent transition-colors', - )} - onClick={() => handleOpenProject(project.id)} - > - <FolderOpen className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" /> - <div className="min-w-0 flex-1"> - <p className="text-foreground truncate text-sm font-medium">{project.name}</p> - <p className="text-muted-foreground truncate text-xs"> - {truncate(project.path, 40)} - </p> - <p className="text-muted-foreground mt-1 text-xs"> - {formatRelativeTime(project.updatedAt)} - </p> - </div> - </Button> - ))} - </div> - ) : ( - <EmptyState - icon={FolderOpen} - size="sm" - title="No projects yet" - action={ - <div className="flex items-center gap-2"> - <Button - size="sm" - type="button" - variant="outline" - onClick={() => setWizardOpen(true)} - > - <Wand2 className="h-3.5 w-3.5" /> - Init Wizard - </Button> - <Button - size="sm" - type="button" - variant="outline" - onClick={() => setCreateWizardOpen(true)} - > - <Sparkles className="h-3.5 w-3.5" /> - New Project - </Button> - </div> - } - /> - )} - - {wizardOpen ? ( - <ProjectInitWizard - onClose={() => setWizardOpen(false)} - onSetupStarted={handleWizardSetupStarted} - /> - ) : null} - - <CreateProjectWizard - open={createWizardOpen} - onClose={() => setCreateWizardOpen(false)} - onProjectCreated={handleProjectCreated} - /> - </CardContent> - </Card> - ); -} diff --git a/src/renderer/features/dashboard/components/RecentProjects/RecentProjects.tsx b/src/renderer/features/dashboard/components/RecentProjects/RecentProjects.tsx new file mode 100644 index 00000000..f243fbc2 --- /dev/null +++ b/src/renderer/features/dashboard/components/RecentProjects/RecentProjects.tsx @@ -0,0 +1,112 @@ +/** + * RecentProjects — Grid of recently used project cards + */ + +import { FolderOpen, Loader2, Sparkles, Wand2 } from 'lucide-react'; + +import { cn, formatRelativeTime, truncate } from '@renderer/shared/lib/utils'; + +import { Button, Card, CardContent, EmptyState } from '@ui'; + +import { CreateProjectWizard, ProjectInitWizard } from '@features/projects'; + +import { useRecentProjects } from './useRecentProjects'; + +export function RecentProjects() { + const { + projects, + isLoading, + wizardOpen, + setWizardOpen, + createWizardOpen, + setCreateWizardOpen, + handleOpenProject, + handleWizardSetupStarted, + handleProjectCreated, + } = useRecentProjects(); + + if (isLoading) { + return ( + <Card className="flex items-center justify-center p-8"> + <Loader2 className="text-muted-foreground h-5 w-5 animate-spin" /> + </Card> + ); + } + + return ( + <Card> + <CardContent className="p-4"> + <p className="text-foreground mb-3 text-sm font-semibold">Recent Projects</p> + + {projects.length > 0 ? ( + <div className="grid grid-cols-1 gap-2 lg:grid-cols-2"> + {projects.map((project) => ( + <Button + key={project.id} + type="button" + variant="ghost" + className={cn( + 'border-border flex h-auto items-start gap-3 rounded-md border p-3 text-left', + 'hover:bg-accent transition-colors', + )} + onClick={() => handleOpenProject(project.id)} + > + <FolderOpen className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" /> + <div className="min-w-0 flex-1"> + <p className="text-foreground truncate text-sm font-medium">{project.name}</p> + <p className="text-muted-foreground truncate text-xs"> + {truncate(project.path, 40)} + </p> + <p className="text-muted-foreground mt-1 text-xs"> + {formatRelativeTime(project.updatedAt)} + </p> + </div> + </Button> + ))} + </div> + ) : ( + <EmptyState + icon={FolderOpen} + size="sm" + title="No projects yet" + action={ + <div className="flex items-center gap-2"> + <Button + size="sm" + type="button" + variant="outline" + onClick={() => setWizardOpen(true)} + > + <Wand2 className="h-3.5 w-3.5" /> + Init Wizard + </Button> + <Button + size="sm" + type="button" + variant="outline" + onClick={() => setCreateWizardOpen(true)} + > + <Sparkles className="h-3.5 w-3.5" /> + New Project + </Button> + </div> + } + /> + )} + + {wizardOpen ? ( + <ProjectInitWizard + onClose={() => setWizardOpen(false)} + onSetupStarted={handleWizardSetupStarted} + /> + ) : null} + + <CreateProjectWizard + open={createWizardOpen} + onClose={() => setCreateWizardOpen(false)} + onProjectCreated={handleProjectCreated} + /> + </CardContent> + </Card> + ); +} diff --git a/src/renderer/features/dashboard/components/RecentProjects/index.ts b/src/renderer/features/dashboard/components/RecentProjects/index.ts new file mode 100644 index 00000000..857e479b --- /dev/null +++ b/src/renderer/features/dashboard/components/RecentProjects/index.ts @@ -0,0 +1,2 @@ +export { RecentProjects } from './RecentProjects'; +export { useRecentProjects } from './useRecentProjects'; diff --git a/src/renderer/features/dashboard/components/RecentProjects/useRecentProjects.ts b/src/renderer/features/dashboard/components/RecentProjects/useRecentProjects.ts new file mode 100644 index 00000000..292db5ce --- /dev/null +++ b/src/renderer/features/dashboard/components/RecentProjects/useRecentProjects.ts @@ -0,0 +1,50 @@ +import { useState } from 'react'; + +import { useNavigate } from '@tanstack/react-router'; + +import { PROJECT_VIEWS, projectViewPath } from '@shared/constants'; + +import { useLayoutStore, useToastStore } from '@renderer/shared/stores'; + +import { useProjects } from '@features/projects'; + +export function useRecentProjects() { + const navigate = useNavigate(); + const { data: projects, isLoading } = useProjects(); + const { addProjectTab } = useLayoutStore(); + const { addToast } = useToastStore(); + + const [wizardOpen, setWizardOpen] = useState(false); + const [createWizardOpen, setCreateWizardOpen] = useState(false); + + function handleOpenProject(projectId: string): void { + addProjectTab(projectId); + void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); + } + + function handleWizardSetupStarted(projectId: string): void { + setWizardOpen(false); + addProjectTab(projectId); + addToast('Project created — setup running in background', 'success'); + void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); + } + + function handleProjectCreated(projectId: string): void { + setCreateWizardOpen(false); + addProjectTab(projectId); + addToast('Project created — setup running in background', 'success'); + void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); + } + + return { + projects: projects ?? [], + isLoading, + wizardOpen, + setWizardOpen, + createWizardOpen, + setCreateWizardOpen, + handleOpenProject, + handleWizardSetupStarted, + handleProjectCreated, + }; +} diff --git a/src/renderer/features/dashboard/components/TodayView.tsx b/src/renderer/features/dashboard/components/TodayView/TodayView.tsx similarity index 94% rename from src/renderer/features/dashboard/components/TodayView.tsx rename to src/renderer/features/dashboard/components/TodayView/TodayView.tsx index d327de36..0261c65e 100644 --- a/src/renderer/features/dashboard/components/TodayView.tsx +++ b/src/renderer/features/dashboard/components/TodayView/TodayView.tsx @@ -10,7 +10,7 @@ import { cn } from '@renderer/shared/lib/utils'; import { Card, CardContent, EmptyState } from '@ui'; -import { useDay } from '@features/personal'; +import { useTodayView } from './useTodayView'; type TimeBlockType = TimeBlock['type']; @@ -46,9 +46,7 @@ function formatTime(time: string): string { } export function TodayView() { - const todayStr = new Date().toISOString().slice(0, 10); - const { data: plan, isLoading } = useDay(todayStr); - const timeBlocks = plan?.timeBlocks ?? []; + const { timeBlocks, isLoading } = useTodayView(); if (isLoading) { return ( diff --git a/src/renderer/features/dashboard/components/TodayView/index.ts b/src/renderer/features/dashboard/components/TodayView/index.ts new file mode 100644 index 00000000..814921ca --- /dev/null +++ b/src/renderer/features/dashboard/components/TodayView/index.ts @@ -0,0 +1,2 @@ +export { TodayView } from './TodayView'; +export { useTodayView } from './useTodayView'; diff --git a/src/renderer/features/dashboard/components/TodayView/useTodayView.ts b/src/renderer/features/dashboard/components/TodayView/useTodayView.ts new file mode 100644 index 00000000..05a76b04 --- /dev/null +++ b/src/renderer/features/dashboard/components/TodayView/useTodayView.ts @@ -0,0 +1,11 @@ +import { useToday } from '@renderer/shared/hooks/useToday'; + +import { useDay } from '@features/planner'; + +export function useTodayView() { + const todayStr = useToday(); + const { data: plan, isLoading } = useDay(todayStr); + const timeBlocks = plan?.timeBlocks ?? []; + + return { timeBlocks, isLoading }; +} diff --git a/src/renderer/features/dashboard/index.ts b/src/renderer/features/dashboard/index.ts index 727a529e..0137f778 100644 --- a/src/renderer/features/dashboard/index.ts +++ b/src/renderer/features/dashboard/index.ts @@ -5,9 +5,6 @@ // Components export { DashboardPage } from './components/DashboardPage'; -// Store -export { useDashboardStore } from './store'; - // API export { dashboardKeys } from './api/queryKeys'; export { useCaptureMutations, useCaptures } from './api/useCaptures'; diff --git a/src/renderer/features/dashboard/store.ts b/src/renderer/features/dashboard/store.ts deleted file mode 100644 index e4fb7d63..00000000 --- a/src/renderer/features/dashboard/store.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Dashboard Store — UI state for the dashboard feature - * - * Quick captures are now persisted via IPC (dashboard.captures.*). - * This store only holds transient UI state. - */ - -import { create } from 'zustand'; - -interface DashboardState { - /** Placeholder for future UI state (e.g., selected widget, collapsed sections) */ - _initialized: boolean; -} - -export const useDashboardStore = create<DashboardState>()(() => ({ - _initialized: true, -})); diff --git a/src/renderer/features/diff-viewer/api/useDiff.ts b/src/renderer/features/diff-viewer/api/useDiff.ts index 1871490e..148f5a1c 100644 --- a/src/renderer/features/diff-viewer/api/useDiff.ts +++ b/src/renderer/features/diff-viewer/api/useDiff.ts @@ -7,7 +7,7 @@ import { useQuery } from '@tanstack/react-query'; -import { MERGE } from '@shared/ipc/misc/merge.channels'; +import { MERGE } from '@shared/ipc/merge'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/diff-viewer/components/DiffFileList.tsx b/src/renderer/features/diff-viewer/components/DiffFileList/DiffFileList.tsx similarity index 82% rename from src/renderer/features/diff-viewer/components/DiffFileList.tsx rename to src/renderer/features/diff-viewer/components/DiffFileList/DiffFileList.tsx index f3df66f6..01d58a77 100644 --- a/src/renderer/features/diff-viewer/components/DiffFileList.tsx +++ b/src/renderer/features/diff-viewer/components/DiffFileList/DiffFileList.tsx @@ -6,13 +6,14 @@ * for viewing in the DiffViewer panel. */ -import { useMemo } from 'react'; - import { cn } from '@renderer/shared/lib/utils'; import { Badge, Button, EmptyState, ScrollArea } from '@ui'; -import type { DiffFileEntry, FileChangeStatus } from '../api/useDiff'; +import { useDiffFileList } from './useDiffFileList'; + +import type { DiffFileEntry, FileChangeStatus } from '../../api/useDiff'; + // ─── Status Badge ─────────────────────────────────────────── @@ -56,33 +57,61 @@ function getFileDir(filePath: string): string { return parts.slice(0, -1).join('/'); } -// ─── Grouped File Utilities ───────────────────────────────── +// ─── File Row ─────────────────────────────────────────────── -interface GroupedFiles { - directory: string; - files: DiffFileEntry[]; +interface FileRowProps { + file: DiffFileEntry; + isSelected: boolean; + showDir: boolean; + onSelect: (filePath: string) => void; } -function groupByDirectory(files: DiffFileEntry[]): GroupedFiles[] { - const groups = new Map<string, DiffFileEntry[]>(); - - for (const file of files) { - const dir = getFileDir(file.filePath); - const key = dir.length > 0 ? dir : '(root)'; - const existing = groups.get(key); - if (existing === undefined) { - groups.set(key, [file]); - } else { - existing.push(file); - } - } - - const result: GroupedFiles[] = []; - for (const [directory, groupFiles] of groups) { - result.push({ directory, files: groupFiles }); - } +function FileRow({ file, isSelected, showDir, onSelect }: FileRowProps) { + const dir = getFileDir(file.filePath); + const hasDir = showDir && dir.length > 0; - return result.sort((a, b) => a.directory.localeCompare(b.directory)); + return ( + <Button + type="button" + variant="ghost" + className={cn( + 'flex h-auto w-full items-center justify-between rounded-none px-3 py-1.5 text-left text-xs', + 'transition-colors', + isSelected + ? 'bg-accent text-accent-foreground' + : 'text-foreground hover:bg-muted/50', + )} + onClick={() => onSelect(file.filePath)} + > + <div className="flex min-w-0 items-center gap-2"> + <StatusBadge status={file.status} /> + <div className="min-w-0"> + <div className="truncate font-mono font-medium"> + {getFileName(file.filePath)} + </div> + {hasDir ? ( + <div className="text-muted-foreground truncate text-[10px]"> + {dir} + </div> + ) : null} + </div> + </div> + <div className="ml-2 flex shrink-0 items-center gap-1.5 font-mono"> + {file.binary ? ( + <span className="text-muted-foreground">bin</span> + ) : ( + <> + {file.insertions > 0 ? ( + <span className="text-success">+{file.insertions}</span> + ) : null} + {file.deletions > 0 ? ( + <span className="text-destructive">-{file.deletions}</span> + ) : null} + </> + )} + </div> + </Button> + ); } // ─── Main Component ───────────────────────────────────────── @@ -100,10 +129,7 @@ export function DiffFileList({ grouped = false, onSelectFile, }: DiffFileListProps) { - const groupedFiles = useMemo( - () => (grouped ? groupByDirectory(files) : null), - [files, grouped], - ); + const { groupedFiles } = useDiffFileList(files, grouped); if (files.length === 0) { return ( @@ -148,60 +174,3 @@ export function DiffFileList({ </ScrollArea> ); } - -// ─── File Row ─────────────────────────────────────────────── - -interface FileRowProps { - file: DiffFileEntry; - isSelected: boolean; - showDir: boolean; - onSelect: (filePath: string) => void; -} - -function FileRow({ file, isSelected, showDir, onSelect }: FileRowProps) { - const dir = getFileDir(file.filePath); - const hasDir = showDir && dir.length > 0; - - return ( - <Button - type="button" - variant="ghost" - className={cn( - 'flex h-auto w-full items-center justify-between rounded-none px-3 py-1.5 text-left text-xs', - 'transition-colors', - isSelected - ? 'bg-accent text-accent-foreground' - : 'text-foreground hover:bg-muted/50', - )} - onClick={() => onSelect(file.filePath)} - > - <div className="flex min-w-0 items-center gap-2"> - <StatusBadge status={file.status} /> - <div className="min-w-0"> - <div className="truncate font-mono font-medium"> - {getFileName(file.filePath)} - </div> - {hasDir ? ( - <div className="text-muted-foreground truncate text-[10px]"> - {dir} - </div> - ) : null} - </div> - </div> - <div className="ml-2 flex shrink-0 items-center gap-1.5 font-mono"> - {file.binary ? ( - <span className="text-muted-foreground">bin</span> - ) : ( - <> - {file.insertions > 0 ? ( - <span className="text-success">+{file.insertions}</span> - ) : null} - {file.deletions > 0 ? ( - <span className="text-destructive">-{file.deletions}</span> - ) : null} - </> - )} - </div> - </Button> - ); -} diff --git a/src/renderer/features/diff-viewer/components/DiffFileList/index.ts b/src/renderer/features/diff-viewer/components/DiffFileList/index.ts new file mode 100644 index 00000000..61734358 --- /dev/null +++ b/src/renderer/features/diff-viewer/components/DiffFileList/index.ts @@ -0,0 +1,2 @@ +export { DiffFileList } from './DiffFileList'; +export { useDiffFileList } from './useDiffFileList'; diff --git a/src/renderer/features/diff-viewer/components/DiffFileList/useDiffFileList.ts b/src/renderer/features/diff-viewer/components/DiffFileList/useDiffFileList.ts new file mode 100644 index 00000000..d02fa78c --- /dev/null +++ b/src/renderer/features/diff-viewer/components/DiffFileList/useDiffFileList.ts @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; + +import type { DiffFileEntry } from '../../api/useDiff'; + +interface GroupedFiles { + directory: string; + files: DiffFileEntry[]; +} + +function getFileDir(filePath: string): string { + const parts = filePath.split('/'); + if (parts.length <= 1) return ''; + return parts.slice(0, -1).join('/'); +} + +function groupByDirectory(files: DiffFileEntry[]): GroupedFiles[] { + const groups = new Map<string, DiffFileEntry[]>(); + + for (const file of files) { + const dir = getFileDir(file.filePath); + const key = dir.length > 0 ? dir : '(root)'; + const existing = groups.get(key); + if (existing === undefined) { + groups.set(key, [file]); + } else { + existing.push(file); + } + } + + const result: GroupedFiles[] = []; + for (const [directory, groupFiles] of groups) { + result.push({ directory, files: groupFiles }); + } + + return result.sort((a, b) => a.directory.localeCompare(b.directory)); +} + +export function useDiffFileList(files: DiffFileEntry[], grouped: boolean) { + const groupedFiles = useMemo( + () => (grouped ? groupByDirectory(files) : null), + [files, grouped], + ); + + return { groupedFiles }; +} diff --git a/src/renderer/features/diff-viewer/components/DiffViewer.tsx b/src/renderer/features/diff-viewer/components/DiffViewer.tsx deleted file mode 100644 index 97593ac0..00000000 --- a/src/renderer/features/diff-viewer/components/DiffViewer.tsx +++ /dev/null @@ -1,302 +0,0 @@ -/** - * DiffViewer — GitHub-style diff viewer using @git-diff-view/react - * - * Renders split (side-by-side) or unified diffs with syntax highlighting, - * line numbers, expandable context lines, and a file header showing path - * and addition/deletion counts. - * - * Theme-aware: uses CSS custom properties via color-mix() for diff line - * backgrounds. No hardcoded colors. - */ - -import { useMemo } from 'react'; - -import { DiffModeEnum, DiffView } from '@git-diff-view/react'; -import '@git-diff-view/react/styles/diff-view.css'; -import { - Columns2, - FileCode, - Minus, - Plus, - Rows2, - UnfoldVertical, -} from 'lucide-react'; - -import { cn } from '@renderer/shared/lib/utils'; -import { useThemeStore } from '@renderer/shared/stores/theme-store'; - -import { Button, Card, CardContent, CardHeader, EmptyState } from '@ui'; - -import type { DiffViewMode } from '../store'; - -// ─── Helpers ──────────────────────────────────────────────── - -/** No-op callback for optional handlers */ -function noop(): void { - /* intentional no-op */ -} - -/** Map file extension to a language hint for syntax highlighting */ -function getFileLang(filePath: string): string { - const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; - const langMap: Record<string, string> = { - ts: 'typescript', - tsx: 'tsx', - js: 'javascript', - jsx: 'jsx', - json: 'json', - css: 'css', - scss: 'scss', - less: 'less', - html: 'xml', - xml: 'xml', - md: 'markdown', - py: 'python', - rs: 'rust', - go: 'go', - java: 'java', - rb: 'ruby', - sh: 'bash', - bash: 'bash', - zsh: 'bash', - yml: 'yaml', - yaml: 'yaml', - sql: 'sql', - graphql: 'graphql', - swift: 'swift', - kt: 'kotlin', - c: 'c', - cpp: 'cpp', - h: 'c', - hpp: 'cpp', - cs: 'csharp', - php: 'php', - lua: 'lua', - r: 'r', - toml: 'ini', - ini: 'ini', - dockerfile: 'dockerfile', - makefile: 'makefile', - }; - return langMap[ext] ?? 'plaintext'; -} - -/** Parse a unified diff to extract hunk strings for @git-diff-view/react */ -function parseHunks(diffText: string): string[] { - if (diffText.length === 0) return []; - - const lines = diffText.split('\n'); - const hunks: string[] = []; - let currentHunk: string[] = []; - - for (const line of lines) { - if (line.startsWith('@@')) { - if (currentHunk.length > 0) { - hunks.push(currentHunk.join('\n')); - } - currentHunk = [line]; - } else if (currentHunk.length > 0) { - currentHunk.push(line); - } - } - - if (currentHunk.length > 0) { - hunks.push(currentHunk.join('\n')); - } - - return hunks; -} - -/** Map our store view mode to the @git-diff-view enum */ -function toDiffModeEnum(viewMode: DiffViewMode): DiffModeEnum { - return viewMode === 'split' ? DiffModeEnum.SplitGitHub : DiffModeEnum.Unified; -} - -/** Resolve effective dark mode from theme store mode */ -function useIsDark(): boolean { - const themeMode = useThemeStore((s) => s.mode); - return useMemo(() => { - if (themeMode === 'system') { - return window.matchMedia('(prefers-color-scheme: dark)').matches; - } - return themeMode === 'dark'; - }, [themeMode]); -} - -// ─── File Header ──────────────────────────────────────────── - -interface FileHeaderProps { - filePath: string; - insertions: number; - deletions: number; - viewMode: DiffViewMode; - expandedContext: boolean; - onToggleViewMode: () => void; - onToggleExpand: () => void; -} - -function FileHeader({ - filePath, - insertions, - deletions, - viewMode, - expandedContext, - onToggleViewMode, - onToggleExpand, -}: FileHeaderProps) { - const isSplit = viewMode === 'split'; - - return ( - <div className="border-border flex items-center justify-between border-b px-4 py-2"> - <div className="flex items-center gap-3"> - <FileCode className="text-muted-foreground h-4 w-4 shrink-0" /> - <span className="text-foreground truncate font-mono text-sm font-medium"> - {filePath} - </span> - <div className="flex items-center gap-2 text-xs"> - {insertions > 0 ? ( - <span className="flex items-center gap-0.5 text-success"> - <Plus className="h-3 w-3" /> - {insertions} - </span> - ) : null} - {deletions > 0 ? ( - <span className="flex items-center gap-0.5 text-destructive"> - <Minus className="h-3 w-3" /> - {deletions} - </span> - ) : null} - </div> - </div> - - <div className="flex items-center gap-1"> - {/* Expand context toggle */} - <Button - aria-label={expandedContext ? 'Collapse context' : 'Expand context'} - size="icon" - type="button" - variant={expandedContext ? 'secondary' : 'ghost'} - onClick={onToggleExpand} - > - <UnfoldVertical className="h-3.5 w-3.5" /> - </Button> - - {/* View mode toggle group */} - <div className="border-border flex items-center gap-0.5 rounded-md border p-0.5"> - <Button - aria-label="Split view" - size="icon" - type="button" - variant={isSplit ? 'secondary' : 'ghost'} - onClick={() => { - if (!isSplit) onToggleViewMode(); - }} - > - <Columns2 className="h-3.5 w-3.5" /> - </Button> - <Button - aria-label="Unified view" - size="icon" - type="button" - variant={isSplit ? 'ghost' : 'secondary'} - onClick={() => { - if (isSplit) onToggleViewMode(); - }} - > - <Rows2 className="h-3.5 w-3.5" /> - </Button> - </div> - </div> - </div> - ); -} - -// ─── DiffViewer (Main Export) ─────────────────────────────── - -interface DiffViewerProps { - /** Raw unified diff text for the file */ - diffText: string; - /** Full file path (used for header and language detection) */ - filePath: string; - /** Number of lines added */ - insertions?: number; - /** Number of lines removed */ - deletions?: number; - /** Split or unified view */ - viewMode?: DiffViewMode; - /** Whether context lines are expanded */ - expandedContext?: boolean; - /** Called when user toggles the view mode */ - onToggleViewMode?: () => void; - /** Called when user toggles context expansion */ - onToggleExpand?: () => void; - /** Additional CSS class */ - className?: string; -} - -export function DiffViewer({ - diffText, - filePath, - insertions = 0, - deletions = 0, - viewMode = 'split', - expandedContext = false, - onToggleViewMode, - onToggleExpand, - className, -}: DiffViewerProps) { - const isDark = useIsDark(); - const hunks = useMemo(() => parseHunks(diffText), [diffText]); - const lang = useMemo(() => getFileLang(filePath), [filePath]); - const diffMode = toDiffModeEnum(viewMode); - const theme = isDark ? 'dark' : 'light'; - - if (hunks.length === 0) { - return ( - <Card className={cn('overflow-hidden', className)}> - <CardHeader className="px-4 py-2"> - <div className="flex items-center gap-2"> - <FileCode className="text-muted-foreground h-4 w-4" /> - <span className="font-mono text-sm">{filePath}</span> - </div> - </CardHeader> - <CardContent className="p-0"> - <EmptyState - className="h-24" - description="No changes in this file" - size="sm" - title="No changes" - /> - </CardContent> - </Card> - ); - } - - return ( - <Card className={cn('overflow-hidden', className)}> - <FileHeader - deletions={deletions} - expandedContext={expandedContext} - filePath={filePath} - insertions={insertions} - viewMode={viewMode} - onToggleExpand={onToggleExpand ?? noop} - onToggleViewMode={onToggleViewMode ?? noop} - /> - <CardContent className="diff-viewer-theme-override p-0"> - <DiffView - diffViewHighlight - diffViewFontSize={13} - diffViewMode={diffMode} - diffViewTheme={theme} - diffViewWrap={false} - data={{ - oldFile: { fileName: filePath, fileLang: lang, content: '' }, - newFile: { fileName: filePath, fileLang: lang, content: '' }, - hunks, - }} - /> - </CardContent> - </Card> - ); -} diff --git a/src/renderer/features/diff-viewer/components/DiffViewer/DiffViewer.tsx b/src/renderer/features/diff-viewer/components/DiffViewer/DiffViewer.tsx new file mode 100644 index 00000000..4c74a3c6 --- /dev/null +++ b/src/renderer/features/diff-viewer/components/DiffViewer/DiffViewer.tsx @@ -0,0 +1,211 @@ +/** + * DiffViewer — GitHub-style diff viewer using @git-diff-view/react + * + * Renders split (side-by-side) or unified diffs with syntax highlighting, + * line numbers, expandable context lines, and a file header showing path + * and addition/deletion counts. + * + * Theme-aware: uses CSS custom properties via color-mix() for diff line + * backgrounds. No hardcoded colors. + */ + +import { DiffView } from '@git-diff-view/react'; +import '@git-diff-view/react/styles/diff-view.css'; +import { + Columns2, + FileCode, + Minus, + Plus, + Rows2, + UnfoldVertical, +} from 'lucide-react'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { Button, Card, CardContent, CardHeader, EmptyState } from '@ui'; + +import { useDiffViewer } from './useDiffViewer'; + +import type { DiffViewMode } from '../../store'; + + +// ─── Helpers ──────────────────────────────────────────────── + +/** No-op callback for optional handlers */ +function noop(): void { + /* intentional no-op */ +} + +// ─── File Header ──────────────────────────────────────────── + +interface FileHeaderProps { + filePath: string; + insertions: number; + deletions: number; + viewMode: DiffViewMode; + expandedContext: boolean; + onToggleViewMode: () => void; + onToggleExpand: () => void; +} + +function FileHeader({ + filePath, + insertions, + deletions, + viewMode, + expandedContext, + onToggleViewMode, + onToggleExpand, +}: FileHeaderProps) { + const isSplit = viewMode === 'split'; + + return ( + <div className="border-border flex items-center justify-between border-b px-4 py-2"> + <div className="flex items-center gap-3"> + <FileCode className="text-muted-foreground h-4 w-4 shrink-0" /> + <span className="text-foreground truncate font-mono text-sm font-medium"> + {filePath} + </span> + <div className="flex items-center gap-2 text-xs"> + {insertions > 0 ? ( + <span className="flex items-center gap-0.5 text-success"> + <Plus className="h-3 w-3" /> + {insertions} + </span> + ) : null} + {deletions > 0 ? ( + <span className="flex items-center gap-0.5 text-destructive"> + <Minus className="h-3 w-3" /> + {deletions} + </span> + ) : null} + </div> + </div> + + <div className="flex items-center gap-1"> + {/* Expand context toggle */} + <Button + aria-label={expandedContext ? 'Collapse context' : 'Expand context'} + size="icon" + type="button" + variant={expandedContext ? 'secondary' : 'ghost'} + onClick={onToggleExpand} + > + <UnfoldVertical className="h-3.5 w-3.5" /> + </Button> + + {/* View mode toggle group */} + <div className="border-border flex items-center gap-0.5 rounded-md border p-0.5"> + <Button + aria-label="Split view" + size="icon" + type="button" + variant={isSplit ? 'secondary' : 'ghost'} + onClick={() => { + if (!isSplit) onToggleViewMode(); + }} + > + <Columns2 className="h-3.5 w-3.5" /> + </Button> + <Button + aria-label="Unified view" + size="icon" + type="button" + variant={isSplit ? 'ghost' : 'secondary'} + onClick={() => { + if (isSplit) onToggleViewMode(); + }} + > + <Rows2 className="h-3.5 w-3.5" /> + </Button> + </div> + </div> + </div> + ); +} + +// ─── DiffViewer (Main Export) ─────────────────────────────── + +interface DiffViewerProps { + /** Raw unified diff text for the file */ + diffText: string; + /** Full file path (used for header and language detection) */ + filePath: string; + /** Number of lines added */ + insertions?: number; + /** Number of lines removed */ + deletions?: number; + /** Split or unified view */ + viewMode?: DiffViewMode; + /** Whether context lines are expanded */ + expandedContext?: boolean; + /** Called when user toggles the view mode */ + onToggleViewMode?: () => void; + /** Called when user toggles context expansion */ + onToggleExpand?: () => void; + /** Additional CSS class */ + className?: string; +} + +export function DiffViewer({ + diffText, + filePath, + insertions = 0, + deletions = 0, + viewMode = 'split', + expandedContext = false, + onToggleViewMode, + onToggleExpand, + className, +}: DiffViewerProps) { + const { hunks, lang, diffMode, theme } = useDiffViewer(diffText, filePath, viewMode); + + if (hunks.length === 0) { + return ( + <Card className={cn('overflow-hidden', className)}> + <CardHeader className="px-4 py-2"> + <div className="flex items-center gap-2"> + <FileCode className="text-muted-foreground h-4 w-4" /> + <span className="font-mono text-sm">{filePath}</span> + </div> + </CardHeader> + <CardContent className="p-0"> + <EmptyState + className="h-24" + description="No changes in this file" + size="sm" + title="No changes" + /> + </CardContent> + </Card> + ); + } + + return ( + <Card className={cn('overflow-hidden', className)}> + <FileHeader + deletions={deletions} + expandedContext={expandedContext} + filePath={filePath} + insertions={insertions} + viewMode={viewMode} + onToggleExpand={onToggleExpand ?? noop} + onToggleViewMode={onToggleViewMode ?? noop} + /> + <CardContent className="diff-viewer-theme-override p-0"> + <DiffView + diffViewHighlight + diffViewFontSize={13} + diffViewMode={diffMode} + diffViewTheme={theme} + diffViewWrap={false} + data={{ + oldFile: { fileName: filePath, fileLang: lang, content: '' }, + newFile: { fileName: filePath, fileLang: lang, content: '' }, + hunks, + }} + /> + </CardContent> + </Card> + ); +} diff --git a/src/renderer/features/diff-viewer/components/DiffViewer/index.ts b/src/renderer/features/diff-viewer/components/DiffViewer/index.ts new file mode 100644 index 00000000..9892e9ea --- /dev/null +++ b/src/renderer/features/diff-viewer/components/DiffViewer/index.ts @@ -0,0 +1,2 @@ +export { DiffViewer } from './DiffViewer'; +export { useDiffViewer } from './useDiffViewer'; diff --git a/src/renderer/features/diff-viewer/components/DiffViewer/useDiffViewer.ts b/src/renderer/features/diff-viewer/components/DiffViewer/useDiffViewer.ts new file mode 100644 index 00000000..28a8a239 --- /dev/null +++ b/src/renderer/features/diff-viewer/components/DiffViewer/useDiffViewer.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; + +import { DiffModeEnum } from '@git-diff-view/react'; + +import { useDarkMode } from '@renderer/shared/hooks/useDarkMode'; +import { useFileLang } from '@renderer/shared/hooks/useFileLangMap'; + +import type { DiffViewMode } from '../../store'; + +/** Parse a unified diff to extract hunk strings for @git-diff-view/react */ +function parseHunks(diffText: string): string[] { + if (diffText.length === 0) return []; + + const lines = diffText.split('\n'); + const hunks: string[] = []; + let currentHunk: string[] = []; + + for (const line of lines) { + if (line.startsWith('@@')) { + if (currentHunk.length > 0) { + hunks.push(currentHunk.join('\n')); + } + currentHunk = [line]; + } else if (currentHunk.length > 0) { + currentHunk.push(line); + } + } + + if (currentHunk.length > 0) { + hunks.push(currentHunk.join('\n')); + } + + return hunks; +} + +/** Map our store view mode to the @git-diff-view enum */ +function toDiffModeEnum(viewMode: DiffViewMode): DiffModeEnum { + return viewMode === 'split' ? DiffModeEnum.SplitGitHub : DiffModeEnum.Unified; +} + +export function useDiffViewer(diffText: string, filePath: string, viewMode: DiffViewMode) { + const isDark = useDarkMode(); + + const hunks = useMemo(() => parseHunks(diffText), [diffText]); + const lang = useFileLang(filePath); + const diffMode = toDiffModeEnum(viewMode); + const theme: 'dark' | 'light' = isDark ? 'dark' : 'light'; + + return { hunks, lang, diffMode, theme }; +} diff --git a/src/renderer/features/file-explorer/components/FileExplorer.tsx b/src/renderer/features/file-explorer/components/FileExplorer.tsx deleted file mode 100644 index 32855dce..00000000 --- a/src/renderer/features/file-explorer/components/FileExplorer.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/** - * FileExplorer -- Virtualized file tree using react-arborist - * - * Renders the project file tree with keyboard navigation, - * expand/collapse, file type icons, and search filtering. - * Designed to sit in the sidebar alongside agent panels. - */ - -import { useCallback, useMemo, useRef } from 'react'; - -import { Tree } from 'react-arborist'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { ScrollArea } from '@ui/scroll-area'; -import { SearchInput } from '@ui/search-input'; -import { Spinner } from '@ui/spinner'; -import { Text } from '@ui/typography'; - -import { useFileTree } from '../api/useFileTree'; -import { useFileTreeEvents } from '../hooks/useFileTreeEvents'; -import { useFileExplorerUI } from '../store'; - -import { FileNode } from './FileNode'; - -import type { FileTreeNode } from '../api/useFileTree'; -import type { NodeApi, TreeApi } from 'react-arborist'; - -// ─── Constants ───────────────────────────────────────────────── - -const ROW_HEIGHT = 28; -const INDENT = 16; -const DEFAULT_HEIGHT = 600; - -// ─── Props ───────────────────────────────────────────────────── - -interface FileExplorerProps { - /** Path to the project root */ - projectPath: string | null; - /** Height of the tree container in pixels */ - height?: number; - /** Callback when a file is activated (clicked/Enter) */ - onFileActivate?: (filePath: string) => void; - /** Additional CSS classes for the root container */ - className?: string; -} - -// ─── Component ───────────────────────────────────────────────── - -export function FileExplorer({ - projectPath, - height = DEFAULT_HEIGHT, - onFileActivate, - className, -}: FileExplorerProps) { - const treeRef = useRef<TreeApi<FileTreeNode> | undefined>(undefined); - const { data: nodes, isLoading, error } = useFileTree(projectPath); - const { searchQuery, setSearchQuery, clearSearch, selectNode } = useFileExplorerUI(); - - // Subscribe to file-watcher events for cache invalidation - useFileTreeEvents(); - - // ─── Handlers ──────────────────────────────────────────────── - - const handleActivate = useCallback( - (node: NodeApi<FileTreeNode>) => { - if (!node.data.isDirectory) { - selectNode(node.id); - onFileActivate?.(node.id); - } - }, - [onFileActivate, selectNode], - ); - - const handleSelect = useCallback( - (selected: Array<NodeApi<FileTreeNode>>) => { - if (selected.length > 0) { - selectNode(selected[0].id); - } else { - selectNode(null); - } - }, - [selectNode], - ); - - const handleSearchMatch = useCallback( - (node: NodeApi<FileTreeNode>, term: string): boolean => { - return node.data.name.toLowerCase().includes(term.toLowerCase()); - }, - [], - ); - - // ─── Accessors ─────────────────────────────────────────────── - - const childrenAccessor = useCallback( - (d: FileTreeNode): readonly FileTreeNode[] | null => d.children, - [], - ); - - const idAccessor = useCallback((d: FileTreeNode): string => d.id, []); - - // ─── Memoized search term ──────────────────────────────────── - - const trimmedSearch = useMemo(() => searchQuery.trim(), [searchQuery]); - - // ─── Render ────────────────────────────────────────────────── - - if (projectPath === null) { - return ( - <div className={cn('flex items-center justify-center p-4', className)}> - <Text className="text-muted-foreground">No project selected</Text> - </div> - ); - } - - if (isLoading) { - return ( - <div className={cn('flex items-center justify-center p-4', className)}> - <Spinner size="sm" /> - </div> - ); - } - - if (error !== null) { - return ( - <div className={cn('flex items-center justify-center p-4', className)}> - <Text className="text-destructive">Failed to load files</Text> - </div> - ); - } - - return ( - <div className={cn('flex flex-col', className)}> - {/* Search bar */} - <div className="px-2 pb-2"> - <SearchInput - className="h-7 text-xs" - placeholder="Search files..." - size="sm" - value={searchQuery} - onClear={clearSearch} - onChange={(e) => { - setSearchQuery(e.target.value); - }} - /> - </div> - - {/* Tree */} - <ScrollArea className="flex-1"> - <Tree<FileTreeNode> - ref={treeRef} - disableDrag - disableDrop - disableEdit - disableMultiSelection - childrenAccessor={childrenAccessor} - className="file-explorer-tree" - data={nodes ?? []} - height={height} - idAccessor={idAccessor} - indent={INDENT} - openByDefault={false} - rowHeight={ROW_HEIGHT} - searchMatch={handleSearchMatch} - searchTerm={trimmedSearch.length > 0 ? trimmedSearch : undefined} - width="100%" - onActivate={handleActivate} - onSelect={handleSelect} - > - {FileNode} - </Tree> - </ScrollArea> - </div> - ); -} diff --git a/src/renderer/features/file-explorer/api/queryKeys.ts b/src/renderer/features/files/api/queryKeys.ts similarity index 100% rename from src/renderer/features/file-explorer/api/queryKeys.ts rename to src/renderer/features/files/api/queryKeys.ts diff --git a/src/renderer/features/file-explorer/api/useFileTree.ts b/src/renderer/features/files/api/useFileTree.ts similarity index 100% rename from src/renderer/features/file-explorer/api/useFileTree.ts rename to src/renderer/features/files/api/useFileTree.ts diff --git a/src/renderer/features/files/components/FileExplorer.tsx b/src/renderer/features/files/components/FileExplorer.tsx new file mode 100644 index 00000000..c066e5ae --- /dev/null +++ b/src/renderer/features/files/components/FileExplorer.tsx @@ -0,0 +1,2 @@ +// Re-export from folder — see FileExplorer/FileExplorer.tsx +export { FileExplorer } from './FileExplorer/FileExplorer'; diff --git a/src/renderer/features/files/components/FileExplorer/FileExplorer.tsx b/src/renderer/features/files/components/FileExplorer/FileExplorer.tsx new file mode 100644 index 00000000..2f36c781 --- /dev/null +++ b/src/renderer/features/files/components/FileExplorer/FileExplorer.tsx @@ -0,0 +1,134 @@ +/** + * FileExplorer -- Virtualized file tree using react-arborist + * + * Renders the project file tree with keyboard navigation, + * expand/collapse, file type icons, and search filtering. + * Designed to sit in the sidebar alongside agent panels. + */ + +import { Tree } from 'react-arborist'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { ScrollArea } from '@ui/scroll-area'; +import { SearchInput } from '@ui/search-input'; +import { Spinner } from '@ui/spinner'; +import { Text } from '@ui/typography'; + +import { FileNode } from '../FileNode'; + +import { useFileExplorer } from './useFileExplorer'; + +import type { FileTreeNode } from '../../api/useFileTree'; + +// ─── Constants ───────────────────────────────────────────────── + +const ROW_HEIGHT = 28; +const INDENT = 16; +const DEFAULT_HEIGHT = 600; + +// ─── Props ───────────────────────────────────────────────────── + +interface FileExplorerProps { + /** Path to the project root */ + projectPath: string | null; + /** Height of the tree container in pixels */ + height?: number; + /** Callback when a file is activated (clicked/Enter) */ + onFileActivate?: (filePath: string) => void; + /** Additional CSS classes for the root container */ + className?: string; +} + +// ─── Component ───────────────────────────────────────────────── + +export function FileExplorer({ + projectPath, + height = DEFAULT_HEIGHT, + onFileActivate, + className, +}: FileExplorerProps) { + const { + treeRef, + nodes, + isLoading, + error, + searchQuery, + setSearchQuery, + clearSearch, + trimmedSearch, + handleActivate, + handleSelect, + handleSearchMatch, + childrenAccessor, + idAccessor, + } = useFileExplorer({ projectPath, onFileActivate }); + + if (projectPath === null) { + return ( + <div className={cn('flex items-center justify-center p-4', className)}> + <Text className="text-muted-foreground">No project selected</Text> + </div> + ); + } + + if (isLoading) { + return ( + <div className={cn('flex items-center justify-center p-4', className)}> + <Spinner size="sm" /> + </div> + ); + } + + if (error !== null) { + return ( + <div className={cn('flex items-center justify-center p-4', className)}> + <Text className="text-destructive">Failed to load files</Text> + </div> + ); + } + + return ( + <div className={cn('flex flex-col', className)}> + {/* Search bar */} + <div className="px-2 pb-2"> + <SearchInput + className="h-7 text-xs" + placeholder="Search files..." + size="sm" + value={searchQuery} + onClear={clearSearch} + onChange={(e) => { + setSearchQuery(e.target.value); + }} + /> + </div> + + {/* Tree */} + <ScrollArea className="flex-1"> + <Tree<FileTreeNode> + ref={treeRef} + disableDrag + disableDrop + disableEdit + disableMultiSelection + childrenAccessor={childrenAccessor} + className="file-explorer-tree" + data={nodes ?? []} + height={height} + idAccessor={idAccessor} + indent={INDENT} + openByDefault={false} + rowHeight={ROW_HEIGHT} + searchMatch={handleSearchMatch} + searchTerm={trimmedSearch.length > 0 ? trimmedSearch : undefined} + width="100%" + onActivate={handleActivate} + onSelect={handleSelect} + > + {FileNode} + </Tree> + </ScrollArea> + </div> + ); +} diff --git a/src/renderer/features/files/components/FileExplorer/index.ts b/src/renderer/features/files/components/FileExplorer/index.ts new file mode 100644 index 00000000..ae4cda57 --- /dev/null +++ b/src/renderer/features/files/components/FileExplorer/index.ts @@ -0,0 +1,2 @@ +export { FileExplorer } from './FileExplorer'; +export { useFileExplorer } from './useFileExplorer'; diff --git a/src/renderer/features/files/components/FileExplorer/useFileExplorer.ts b/src/renderer/features/files/components/FileExplorer/useFileExplorer.ts new file mode 100644 index 00000000..7875c90e --- /dev/null +++ b/src/renderer/features/files/components/FileExplorer/useFileExplorer.ts @@ -0,0 +1,75 @@ +import { useCallback, useMemo, useRef } from 'react'; + +import { useFileTree } from '../../api/useFileTree'; +import { useFileTreeEvents } from '../../hooks/useFileTreeEvents'; +import { useFileExplorerUI } from '../../store'; + +import type { FileTreeNode } from '../../api/useFileTree'; +import type { NodeApi, TreeApi } from 'react-arborist'; + +interface UseFileExplorerOptions { + projectPath: string | null; + onFileActivate?: (filePath: string) => void; +} + +export function useFileExplorer({ projectPath, onFileActivate }: UseFileExplorerOptions) { + const treeRef = useRef<TreeApi<FileTreeNode> | undefined>(undefined); + const { data: nodes, isLoading, error } = useFileTree(projectPath); + const { searchQuery, setSearchQuery, clearSearch, selectNode } = useFileExplorerUI(); + + // Subscribe to file-watcher events for cache invalidation + useFileTreeEvents(); + + const handleActivate = useCallback( + (node: NodeApi<FileTreeNode>) => { + if (!node.data.isDirectory) { + selectNode(node.id); + onFileActivate?.(node.id); + } + }, + [onFileActivate, selectNode], + ); + + const handleSelect = useCallback( + (selected: Array<NodeApi<FileTreeNode>>) => { + if (selected.length > 0) { + selectNode(selected[0].id); + } else { + selectNode(null); + } + }, + [selectNode], + ); + + const handleSearchMatch = useCallback( + (node: NodeApi<FileTreeNode>, term: string): boolean => { + return node.data.name.toLowerCase().includes(term.toLowerCase()); + }, + [], + ); + + const childrenAccessor = useCallback( + (d: FileTreeNode): readonly FileTreeNode[] | null => d.children, + [], + ); + + const idAccessor = useCallback((d: FileTreeNode): string => d.id, []); + + const trimmedSearch = useMemo(() => searchQuery.trim(), [searchQuery]); + + return { + treeRef, + nodes, + isLoading, + error, + searchQuery, + setSearchQuery, + clearSearch, + trimmedSearch, + handleActivate, + handleSelect, + handleSearchMatch, + childrenAccessor, + idAccessor, + }; +} diff --git a/src/renderer/features/file-explorer/components/FileNode.tsx b/src/renderer/features/files/components/FileNode.tsx similarity index 100% rename from src/renderer/features/file-explorer/components/FileNode.tsx rename to src/renderer/features/files/components/FileNode.tsx diff --git a/src/renderer/features/file-explorer/hooks/useFileTreeEvents.ts b/src/renderer/features/files/hooks/useFileTreeEvents.ts similarity index 100% rename from src/renderer/features/file-explorer/hooks/useFileTreeEvents.ts rename to src/renderer/features/files/hooks/useFileTreeEvents.ts diff --git a/src/renderer/features/file-explorer/index.ts b/src/renderer/features/files/index.ts similarity index 100% rename from src/renderer/features/file-explorer/index.ts rename to src/renderer/features/files/index.ts diff --git a/src/renderer/features/file-explorer/store.ts b/src/renderer/features/files/store.ts similarity index 100% rename from src/renderer/features/file-explorer/store.ts rename to src/renderer/features/files/store.ts diff --git a/src/renderer/features/personal/fitness/api/queryKeys.ts b/src/renderer/features/fitness/api/queryKeys.ts similarity index 100% rename from src/renderer/features/personal/fitness/api/queryKeys.ts rename to src/renderer/features/fitness/api/queryKeys.ts diff --git a/src/renderer/features/personal/fitness/api/useFitness.ts b/src/renderer/features/fitness/api/useFitness.ts similarity index 100% rename from src/renderer/features/personal/fitness/api/useFitness.ts rename to src/renderer/features/fitness/api/useFitness.ts diff --git a/src/renderer/features/personal/fitness/api/useFitnessMutations.ts b/src/renderer/features/fitness/api/useFitnessMutations.ts similarity index 100% rename from src/renderer/features/personal/fitness/api/useFitnessMutations.ts rename to src/renderer/features/fitness/api/useFitnessMutations.ts diff --git a/src/renderer/features/fitness/components/BodyComposition.tsx b/src/renderer/features/fitness/components/BodyComposition.tsx new file mode 100644 index 00000000..9caea9b6 --- /dev/null +++ b/src/renderer/features/fitness/components/BodyComposition.tsx @@ -0,0 +1,2 @@ +// Re-export from folder — see BodyComposition/BodyComposition.tsx +export { BodyComposition } from './BodyComposition/BodyComposition'; diff --git a/src/renderer/features/fitness/components/BodyComposition/BodyComposition.tsx b/src/renderer/features/fitness/components/BodyComposition/BodyComposition.tsx new file mode 100644 index 00000000..2a238257 --- /dev/null +++ b/src/renderer/features/fitness/components/BodyComposition/BodyComposition.tsx @@ -0,0 +1,329 @@ +/** + * BodyComposition — Body composition breakdown and measurement logging + */ + +import { Pencil, Plus, Scale, Trash2 } from 'lucide-react'; + +import type { BodyMeasurement } from '@shared/types'; + +import { RelativeTime } from '@renderer/shared/components/RelativeTime'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Card, + CardContent, + EmptyState, + Heading, + Input, + Label, + Text, +} from '@ui'; + +import { MeasurementEditDialog } from '../MeasurementEditDialog'; + +import { useBodyComposition, useMeasurementHistoryRow } from './useBodyComposition'; + +// ── Component ──────────────────────────────────────────────── + +export function BodyComposition() { + const { + displayMeasurements, + latest, + isFiltered, + showForm, + setShowForm, + weight, + setWeight, + bodyFat, + setBodyFat, + dateFrom, + setDateFrom, + dateTo, + setDateTo, + handleSubmit, + handleClearFilter, + } = useBodyComposition(); + + return ( + <div className="space-y-4"> + {/* Date-range filter */} + <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-3"> + <div className="flex-1"> + <Label className="mb-1" htmlFor="measure-date-from"> + From + </Label> + <Input + id="measure-date-from" + type="date" + value={dateFrom} + onChange={(e) => setDateFrom(e.target.value)} + /> + </div> + <div className="flex-1"> + <Label className="mb-1" htmlFor="measure-date-to"> + To + </Label> + <Input + id="measure-date-to" + type="date" + value={dateTo} + onChange={(e) => setDateTo(e.target.value)} + /> + </div> + {isFiltered ? ( + <Button size="sm" type="button" variant="ghost" onClick={handleClearFilter}> + Clear + </Button> + ) : null} + </div> + + {/* Latest measurements */} + {latest === null ? ( + <EmptyState + icon={Scale} + size="sm" + title="" + description={ + isFiltered ? 'No measurements in this date range' : 'No measurements recorded yet' + } + /> + ) : ( + <Card> + <CardContent className="p-4"> + <Heading + as="h4" + className="text-muted-foreground mb-3 text-xs font-medium tracking-wider uppercase" + > + Latest Measurements + </Heading> + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3"> + {latest.weight === undefined ? null : ( + <div> + <span className="text-muted-foreground text-xs">Weight</span> + <Text className="text-lg font-bold">{String(latest.weight)} kg</Text> + </div> + )} + {latest.bodyFat === undefined ? null : ( + <div> + <span className="text-muted-foreground text-xs">Body Fat</span> + <Text className="text-lg font-bold">{String(latest.bodyFat)}%</Text> + </div> + )} + {latest.muscleMass === undefined ? null : ( + <div> + <span className="text-muted-foreground text-xs">Muscle Mass</span> + <Text className="text-lg font-bold">{String(latest.muscleMass)} kg</Text> + </div> + )} + {latest.boneMass === undefined ? null : ( + <div> + <span className="text-muted-foreground text-xs">Bone Mass</span> + <Text className="text-lg font-bold">{String(latest.boneMass)} kg</Text> + </div> + )} + {latest.waterPercentage === undefined ? null : ( + <div> + <span className="text-muted-foreground text-xs">Water</span> + <Text className="text-lg font-bold">{String(latest.waterPercentage)}%</Text> + </div> + )} + {latest.visceralFat === undefined ? null : ( + <div> + <span className="text-muted-foreground text-xs">Visceral Fat</span> + <Text className="text-lg font-bold">{String(latest.visceralFat)}</Text> + </div> + )} + </div> + <Text className="mt-2" size="sm" variant="muted"> + {latest.date} · {latest.source} + </Text> + </CardContent> + </Card> + )} + + {/* Add measurement */} + {showForm ? ( + <Card> + <CardContent className="p-4"> + <Heading as="h4" className="text-foreground mb-3 text-sm font-medium"> + Log Measurement + </Heading> + <div className="flex gap-3"> + <div className="flex-1"> + <Label className="mb-1" htmlFor="measure-weight"> + Weight (kg) + </Label> + <Input + id="measure-weight" + placeholder="75.5" + type="number" + value={weight} + onChange={(e) => setWeight(e.target.value)} + /> + </div> + <div className="flex-1"> + <Label className="mb-1" htmlFor="measure-bodyfat"> + Body Fat (%) + </Label> + <Input + id="measure-bodyfat" + placeholder="18.5" + type="number" + value={bodyFat} + onChange={(e) => setBodyFat(e.target.value)} + /> + </div> + </div> + <div className="mt-3 flex gap-2"> + <Button + className="flex-1" + disabled={weight === '' && bodyFat === ''} + type="button" + onClick={handleSubmit} + > + Save + </Button> + <Button type="button" variant="ghost" onClick={() => setShowForm(false)}> + Cancel + </Button> + </div> + </CardContent> + </Card> + ) : ( + <Button + className="text-primary" + type="button" + variant="ghost" + onClick={() => setShowForm(true)} + > + <Plus className="h-4 w-4" /> + Log Measurement + </Button> + )} + + {/* History */} + {displayMeasurements.length > 1 ? ( + <Card> + <Heading + as="h4" + className="text-muted-foreground border-border border-b px-4 py-2 text-xs font-medium tracking-wider uppercase" + > + History + </Heading> + <div className="divide-border divide-y"> + {displayMeasurements.slice(0, 10).map((m) => ( + <MeasurementHistoryRow key={m.id} measurement={m} /> + ))} + </div> + </Card> + ) : null} + </div> + ); +} + +// ── MeasurementHistoryRow ──────────────────────────────────── + +interface MeasurementHistoryRowProps { + measurement: BodyMeasurement; +} + +function MeasurementHistoryRow({ measurement: m }: MeasurementHistoryRowProps) { + const { + editOpen, + setEditOpen, + deleteOpen, + setDeleteOpen, + handleDeleteConfirm, + } = useMeasurementHistoryRow(m.id); + + return ( + <> + <div className="flex items-center justify-between px-4 py-2"> + <div className="flex-1"> + <span className="text-foreground text-sm">{m.date}</span> + <div className="mt-0.5 flex flex-wrap gap-3"> + {m.weight === undefined ? null : ( + <span className="text-muted-foreground text-xs">{String(m.weight)} kg</span> + )} + {m.bodyFat === undefined ? null : ( + <span className="text-muted-foreground text-xs">{String(m.bodyFat)}% fat</span> + )} + {m.muscleMass === undefined ? null : ( + <span className="text-muted-foreground text-xs"> + {String(m.muscleMass)} kg muscle + </span> + )} + {m.boneMass === undefined ? null : ( + <span className="text-muted-foreground text-xs">{String(m.boneMass)} kg bone</span> + )} + {m.waterPercentage === undefined ? null : ( + <span className="text-muted-foreground text-xs"> + {String(m.waterPercentage)}% water + </span> + )} + {m.visceralFat === undefined ? null : ( + <span className="text-muted-foreground text-xs"> + visceral {String(m.visceralFat)} + </span> + )} + <span className="text-muted-foreground text-xs capitalize">{m.source}</span> + </div> + <RelativeTime value={m.createdAt} /> + </div> + <div className="flex items-center gap-1"> + <Button + aria-label="Edit measurement" + className="text-muted-foreground hover:text-foreground" + size="icon" + type="button" + variant="ghost" + onClick={() => setEditOpen(true)} + > + <Pencil className="h-4 w-4" /> + </Button> + <Button + aria-label="Delete measurement" + className="text-muted-foreground hover:text-destructive" + size="icon" + type="button" + variant="ghost" + onClick={() => setDeleteOpen(true)} + > + <Trash2 className="h-4 w-4" /> + </Button> + </div> + </div> + + <MeasurementEditDialog measurement={m} open={editOpen} onOpenChange={setEditOpen} /> + + <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete measurement?</AlertDialogTitle> + <AlertDialogDescription> + This will permanently remove the measurement from {m.date}. This action cannot be + undone. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + onClick={handleDeleteConfirm} + > + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </> + ); +} diff --git a/src/renderer/features/fitness/components/BodyComposition/index.ts b/src/renderer/features/fitness/components/BodyComposition/index.ts new file mode 100644 index 00000000..f3972ea8 --- /dev/null +++ b/src/renderer/features/fitness/components/BodyComposition/index.ts @@ -0,0 +1,2 @@ +export { BodyComposition } from './BodyComposition'; +export { useBodyComposition } from './useBodyComposition'; diff --git a/src/renderer/features/fitness/components/BodyComposition/useBodyComposition.ts b/src/renderer/features/fitness/components/BodyComposition/useBodyComposition.ts new file mode 100644 index 00000000..fa2b93a5 --- /dev/null +++ b/src/renderer/features/fitness/components/BodyComposition/useBodyComposition.ts @@ -0,0 +1,102 @@ +import { useCallback, useState } from 'react'; + +import type { BodyMeasurement } from '@shared/types'; + +import { useToday } from '@renderer/shared/hooks/useToday'; + +import { useDeleteMeasurement, useLogMeasurement, useMeasurements } from '../../api/useFitness'; + +export function useBodyComposition() { + const { data: measurements } = useMeasurements(30); + const logMeasurement = useLogMeasurement(); + const today = useToday(); + + const [showForm, setShowForm] = useState(false); + const [weight, setWeight] = useState(''); + const [bodyFat, setBodyFat] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + + const allMeasurements: BodyMeasurement[] = measurements ?? []; + + const displayMeasurements = allMeasurements.filter((m) => { + if (dateFrom !== '' && m.date < dateFrom) return false; + if (dateTo !== '' && m.date > dateTo) return false; + return true; + }); + + const latest = displayMeasurements.length > 0 ? displayMeasurements[0] : null; + const isFiltered = dateFrom !== '' || dateTo !== ''; + + function handleSubmit(): void { + const weightNum = weight === '' ? undefined : Number(weight); + const bodyFatNum = bodyFat === '' ? undefined : Number(bodyFat); + + if (weightNum === undefined && bodyFatNum === undefined) return; + + logMeasurement.mutate( + { + date: today, + weight: weightNum, + bodyFat: bodyFatNum, + source: 'manual', + }, + { + onSuccess: () => { + setWeight(''); + setBodyFat(''); + setShowForm(false); + }, + }, + ); + } + + function handleClearFilter(): void { + setDateFrom(''); + setDateTo(''); + } + + return { + displayMeasurements, + latest, + isFiltered, + showForm, + setShowForm, + weight, + setWeight, + bodyFat, + setBodyFat, + dateFrom, + setDateFrom, + dateTo, + setDateTo, + handleSubmit, + handleClearFilter, + }; +} + +export interface MeasurementHistoryRowState { + editOpen: boolean; + setEditOpen: (open: boolean) => void; + deleteOpen: boolean; + setDeleteOpen: (open: boolean) => void; + handleDeleteConfirm: () => void; +} + +export function useMeasurementHistoryRow(measurementId: string): MeasurementHistoryRowState { + const deleteMeasurement = useDeleteMeasurement(); + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const handleDeleteConfirm = useCallback(() => { + deleteMeasurement.mutate(measurementId); + }, [deleteMeasurement, measurementId]); + + return { + editOpen, + setEditOpen, + deleteOpen, + setDeleteOpen, + handleDeleteConfirm, + }; +} diff --git a/src/renderer/features/personal/fitness/components/FitnessPage.tsx b/src/renderer/features/fitness/components/FitnessPage.tsx similarity index 95% rename from src/renderer/features/personal/fitness/components/FitnessPage.tsx rename to src/renderer/features/fitness/components/FitnessPage.tsx index c9ae229e..7ae5d24c 100644 --- a/src/renderer/features/personal/fitness/components/FitnessPage.tsx +++ b/src/renderer/features/fitness/components/FitnessPage.tsx @@ -4,7 +4,7 @@ import { Dumbbell, Plus, Scale, Target, TrendingUp } from 'lucide-react'; -import { Button, PageContent, PageHeader, PageLayout } from '@ui'; +import { Button, Heading, PageContent, PageHeader, PageLayout } from '@ui'; import { useFitnessEvents } from '../hooks/useFitnessEvents'; import { useFitnessUI } from '../store'; @@ -92,7 +92,7 @@ function OverviewTab() { <div className="space-y-6"> <StatsOverview /> <div> - <h3 className="text-foreground mb-3 text-sm font-semibold">Recent Workouts</h3> + <Heading as="h3" className="mb-3 text-sm">Recent Workouts</Heading> <div className="bg-card border-border rounded-lg border"> <WorkoutLog /> </div> diff --git a/src/renderer/features/fitness/components/GoalEditDialog.tsx b/src/renderer/features/fitness/components/GoalEditDialog.tsx new file mode 100644 index 00000000..fc3528be --- /dev/null +++ b/src/renderer/features/fitness/components/GoalEditDialog.tsx @@ -0,0 +1,2 @@ +// Re-export from folder — see GoalEditDialog/GoalEditDialog.tsx +export { GoalEditDialog } from './GoalEditDialog/GoalEditDialog'; diff --git a/src/renderer/features/fitness/components/GoalEditDialog/GoalEditDialog.tsx b/src/renderer/features/fitness/components/GoalEditDialog/GoalEditDialog.tsx new file mode 100644 index 00000000..5305491a --- /dev/null +++ b/src/renderer/features/fitness/components/GoalEditDialog/GoalEditDialog.tsx @@ -0,0 +1,158 @@ +/** + * GoalEditDialog — Edit an existing fitness goal's type, target, unit, and deadline + */ + +import type { FitnessGoal, FitnessGoalType } from '@shared/types'; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@ui'; + +import { useGoalEditDialog } from './useGoalEditDialog'; + +// ── Constants ──────────────────────────────────────────────── + +const GOAL_TYPE_LABELS: Record<FitnessGoalType, string> = { + weight: 'Weight', + workout_frequency: 'Workout Frequency', + lift_target: 'Lift Target', + cardio_target: 'Cardio Target', +}; + +const GOAL_TYPES: FitnessGoalType[] = [ + 'weight', + 'workout_frequency', + 'lift_target', + 'cardio_target', +]; + +// ── Props ──────────────────────────────────────────────────── + +interface GoalEditDialogProps { + goal: FitnessGoal; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// ── Component ──────────────────────────────────────────────── + +export function GoalEditDialog({ goal, open, onOpenChange }: GoalEditDialogProps) { + const { + goalType, + setGoalType, + target, + setTarget, + unit, + setUnit, + deadline, + setDeadline, + isSaveDisabled, + handleSave, + } = useGoalEditDialog({ goal, onOpenChange }); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Edit Goal</DialogTitle> + </DialogHeader> + + <div className="space-y-4 py-2"> + {/* Goal Type */} + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-goal-type" + > + Goal Type + </Label> + <Select value={goalType} onValueChange={(v) => setGoalType(v as FitnessGoalType)}> + <SelectTrigger id="edit-goal-type"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {GOAL_TYPES.map((gt) => ( + <SelectItem key={gt} value={gt}> + {GOAL_TYPE_LABELS[gt]} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* Target + Unit */} + <div className="flex gap-3"> + <div className="flex-1"> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-goal-target" + > + Target + </Label> + <Input + id="edit-goal-target" + min="1" + placeholder="100" + type="number" + value={target} + onChange={(e) => setTarget(e.target.value)} + /> + </div> + <div className="w-24"> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-goal-unit" + > + Unit + </Label> + <Input + id="edit-goal-unit" + placeholder="kg" + type="text" + value={unit} + onChange={(e) => setUnit(e.target.value)} + /> + </div> + </div> + + {/* Deadline */} + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-goal-deadline" + > + Deadline (optional) + </Label> + <Input + id="edit-goal-deadline" + type="date" + value={deadline} + onChange={(e) => setDeadline(e.target.value)} + /> + </div> + </div> + + <DialogFooter> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button disabled={isSaveDisabled} type="button" onClick={handleSave}> + Save Changes + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/fitness/components/GoalEditDialog/index.ts b/src/renderer/features/fitness/components/GoalEditDialog/index.ts new file mode 100644 index 00000000..406b4dfd --- /dev/null +++ b/src/renderer/features/fitness/components/GoalEditDialog/index.ts @@ -0,0 +1,2 @@ +export { GoalEditDialog } from './GoalEditDialog'; +export { useGoalEditDialog } from './useGoalEditDialog'; diff --git a/src/renderer/features/fitness/components/GoalEditDialog/useGoalEditDialog.ts b/src/renderer/features/fitness/components/GoalEditDialog/useGoalEditDialog.ts new file mode 100644 index 00000000..71d11d49 --- /dev/null +++ b/src/renderer/features/fitness/components/GoalEditDialog/useGoalEditDialog.ts @@ -0,0 +1,48 @@ +import { useState } from 'react'; + +import type { FitnessGoal, FitnessGoalType } from '@shared/types'; + +import { useUpdateGoal } from '../../api/useFitness'; +import { useEditDialog } from '../../hooks/useEditDialog'; + +interface UseGoalEditDialogOptions { + goal: FitnessGoal; + onOpenChange: (open: boolean) => void; +} + +export function useGoalEditDialog({ goal, onOpenChange }: UseGoalEditDialogOptions) { + const updateGoal = useUpdateGoal(); + + const [goalType, setGoalType] = useState<FitnessGoalType>(goal.type); + const [target, setTarget] = useState(String(goal.target)); + const [unit, setUnit] = useState(goal.unit); + const [deadline, setDeadline] = useState(goal.deadline ?? ''); + + const { isSaveDisabled, handleSave } = useEditDialog({ + mutation: updateGoal, + buildInput: () => { + if (Number(target) <= 0 || unit.trim().length === 0) return null; + return { + id: goal.id, + type: goalType, + target: Number(target), + unit: unit.trim(), + deadline: deadline.trim().length > 0 ? deadline.trim() : null, + }; + }, + onClose: () => onOpenChange(false), + }); + + return { + goalType, + setGoalType, + target, + setTarget, + unit, + setUnit, + deadline, + setDeadline, + isSaveDisabled, + handleSave, + }; +} diff --git a/src/renderer/features/fitness/components/GoalsPanel.tsx b/src/renderer/features/fitness/components/GoalsPanel.tsx new file mode 100644 index 00000000..83819fc3 --- /dev/null +++ b/src/renderer/features/fitness/components/GoalsPanel.tsx @@ -0,0 +1,2 @@ +// Re-export from folder — see GoalsPanel/GoalsPanel.tsx +export { GoalsPanel } from './GoalsPanel/GoalsPanel'; diff --git a/src/renderer/features/fitness/components/GoalsPanel/GoalCard.tsx b/src/renderer/features/fitness/components/GoalsPanel/GoalCard.tsx new file mode 100644 index 00000000..04770476 --- /dev/null +++ b/src/renderer/features/fitness/components/GoalsPanel/GoalCard.tsx @@ -0,0 +1,126 @@ +/** + * GoalCard — Individual fitness goal card with edit/delete actions + */ + +import { Pencil, Trash2 } from 'lucide-react'; + +import type { FitnessGoal, FitnessGoalType } from '@shared/types'; + +import { RelativeTime } from '@renderer/shared/components/RelativeTime'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Card, + CardContent, + Progress, + Text, +} from '@ui'; + +import { GoalEditDialog } from '../GoalEditDialog'; + +import { useGoalCard } from './useGoalsPanel'; + +// ── Constants ──────────────────────────────────────────────── + +const GOAL_TYPE_LABELS: Record<FitnessGoalType, string> = { + weight: 'Weight', + workout_frequency: 'Workout Frequency', + lift_target: 'Lift Target', + cardio_target: 'Cardio Target', +}; + +// ── Props ──────────────────────────────────────────────────── + +interface GoalCardProps { + goal: FitnessGoal; +} + +// ── Component ──────────────────────────────────────────────── + +export function GoalCard({ goal }: GoalCardProps) { + const { editOpen, setEditOpen, deleteOpen, setDeleteOpen, handleDeleteConfirm } = useGoalCard( + goal.id, + ); + + const progress = goal.target > 0 ? Math.min((goal.current / goal.target) * 100, 100) : 0; + + return ( + <> + <Card> + <CardContent className="p-4"> + <div className="flex items-start justify-between"> + <div> + <span className="text-muted-foreground text-xs font-medium"> + {GOAL_TYPE_LABELS[goal.type]} + </span> + <Text className="font-semibold" size="sm"> + {String(goal.current)} / {String(goal.target)} {goal.unit} + </Text> + </div> + <div className="flex items-center gap-1"> + <Button + aria-label="Edit goal" + className="text-muted-foreground hover:text-foreground h-7 w-7" + size="icon" + type="button" + variant="ghost" + onClick={() => setEditOpen(true)} + > + <Pencil className="h-3 w-3" /> + </Button> + <Button + aria-label="Delete goal" + className="text-muted-foreground hover:text-destructive h-7 w-7" + size="icon" + type="button" + variant="ghost" + onClick={() => setDeleteOpen(true)} + > + <Trash2 className="h-3 w-3" /> + </Button> + </div> + </div> + <Progress className="mt-2" size="sm" value={Math.round(progress)} /> + <Text className="mt-1 text-xs" variant="muted"> + {String(Math.round(progress))}% complete + {goal.deadline === undefined ? '' : ` \u00B7 Due ${goal.deadline}`} + </Text> + <div className="mt-1"> + <RelativeTime value={goal.createdAt} /> + </div> + </CardContent> + </Card> + + <GoalEditDialog goal={goal} open={editOpen} onOpenChange={setEditOpen} /> + + <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete goal?</AlertDialogTitle> + <AlertDialogDescription> + This will permanently remove the {GOAL_TYPE_LABELS[goal.type]} goal. This action + cannot be undone. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + onClick={handleDeleteConfirm} + > + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </> + ); +} diff --git a/src/renderer/features/fitness/components/GoalsPanel/GoalsPanel.tsx b/src/renderer/features/fitness/components/GoalsPanel/GoalsPanel.tsx new file mode 100644 index 00000000..c95cb588 --- /dev/null +++ b/src/renderer/features/fitness/components/GoalsPanel/GoalsPanel.tsx @@ -0,0 +1,174 @@ +/** + * GoalsPanel — Set and view fitness goals + */ + +import { Plus, Target } from 'lucide-react'; + +import type { FitnessGoalType } from '@shared/types'; + +import { + Button, + Card, + CardContent, + EmptyState, + Heading, + Input, + Label, + SearchInput, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@ui'; + +import { GoalCard } from './GoalCard'; +import { useGoalsPanel } from './useGoalsPanel'; + +// ── Constants ──────────────────────────────────────────────── + +const GOAL_TYPE_LABELS: Record<FitnessGoalType, string> = { + weight: 'Weight', + workout_frequency: 'Workout Frequency', + lift_target: 'Lift Target', + cardio_target: 'Cardio Target', +}; + +const GOAL_TYPES: FitnessGoalType[] = [ + 'weight', + 'workout_frequency', + 'lift_target', + 'cardio_target', +]; + +// ── Component ──────────────────────────────────────────────── + +export function GoalsPanel() { + const { + displayGoals, + showForm, + setShowForm, + goalType, + setGoalType, + target, + setTarget, + unit, + setUnit, + searchQuery, + setSearchQuery, + isSubmitDisabled, + handleSubmit, + } = useGoalsPanel(); + + return ( + <div className="space-y-4"> + {/* Search */} + <SearchInput + placeholder="Search goals by type..." + showClear={searchQuery.length > 0} + size="sm" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onClear={() => setSearchQuery('')} + /> + + {/* Goals list */} + {displayGoals.length > 0 ? ( + <div className="space-y-3"> + {displayGoals.map((goal) => ( + <GoalCard key={goal.id} goal={goal} /> + ))} + </div> + ) : ( + <EmptyState + description={searchQuery.length > 0 ? 'No goals match your search' : 'No goals set yet'} + icon={Target} + size="sm" + title="" + /> + )} + + {/* Add goal form */} + {showForm ? ( + <Card> + <CardContent className="p-4"> + <Heading as="h4" className="text-foreground mb-3 text-sm font-medium"> + Set Goal + </Heading> + <div className="space-y-3"> + <div> + <Label className="mb-1" htmlFor="goal-type"> + Goal Type + </Label> + <Select + value={goalType} + onValueChange={(v) => setGoalType(v as FitnessGoalType)} + > + <SelectTrigger id="goal-type"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {GOAL_TYPES.map((gt) => ( + <SelectItem key={gt} value={gt}> + {GOAL_TYPE_LABELS[gt]} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="flex gap-3"> + <div className="flex-1"> + <Label className="mb-1" htmlFor="goal-target"> + Target + </Label> + <Input + id="goal-target" + placeholder="100" + type="number" + value={target} + onChange={(e) => setTarget(e.target.value)} + /> + </div> + <div className="w-24"> + <Label className="mb-1" htmlFor="goal-unit"> + Unit + </Label> + <Input + id="goal-unit" + placeholder="kg" + type="text" + value={unit} + onChange={(e) => setUnit(e.target.value)} + /> + </div> + </div> + <div className="flex gap-2"> + <Button + className="flex-1" + disabled={isSubmitDisabled} + type="button" + onClick={handleSubmit} + > + Set Goal + </Button> + <Button type="button" variant="ghost" onClick={() => setShowForm(false)}> + Cancel + </Button> + </div> + </div> + </CardContent> + </Card> + ) : ( + <Button + className="text-primary" + type="button" + variant="ghost" + onClick={() => setShowForm(true)} + > + <Plus className="h-4 w-4" /> + Set New Goal + </Button> + )} + </div> + ); +} diff --git a/src/renderer/features/fitness/components/GoalsPanel/index.ts b/src/renderer/features/fitness/components/GoalsPanel/index.ts new file mode 100644 index 00000000..1b6a10a4 --- /dev/null +++ b/src/renderer/features/fitness/components/GoalsPanel/index.ts @@ -0,0 +1,3 @@ +export { GoalsPanel } from './GoalsPanel'; +export { GoalCard } from './GoalCard'; +export { useGoalsPanel, useGoalCard } from './useGoalsPanel'; diff --git a/src/renderer/features/fitness/components/GoalsPanel/useGoalsPanel.ts b/src/renderer/features/fitness/components/GoalsPanel/useGoalsPanel.ts new file mode 100644 index 00000000..0fc1927b --- /dev/null +++ b/src/renderer/features/fitness/components/GoalsPanel/useGoalsPanel.ts @@ -0,0 +1,76 @@ +import { useState } from 'react'; + +import type { FitnessGoalType } from '@shared/types'; + +import { useDebounce } from '@renderer/shared/hooks/useDebounce'; + +import { useDeleteGoal, useFitnessGoals, useSetGoal } from '../../api/useFitness'; + +export function useGoalsPanel() { + const { data: goals } = useFitnessGoals(); + const setGoal = useSetGoal(); + + const [showForm, setShowForm] = useState(false); + const [goalType, setGoalType] = useState<FitnessGoalType>('weight'); + const [target, setTarget] = useState(''); + const [unit, setUnit] = useState('kg'); + const [searchQuery, setSearchQuery] = useState(''); + const debouncedQuery = useDebounce(searchQuery); + + const allGoals = goals ?? []; + + const displayGoals = debouncedQuery.trim().length > 0 + ? allGoals.filter((g) => g.type.toLowerCase().includes(debouncedQuery.toLowerCase())) + : allGoals; + + function handleSubmit(): void { + const targetNum = Number(target); + if (targetNum <= 0) return; + + setGoal.mutate( + { type: goalType, target: targetNum, unit }, + { + onSuccess: () => { + setTarget(''); + setShowForm(false); + }, + }, + ); + } + + const isSubmitDisabled = Number(target) <= 0; + + return { + displayGoals, + showForm, + setShowForm, + goalType, + setGoalType, + target, + setTarget, + unit, + setUnit, + searchQuery, + setSearchQuery, + isSubmitDisabled, + handleSubmit, + }; +} + +export function useGoalCard(goalId: string) { + const deleteGoal = useDeleteGoal(); + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + function handleDeleteConfirm(): void { + deleteGoal.mutate(goalId); + } + + return { + editOpen, + setEditOpen, + deleteOpen, + setDeleteOpen, + handleDeleteConfirm, + }; +} diff --git a/src/renderer/features/fitness/components/MeasurementEditDialog.tsx b/src/renderer/features/fitness/components/MeasurementEditDialog.tsx new file mode 100644 index 00000000..75998cde --- /dev/null +++ b/src/renderer/features/fitness/components/MeasurementEditDialog.tsx @@ -0,0 +1,2 @@ +// Re-export from folder — see MeasurementEditDialog/MeasurementEditDialog.tsx +export { MeasurementEditDialog } from './MeasurementEditDialog/MeasurementEditDialog'; diff --git a/src/renderer/features/fitness/components/MeasurementEditDialog/MeasurementEditDialog.tsx b/src/renderer/features/fitness/components/MeasurementEditDialog/MeasurementEditDialog.tsx new file mode 100644 index 00000000..f962261c --- /dev/null +++ b/src/renderer/features/fitness/components/MeasurementEditDialog/MeasurementEditDialog.tsx @@ -0,0 +1,192 @@ +/** + * MeasurementEditDialog — Edit an existing body measurement + */ + +import type { BodyMeasurement } from '@shared/types'; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from '@ui'; + +import { useMeasurementEditDialog } from './useMeasurementEditDialog'; + +// ── Props ──────────────────────────────────────────────────── + +interface MeasurementEditDialogProps { + measurement: BodyMeasurement; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// ── Component ──────────────────────────────────────────────── + +export function MeasurementEditDialog({ + measurement, + open, + onOpenChange, +}: MeasurementEditDialogProps) { + const { + date, + setDate, + weight, + setWeight, + bodyFat, + setBodyFat, + muscleMass, + setMuscleMass, + boneMass, + setBoneMass, + waterPercentage, + setWaterPercentage, + visceralFat, + setVisceralFat, + isSaveDisabled, + handleSave, + } = useMeasurementEditDialog({ measurement, onOpenChange }); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Edit Measurement</DialogTitle> + </DialogHeader> + + <div className="space-y-4 py-2"> + {/* Date */} + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-measure-date" + > + Date + </Label> + <Input + id="edit-measure-date" + type="date" + value={date} + onChange={(e) => setDate(e.target.value)} + /> + </div> + + {/* Weight + Body Fat */} + <div className="grid grid-cols-2 gap-3"> + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-measure-weight" + > + Weight (kg) + </Label> + <Input + id="edit-measure-weight" + placeholder="75.5" + type="number" + value={weight} + onChange={(e) => setWeight(e.target.value)} + /> + </div> + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-measure-bodyfat" + > + Body Fat (%) + </Label> + <Input + id="edit-measure-bodyfat" + placeholder="18.5" + type="number" + value={bodyFat} + onChange={(e) => setBodyFat(e.target.value)} + /> + </div> + </div> + + {/* Muscle Mass + Bone Mass */} + <div className="grid grid-cols-2 gap-3"> + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-measure-muscle" + > + Muscle Mass (kg) + </Label> + <Input + id="edit-measure-muscle" + placeholder="35.0" + type="number" + value={muscleMass} + onChange={(e) => setMuscleMass(e.target.value)} + /> + </div> + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-measure-bone" + > + Bone Mass (kg) + </Label> + <Input + id="edit-measure-bone" + placeholder="3.2" + type="number" + value={boneMass} + onChange={(e) => setBoneMass(e.target.value)} + /> + </div> + </div> + + {/* Water + Visceral Fat */} + <div className="grid grid-cols-2 gap-3"> + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-measure-water" + > + Water (%) + </Label> + <Input + id="edit-measure-water" + placeholder="55.0" + type="number" + value={waterPercentage} + onChange={(e) => setWaterPercentage(e.target.value)} + /> + </div> + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-measure-visceral" + > + Visceral Fat + </Label> + <Input + id="edit-measure-visceral" + placeholder="8" + type="number" + value={visceralFat} + onChange={(e) => setVisceralFat(e.target.value)} + /> + </div> + </div> + </div> + + <DialogFooter> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button disabled={isSaveDisabled} type="button" onClick={handleSave}> + Save Changes + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/fitness/components/MeasurementEditDialog/index.ts b/src/renderer/features/fitness/components/MeasurementEditDialog/index.ts new file mode 100644 index 00000000..81198f07 --- /dev/null +++ b/src/renderer/features/fitness/components/MeasurementEditDialog/index.ts @@ -0,0 +1,2 @@ +export { MeasurementEditDialog } from './MeasurementEditDialog'; +export { useMeasurementEditDialog } from './useMeasurementEditDialog'; diff --git a/src/renderer/features/fitness/components/MeasurementEditDialog/useMeasurementEditDialog.ts b/src/renderer/features/fitness/components/MeasurementEditDialog/useMeasurementEditDialog.ts new file mode 100644 index 00000000..c6e01b78 --- /dev/null +++ b/src/renderer/features/fitness/components/MeasurementEditDialog/useMeasurementEditDialog.ts @@ -0,0 +1,82 @@ +import { useState } from 'react'; + +import type { BodyMeasurement } from '@shared/types'; + +import { useUpdateMeasurement } from '../../api/useFitness'; +import { useEditDialog } from '../../hooks/useEditDialog'; + +interface UseMeasurementEditDialogOptions { + measurement: BodyMeasurement; + onOpenChange: (open: boolean) => void; +} + +export function useMeasurementEditDialog({ + measurement, + onOpenChange, +}: UseMeasurementEditDialogOptions) { + const updateMeasurement = useUpdateMeasurement(); + + const [date, setDate] = useState(measurement.date); + const [weight, setWeight] = useState( + measurement.weight === undefined ? '' : String(measurement.weight), + ); + const [bodyFat, setBodyFat] = useState( + measurement.bodyFat === undefined ? '' : String(measurement.bodyFat), + ); + const [muscleMass, setMuscleMass] = useState( + measurement.muscleMass === undefined ? '' : String(measurement.muscleMass), + ); + const [boneMass, setBoneMass] = useState( + measurement.boneMass === undefined ? '' : String(measurement.boneMass), + ); + const [waterPercentage, setWaterPercentage] = useState( + measurement.waterPercentage === undefined ? '' : String(measurement.waterPercentage), + ); + const [visceralFat, setVisceralFat] = useState( + measurement.visceralFat === undefined ? '' : String(measurement.visceralFat), + ); + + const { isSaveDisabled, handleSave } = useEditDialog({ + mutation: updateMeasurement, + buildInput: () => { + const hasAnyValue = + weight !== '' || + bodyFat !== '' || + muscleMass !== '' || + boneMass !== '' || + waterPercentage !== '' || + visceralFat !== ''; + if (!hasAnyValue) return null; + return { + id: measurement.id, + date, + weight: weight === '' ? undefined : Number(weight), + bodyFat: bodyFat === '' ? undefined : Number(bodyFat), + muscleMass: muscleMass === '' ? undefined : Number(muscleMass), + boneMass: boneMass === '' ? undefined : Number(boneMass), + waterPercentage: waterPercentage === '' ? undefined : Number(waterPercentage), + visceralFat: visceralFat === '' ? undefined : Number(visceralFat), + }; + }, + onClose: () => onOpenChange(false), + }); + + return { + date, + setDate, + weight, + setWeight, + bodyFat, + setBodyFat, + muscleMass, + setMuscleMass, + boneMass, + setBoneMass, + waterPercentage, + setWaterPercentage, + visceralFat, + setVisceralFat, + isSaveDisabled, + handleSave, + }; +} diff --git a/src/renderer/features/personal/fitness/components/StatsOverview.tsx b/src/renderer/features/fitness/components/StatsOverview.tsx similarity index 100% rename from src/renderer/features/personal/fitness/components/StatsOverview.tsx rename to src/renderer/features/fitness/components/StatsOverview.tsx diff --git a/src/renderer/features/fitness/components/WorkoutEditDialog.tsx b/src/renderer/features/fitness/components/WorkoutEditDialog.tsx new file mode 100644 index 00000000..171369bb --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutEditDialog.tsx @@ -0,0 +1,3 @@ +// Re-export from folder — see WorkoutEditDialog/WorkoutEditDialog.tsx +export { WorkoutEditDialog } from './WorkoutEditDialog/WorkoutEditDialog'; +export type { FormExercise } from './WorkoutEditDialog/useWorkoutEditDialog'; diff --git a/src/renderer/features/fitness/components/WorkoutEditDialog/WorkoutEditDialog.tsx b/src/renderer/features/fitness/components/WorkoutEditDialog/WorkoutEditDialog.tsx new file mode 100644 index 00000000..09aa98bf --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutEditDialog/WorkoutEditDialog.tsx @@ -0,0 +1,171 @@ +/** + * WorkoutEditDialog — Edit an existing workout's date, type, duration, exercises, notes + */ + +import type { Workout, WorkoutType } from '@shared/types'; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from '@ui'; + +import { WorkoutExerciseList } from '../WorkoutExerciseList'; + +import { useWorkoutEditDialog } from './useWorkoutEditDialog'; + +// ── Constants ──────────────────────────────────────────────── + +const WORKOUT_TYPES: Array<{ value: WorkoutType; label: string }> = [ + { value: 'strength', label: 'Strength' }, + { value: 'cardio', label: 'Cardio' }, + { value: 'flexibility', label: 'Flexibility' }, + { value: 'sport', label: 'Sport' }, +]; + +// ── Props ──────────────────────────────────────────────────── + +interface WorkoutEditDialogProps { + workout: Workout; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// ── Component ──────────────────────────────────────────────── + +export function WorkoutEditDialog({ workout, open, onOpenChange }: WorkoutEditDialogProps) { + const { + date, + setDate, + type, + setType, + duration, + setDuration, + notes, + setNotes, + exercises, + isSaveDisabled, + handleAddExercise, + handleRemoveExercise, + handleExerciseNameChange, + handleAddSet, + handleSetChange, + handleSave, + } = useWorkoutEditDialog({ workout, onOpenChange }); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg"> + <DialogHeader> + <DialogTitle>Edit Workout</DialogTitle> + </DialogHeader> + + <div className="space-y-4 py-2"> + {/* Date + Type */} + <div className="flex gap-3"> + <div className="flex-1"> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-workout-date" + > + Date + </Label> + <Input + id="edit-workout-date" + type="date" + value={date} + onChange={(e) => setDate(e.target.value)} + /> + </div> + <div className="flex-1"> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-workout-type" + > + Type + </Label> + <Select value={type} onValueChange={(v) => setType(v as WorkoutType)}> + <SelectTrigger id="edit-workout-type"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {WORKOUT_TYPES.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + {/* Duration */} + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-workout-duration" + > + Duration (minutes) + </Label> + <Input + id="edit-workout-duration" + min="1" + placeholder="45" + type="number" + value={duration} + onChange={(e) => setDuration(e.target.value)} + /> + </div> + + {/* Exercises */} + <WorkoutExerciseList + exercises={exercises} + onAddExercise={handleAddExercise} + onAddSet={handleAddSet} + onExerciseNameChange={handleExerciseNameChange} + onRemoveExercise={handleRemoveExercise} + onSetChange={handleSetChange} + /> + + {/* Notes */} + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="edit-workout-notes" + > + Notes + </Label> + <Textarea + className="h-16" + id="edit-workout-notes" + placeholder="Optional notes..." + resize="none" + value={notes} + onChange={(e) => setNotes(e.target.value)} + /> + </div> + </div> + + <DialogFooter> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button disabled={isSaveDisabled} type="button" onClick={handleSave}> + Save Changes + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/fitness/components/WorkoutEditDialog/index.ts b/src/renderer/features/fitness/components/WorkoutEditDialog/index.ts new file mode 100644 index 00000000..01625cf0 --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutEditDialog/index.ts @@ -0,0 +1,3 @@ +export { WorkoutEditDialog } from './WorkoutEditDialog'; +export { useWorkoutEditDialog } from './useWorkoutEditDialog'; +export type { FormExercise } from './useWorkoutEditDialog'; diff --git a/src/renderer/features/fitness/components/WorkoutEditDialog/useWorkoutEditDialog.ts b/src/renderer/features/fitness/components/WorkoutEditDialog/useWorkoutEditDialog.ts new file mode 100644 index 00000000..b6fae61d --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutEditDialog/useWorkoutEditDialog.ts @@ -0,0 +1,71 @@ +import { useState } from 'react'; + +import type { Workout, WorkoutType } from '@shared/types'; + +import { useUpdateWorkout } from '../../api/useFitness'; +import { useEditDialog } from '../../hooks/useEditDialog'; +import { toFormExercises, useExerciseForm } from '../../hooks/useExerciseForm'; + +export type { FormExercise } from '../../hooks/useExerciseForm'; +export { toFormExercises }; + +interface UseWorkoutEditDialogOptions { + workout: Workout; + onOpenChange: (open: boolean) => void; +} + +export function useWorkoutEditDialog({ workout, onOpenChange }: UseWorkoutEditDialogOptions) { + const updateWorkout = useUpdateWorkout(); + + const [date, setDate] = useState(workout.date); + const [type, setType] = useState<WorkoutType>(workout.type); + const [duration, setDuration] = useState(String(workout.duration)); + const [notes, setNotes] = useState(workout.notes ?? ''); + + const { + exercises, + handleAddExercise, + handleRemoveExercise, + handleExerciseNameChange, + handleAddSet, + handleSetChange, + } = useExerciseForm(toFormExercises(workout.exercises)); + + const { isSaveDisabled, handleSave } = useEditDialog({ + mutation: updateWorkout, + buildInput: () => { + const durationNum = Number(duration); + if (durationNum <= 0) return null; + + const validExercises = exercises.filter((e) => e.name.trim().length > 0); + return { + id: workout.id, + date, + type, + duration: durationNum, + exercises: validExercises, + notes: notes.trim().length > 0 ? notes.trim() : undefined, + }; + }, + onClose: () => onOpenChange(false), + }); + + return { + date, + setDate, + type, + setType, + duration, + setDuration, + notes, + setNotes, + exercises, + isSaveDisabled, + handleAddExercise, + handleRemoveExercise, + handleExerciseNameChange, + handleAddSet, + handleSetChange, + handleSave, + }; +} diff --git a/src/renderer/features/personal/fitness/components/WorkoutExerciseList.tsx b/src/renderer/features/fitness/components/WorkoutExerciseList.tsx similarity index 100% rename from src/renderer/features/personal/fitness/components/WorkoutExerciseList.tsx rename to src/renderer/features/fitness/components/WorkoutExerciseList.tsx diff --git a/src/renderer/features/fitness/components/WorkoutForm.tsx b/src/renderer/features/fitness/components/WorkoutForm.tsx new file mode 100644 index 00000000..dd14d8f1 --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutForm.tsx @@ -0,0 +1,2 @@ +// Re-export from folder — see WorkoutForm/WorkoutForm.tsx +export { WorkoutForm } from './WorkoutForm/WorkoutForm'; diff --git a/src/renderer/features/fitness/components/WorkoutForm/ExerciseInput.tsx b/src/renderer/features/fitness/components/WorkoutForm/ExerciseInput.tsx new file mode 100644 index 00000000..21a61a5e --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutForm/ExerciseInput.tsx @@ -0,0 +1,94 @@ +/** + * ExerciseInput — Single exercise row with sets for WorkoutForm + */ + +import { Trash2 } from 'lucide-react'; + +import type { ExerciseSet } from '@shared/types'; + +import { Button, Input } from '@ui'; + +import type { FormExercise } from './useWorkoutForm'; + +// ── Props ──────────────────────────────────────────────────── + +interface ExerciseInputProps { + exercise: FormExercise; + exerciseIndex: number; + onNameChange: (name: string) => void; + onRemove: () => void; + onAddSet: () => void; + onSetChange: (setIndex: number, field: keyof ExerciseSet, value: string) => void; +} + +// ── Component ──────────────────────────────────────────────── + +export function ExerciseInput({ + exercise, + exerciseIndex, + onNameChange, + onRemove, + onAddSet, + onSetChange, +}: ExerciseInputProps) { + return ( + <div className="bg-muted/50 rounded-md p-3"> + <div className="mb-2 flex items-center gap-2"> + <Input + aria-label={`Exercise ${String(exerciseIndex + 1)} name`} + className="flex-1" + placeholder="Exercise name" + size="sm" + type="text" + value={exercise.name} + onChange={(e) => onNameChange(e.target.value)} + /> + <Button + aria-label="Remove exercise" + className="text-muted-foreground hover:text-destructive h-auto p-1" + size="icon" + type="button" + variant="ghost" + onClick={onRemove} + > + <Trash2 className="h-3 w-3" /> + </Button> + </div> + <div className="space-y-1"> + {exercise.sets.map((exerciseSet, setIndex) => ( + <div key={exercise._setKeys[setIndex]} className="flex items-center gap-2"> + <span className="text-muted-foreground w-8 text-xs">S{String(setIndex + 1)}</span> + <Input + aria-label={`Set ${String(setIndex + 1)} reps`} + className="w-16" + placeholder="Reps" + size="sm" + type="number" + value={exerciseSet.reps ?? ''} + onChange={(e) => onSetChange(setIndex, 'reps', e.target.value)} + /> + <Input + aria-label={`Set ${String(setIndex + 1)} weight`} + className="w-20" + placeholder="Weight" + size="sm" + type="number" + value={exerciseSet.weight ?? ''} + onChange={(e) => onSetChange(setIndex, 'weight', e.target.value)} + /> + <span className="text-muted-foreground text-xs">lbs</span> + </div> + ))} + </div> + <Button + className="text-primary mt-1 h-auto p-0 text-xs font-medium" + size="sm" + type="button" + variant="ghost" + onClick={onAddSet} + > + + Add Set + </Button> + </div> + ); +} diff --git a/src/renderer/features/fitness/components/WorkoutForm/WorkoutForm.tsx b/src/renderer/features/fitness/components/WorkoutForm/WorkoutForm.tsx new file mode 100644 index 00000000..7b976050 --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutForm/WorkoutForm.tsx @@ -0,0 +1,193 @@ +/** + * WorkoutForm — Log a new workout + */ + +import { Plus, Send, X } from 'lucide-react'; + +import type { WorkoutType } from '@shared/types'; + +import { + Button, + Heading, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from '@ui'; + +import { ExerciseInput } from './ExerciseInput'; +import { useWorkoutForm } from './useWorkoutForm'; + +// ── Constants ──────────────────────────────────────────────── + +const WORKOUT_TYPES: Array<{ value: WorkoutType; label: string }> = [ + { value: 'strength', label: 'Strength' }, + { value: 'cardio', label: 'Cardio' }, + { value: 'flexibility', label: 'Flexibility' }, + { value: 'sport', label: 'Sport' }, +]; + +// ── Component ──────────────────────────────────────────────── + +export function WorkoutForm() { + const { + date, + setDate, + type, + setType, + duration, + setDuration, + notes, + setNotes, + exercises, + isSubmitDisabled, + setShowWorkoutForm, + handleAddExercise, + handleRemoveExercise, + handleExerciseNameChange, + handleAddSet, + handleSetChange, + handleSubmit, + } = useWorkoutForm(); + + return ( + <div className="bg-card border-border rounded-lg border"> + {/* Header */} + <div className="border-border flex items-center justify-between border-b px-4 py-3"> + <Heading as="h3" className="text-sm">Log Workout</Heading> + <Button + aria-label="Close form" + size="icon" + type="button" + variant="ghost" + onClick={() => setShowWorkoutForm(false)} + > + <X className="h-4 w-4" /> + </Button> + </div> + + <div className="space-y-4 p-4"> + {/* Date + Type */} + <div className="flex gap-3"> + <div className="flex-1"> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="workout-date" + > + Date + </Label> + <Input + id="workout-date" + type="date" + value={date} + onChange={(e) => setDate(e.target.value)} + /> + </div> + <div className="flex-1"> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="workout-type" + > + Type + </Label> + <Select value={type} onValueChange={(v) => setType(v as WorkoutType)}> + <SelectTrigger id="workout-type"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {WORKOUT_TYPES.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + {/* Duration */} + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="workout-duration" + > + Duration (minutes) + </Label> + <Input + id="workout-duration" + min="1" + placeholder="45" + type="number" + value={duration} + onChange={(e) => setDuration(e.target.value)} + /> + </div> + + {/* Exercises */} + <div> + <div className="mb-2 flex items-center justify-between"> + <span className="text-muted-foreground text-xs font-medium">Exercises</span> + <Button + className="text-primary h-auto p-0 text-xs font-medium" + size="sm" + type="button" + variant="ghost" + onClick={handleAddExercise} + > + <Plus className="h-3 w-3" /> + Add Exercise + </Button> + </div> + <div className="space-y-3"> + {exercises.map((exercise, exerciseIndex) => ( + <ExerciseInput + key={exercise._key} + exercise={exercise} + exerciseIndex={exerciseIndex} + onAddSet={() => handleAddSet(exerciseIndex)} + onNameChange={(name) => handleExerciseNameChange(exerciseIndex, name)} + onRemove={() => handleRemoveExercise(exerciseIndex)} + onSetChange={(setIndex, field, value) => + handleSetChange(exerciseIndex, setIndex, field, value) + } + /> + ))} + </div> + </div> + + {/* Notes */} + <div> + <Label + className="text-muted-foreground mb-1 block text-xs font-medium" + htmlFor="workout-notes" + > + Notes + </Label> + <Textarea + className="h-16" + id="workout-notes" + placeholder="Optional notes..." + resize="none" + value={notes} + onChange={(e) => setNotes(e.target.value)} + /> + </div> + + {/* Submit */} + <Button + className="w-full" + disabled={isSubmitDisabled} + type="button" + onClick={handleSubmit} + > + <Send className="h-4 w-4" /> + Log Workout + </Button> + </div> + </div> + ); +} diff --git a/src/renderer/features/fitness/components/WorkoutForm/index.ts b/src/renderer/features/fitness/components/WorkoutForm/index.ts new file mode 100644 index 00000000..578603e5 --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutForm/index.ts @@ -0,0 +1,4 @@ +export { WorkoutForm } from './WorkoutForm'; +export { ExerciseInput } from './ExerciseInput'; +export { useWorkoutForm } from './useWorkoutForm'; +export type { FormExercise } from './useWorkoutForm'; diff --git a/src/renderer/features/fitness/components/WorkoutForm/useWorkoutForm.ts b/src/renderer/features/fitness/components/WorkoutForm/useWorkoutForm.ts new file mode 100644 index 00000000..63f03b70 --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutForm/useWorkoutForm.ts @@ -0,0 +1,75 @@ +import { useState } from 'react'; + +import type { WorkoutType } from '@shared/types'; + +import { useToday } from '@renderer/shared/hooks/useToday'; + +import { useLogWorkout } from '../../api/useFitness'; +import { useExerciseForm } from '../../hooks/useExerciseForm'; +import { useFitnessUI } from '../../store'; + +export type { FormExercise } from '../../hooks/useExerciseForm'; + +export function useWorkoutForm() { + const logWorkout = useLogWorkout(); + const { setShowWorkoutForm } = useFitnessUI(); + const today = useToday(); + + const [date, setDate] = useState(today); + const [type, setType] = useState<WorkoutType>('strength'); + const [duration, setDuration] = useState(''); + const [notes, setNotes] = useState(''); + + const { + exercises, + handleAddExercise, + handleRemoveExercise, + handleExerciseNameChange, + handleAddSet, + handleSetChange, + } = useExerciseForm(); + + function handleSubmit(): void { + const durationNum = Number(duration); + if (durationNum <= 0) return; + + const validExercises = exercises.filter((e) => e.name.trim().length > 0); + + logWorkout.mutate( + { + date, + type, + duration: durationNum, + exercises: validExercises, + notes: notes.trim().length > 0 ? notes.trim() : undefined, + }, + { + onSuccess: () => { + setShowWorkoutForm(false); + }, + }, + ); + } + + const isSubmitDisabled = Number(duration) <= 0; + + return { + date, + setDate, + type, + setType, + duration, + setDuration, + notes, + setNotes, + exercises, + isSubmitDisabled, + setShowWorkoutForm, + handleAddExercise, + handleRemoveExercise, + handleExerciseNameChange, + handleAddSet, + handleSetChange, + handleSubmit, + }; +} diff --git a/src/renderer/features/fitness/components/WorkoutLog.tsx b/src/renderer/features/fitness/components/WorkoutLog.tsx new file mode 100644 index 00000000..bb0ccc56 --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutLog.tsx @@ -0,0 +1,2 @@ +// Re-export from folder — see WorkoutLog/WorkoutLog.tsx +export { WorkoutLog } from './WorkoutLog/WorkoutLog'; diff --git a/src/renderer/features/fitness/components/WorkoutLog/WorkoutItem.tsx b/src/renderer/features/fitness/components/WorkoutLog/WorkoutItem.tsx new file mode 100644 index 00000000..498f8123 --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutLog/WorkoutItem.tsx @@ -0,0 +1,107 @@ +/** + * WorkoutItem — Single workout row with edit/delete actions + */ + +import { useState } from 'react'; + +import { Pencil, Trash2 } from 'lucide-react'; + +import type { Workout } from '@shared/types'; + +import { RelativeTime } from '@renderer/shared/components/RelativeTime'; + +import { Badge, Button, Text } from '@ui'; + +import { WorkoutEditDialog } from '../WorkoutEditDialog'; + +// ── Constants ──────────────────────────────────────────────── + +const TYPE_LABELS: Record<string, string> = { + strength: 'Strength', + cardio: 'Cardio', + flexibility: 'Flexibility', + sport: 'Sport', +}; + +// ── Helpers ────────────────────────────────────────────────── + +function getWorkoutVariant( + type: string, +): 'default' | 'success' | 'info' | 'warning' | 'secondary' { + if (type === 'strength') return 'default'; + if (type === 'cardio') return 'success'; + if (type === 'flexibility') return 'info'; + if (type === 'sport') return 'warning'; + return 'secondary'; +} + +// ── Props ──────────────────────────────────────────────────── + +interface WorkoutItemProps { + workout: Workout; + onDelete: () => void; +} + +// ── Component ──────────────────────────────────────────────── + +export function WorkoutItem({ workout, onDelete }: WorkoutItemProps) { + const [editOpen, setEditOpen] = useState(false); + const exerciseCount = workout.exercises.length; + const totalSets = workout.exercises.reduce((sum, e) => sum + e.sets.length, 0); + + return ( + <> + <div className="flex items-start justify-between px-4 py-3"> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <Badge variant={getWorkoutVariant(workout.type)}> + {TYPE_LABELS[workout.type] ?? workout.type} + </Badge> + <span className="text-muted-foreground text-xs">{workout.date}</span> + </div> + <Text className="mt-1" size="sm"> + {String(exerciseCount)} exercise{exerciseCount === 1 ? '' : 's'} ·{' '} + {String(totalSets)} set{totalSets === 1 ? '' : 's'} · {String(workout.duration)}{' '} + min + </Text> + {workout.exercises.length > 0 ? ( + <Text className="mt-0.5 text-xs" variant="muted"> + {workout.exercises.map((e) => e.name).join(', ')} + </Text> + ) : null} + {workout.notes === undefined ? null : ( + <Text className="mt-1 italic" size="sm" variant="muted"> + {workout.notes} + </Text> + )} + <div className="mt-1"> + <RelativeTime value={workout.createdAt} /> + </div> + </div> + <div className="flex items-center gap-1"> + <Button + aria-label="Edit workout" + className="text-muted-foreground hover:text-foreground" + size="icon" + type="button" + variant="ghost" + onClick={() => setEditOpen(true)} + > + <Pencil className="h-4 w-4" /> + </Button> + <Button + aria-label="Delete workout" + className="text-muted-foreground hover:text-destructive" + size="icon" + type="button" + variant="ghost" + onClick={onDelete} + > + <Trash2 className="h-4 w-4" /> + </Button> + </div> + </div> + <WorkoutEditDialog open={editOpen} workout={workout} onOpenChange={setEditOpen} /> + </> + ); +} diff --git a/src/renderer/features/fitness/components/WorkoutLog/WorkoutLog.tsx b/src/renderer/features/fitness/components/WorkoutLog/WorkoutLog.tsx new file mode 100644 index 00000000..a321509b --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutLog/WorkoutLog.tsx @@ -0,0 +1,46 @@ +/** + * WorkoutLog — Recent workouts list + */ + +import { EmptyState, SearchInput } from '@ui'; + +import { useWorkoutLog } from './useWorkoutLog'; +import { WorkoutItem } from './WorkoutItem'; + +// ── Component ──────────────────────────────────────────────── + +export function WorkoutLog() { + const { displayWorkouts, searchQuery, setSearchQuery, deleteWorkout } = useWorkoutLog(); + + return ( + <div className="flex flex-col gap-3"> + <SearchInput + placeholder="Search workouts by type or notes..." + showClear={searchQuery.length > 0} + size="sm" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onClear={() => setSearchQuery('')} + /> + {displayWorkouts.length === 0 ? ( + <EmptyState + size="sm" + title="" + description={ + searchQuery.length > 0 ? 'No workouts match your search' : 'No workouts logged yet' + } + /> + ) : ( + <div className="divide-border divide-y"> + {displayWorkouts.slice(0, 20).map((workout) => ( + <WorkoutItem + key={workout.id} + workout={workout} + onDelete={() => deleteWorkout.mutate(workout.id)} + /> + ))} + </div> + )} + </div> + ); +} diff --git a/src/renderer/features/fitness/components/WorkoutLog/index.ts b/src/renderer/features/fitness/components/WorkoutLog/index.ts new file mode 100644 index 00000000..520442b9 --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutLog/index.ts @@ -0,0 +1,3 @@ +export { WorkoutLog } from './WorkoutLog'; +export { WorkoutItem } from './WorkoutItem'; +export { useWorkoutLog } from './useWorkoutLog'; diff --git a/src/renderer/features/fitness/components/WorkoutLog/useWorkoutLog.ts b/src/renderer/features/fitness/components/WorkoutLog/useWorkoutLog.ts new file mode 100644 index 00000000..8832c1e9 --- /dev/null +++ b/src/renderer/features/fitness/components/WorkoutLog/useWorkoutLog.ts @@ -0,0 +1,28 @@ +import { useState } from 'react'; + +import { useDebounce } from '@renderer/shared/hooks/useDebounce'; + +import { useDeleteWorkout, useWorkouts } from '../../api/useFitness'; + +export function useWorkoutLog() { + const { data: workouts } = useWorkouts(); + const deleteWorkout = useDeleteWorkout(); + const [searchQuery, setSearchQuery] = useState(''); + const debouncedQuery = useDebounce(searchQuery); + + const allWorkouts = workouts ?? []; + + const displayWorkouts = debouncedQuery.trim().length > 0 + ? allWorkouts.filter((w) => { + const q = debouncedQuery.toLowerCase(); + return w.type.toLowerCase().includes(q) || (w.notes ?? '').toLowerCase().includes(q); + }) + : allWorkouts; + + return { + displayWorkouts, + searchQuery, + setSearchQuery, + deleteWorkout, + }; +} diff --git a/src/renderer/features/fitness/hooks/useEditDialog.ts b/src/renderer/features/fitness/hooks/useEditDialog.ts new file mode 100644 index 00000000..556df132 --- /dev/null +++ b/src/renderer/features/fitness/hooks/useEditDialog.ts @@ -0,0 +1,46 @@ +/** + * useEditDialog — shared save-with-close pattern for fitness edit dialogs. + * + * Wraps a TanStack mutation with validation gating and auto-close on success. + * Used by WorkoutEditDialog, MeasurementEditDialog, and GoalEditDialog. + */ + +import type { UseMutationResult } from '@tanstack/react-query'; + +interface UseEditDialogOptions<TInput, TOutput> { + /** The TanStack mutation to call on save */ + mutation: UseMutationResult<TOutput, Error, TInput>; + /** Build the mutation input from current form state; return null if invalid */ + buildInput: () => TInput | null; + /** Close callback (typically `onOpenChange(false)`) */ + onClose: () => void; +} + +interface UseEditDialogReturn { + /** Whether the save button should be disabled */ + isSaveDisabled: boolean; + /** Call to validate, mutate, and close on success */ + handleSave: () => void; +} + +export function useEditDialog<TInput, TOutput>({ + mutation, + buildInput, + onClose, +}: UseEditDialogOptions<TInput, TOutput>): UseEditDialogReturn { + const input = buildInput(); + const isSaveDisabled = input === null || mutation.isPending; + + function handleSave(): void { + const payload = buildInput(); + if (payload === null) return; + + mutation.mutate(payload, { + onSuccess: () => { + onClose(); + }, + }); + } + + return { isSaveDisabled, handleSave }; +} diff --git a/src/renderer/features/fitness/hooks/useExerciseForm.ts b/src/renderer/features/fitness/hooks/useExerciseForm.ts new file mode 100644 index 00000000..d96b259b --- /dev/null +++ b/src/renderer/features/fitness/hooks/useExerciseForm.ts @@ -0,0 +1,87 @@ +/** + * useExerciseForm — shared exercise array manipulation for workout forms. + * + * Extracts the add/remove exercise, add set, change set, and name change + * handlers duplicated in WorkoutForm and WorkoutEditDialog. + */ + +import { useState } from 'react'; + +import type { Exercise, ExerciseSet } from '@shared/types'; + +export interface FormExercise extends Exercise { + _key: string; + _setKeys: string[]; +} + +let nextId = 0; +export function uid(): string { + nextId += 1; + return `ex-${String(nextId)}`; +} + +export function toFormExercises(exercises: Exercise[]): FormExercise[] { + return exercises.map((e) => ({ + ...e, + _key: uid(), + _setKeys: e.sets.map(() => uid()), + })); +} + +export function useExerciseForm(initial: FormExercise[] = []) { + const [exercises, setExercises] = useState<FormExercise[]>(initial); + + function handleAddExercise(): void { + setExercises((prev) => [...prev, { name: '', sets: [{}], _key: uid(), _setKeys: [uid()] }]); + } + + function handleRemoveExercise(index: number): void { + setExercises((prev) => prev.filter((_, i) => i !== index)); + } + + function handleExerciseNameChange(index: number, name: string): void { + setExercises((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], name }; + return updated; + }); + } + + function handleAddSet(exerciseIndex: number): void { + setExercises((prev) => { + const updated = [...prev]; + updated[exerciseIndex] = { + ...updated[exerciseIndex], + sets: [...updated[exerciseIndex].sets, {}], + _setKeys: [...updated[exerciseIndex]._setKeys, uid()], + }; + return updated; + }); + } + + function handleSetChange( + exerciseIndex: number, + setIndex: number, + field: keyof ExerciseSet, + value: string, + ): void { + setExercises((prev) => { + const updated = [...prev]; + const sets = [...updated[exerciseIndex].sets]; + const numericValue = value === '' ? undefined : Number(value); + sets[setIndex] = { ...sets[setIndex], [field]: numericValue }; + updated[exerciseIndex] = { ...updated[exerciseIndex], sets }; + return updated; + }); + } + + return { + exercises, + setExercises, + handleAddExercise, + handleRemoveExercise, + handleExerciseNameChange, + handleAddSet, + handleSetChange, + }; +} diff --git a/src/renderer/features/personal/fitness/hooks/useFitnessEvents.ts b/src/renderer/features/fitness/hooks/useFitnessEvents.ts similarity index 100% rename from src/renderer/features/personal/fitness/hooks/useFitnessEvents.ts rename to src/renderer/features/fitness/hooks/useFitnessEvents.ts diff --git a/src/renderer/features/personal/fitness/index.ts b/src/renderer/features/fitness/index.ts similarity index 100% rename from src/renderer/features/personal/fitness/index.ts rename to src/renderer/features/fitness/index.ts diff --git a/src/renderer/features/personal/fitness/store.ts b/src/renderer/features/fitness/store.ts similarity index 100% rename from src/renderer/features/personal/fitness/store.ts rename to src/renderer/features/fitness/store.ts diff --git a/src/renderer/features/git-overview/components/ChangelogSummary.tsx b/src/renderer/features/git-overview/components/ChangelogSummary.tsx deleted file mode 100644 index b7001f5e..00000000 --- a/src/renderer/features/git-overview/components/ChangelogSummary.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/** - * ChangelogSummary — Compact display of the latest changelog entry - * - * Shows a truncated summary (~100 chars) of the most recent changelog entry - * with Expand (opens Dialog with full ChangelogPage), Copy (clipboard), - * and Update (opens generate form) actions. - */ - -import { useState } from 'react'; - -import { ClipboardCopy, Expand, RefreshCw, ScrollText } from 'lucide-react'; - -import type { ChangelogEntry } from '@shared/types'; - -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - Text, -} from '@ui'; - -import { ChangelogPage, useChangelog } from '@features/personal'; - -/** Build a plain-text summary from a changelog entry, truncated to maxLen chars */ -function summariseEntry(entry: ChangelogEntry, maxLen = 100): string { - const parts: string[] = []; - for (const cat of entry.categories) { - for (const item of cat.items) { - parts.push(item); - } - } - const joined = parts.join('; '); - return joined.length > maxLen ? `${joined.slice(0, maxLen)}...` : joined; -} - -/** Build full plain-text for clipboard copy */ -function entryToClipboard(entry: ChangelogEntry): string { - const lines: string[] = [`v${entry.version} — ${entry.date}`]; - for (const cat of entry.categories) { - lines.push(`\n### ${cat.type}`); - for (const item of cat.items) { - lines.push(`- ${item}`); - } - } - return lines.join('\n'); -} - -export function ChangelogSummary() { - const { data: entries, isLoading } = useChangelog(); - const [dialogOpen, setDialogOpen] = useState(false); - const [copied, setCopied] = useState(false); - - const latest = entries?.[0] ?? null; - - function handleCopy(): void { - if (!latest) return; - void (async () => { - await navigator.clipboard.writeText(entryToClipboard(latest)); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - })(); - } - - // Loading state - if (isLoading) { - return ( - <Text className="text-muted-foreground text-xs">Loading changelog...</Text> - ); - } - - // Empty state - if (!latest) { - return ( - <div className="flex items-center gap-2"> - <ScrollText className="text-muted-foreground h-4 w-4" /> - <Text className="text-muted-foreground text-xs">No changelog entries</Text> - </div> - ); - } - - const summary = summariseEntry(latest); - - return ( - <> - <div className="flex items-center gap-2"> - <ScrollText className="text-muted-foreground h-4 w-4 shrink-0" /> - <Text className="text-muted-foreground max-w-[280px] truncate text-xs" title={summary}> - v{latest.version}: {summary} - </Text> - - <Button - aria-label="Expand changelog" - className="h-6 w-6" - size="icon" - variant="ghost" - onClick={() => setDialogOpen(true)} - > - <Expand className="h-3 w-3" /> - </Button> - - <Button - aria-label={copied ? 'Copied' : 'Copy latest changelog'} - className="h-6 w-6" - size="icon" - variant="ghost" - onClick={handleCopy} - > - <ClipboardCopy className="h-3 w-3" /> - </Button> - - <Button - aria-label="Update changelog" - className="h-6 w-6" - size="icon" - variant="ghost" - onClick={() => setDialogOpen(true)} - > - <RefreshCw className="h-3 w-3" /> - </Button> - </div> - - <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> - <DialogContent className="max-h-[80vh] max-w-3xl overflow-y-auto"> - <DialogHeader> - <DialogTitle>Changelog</DialogTitle> - <DialogDescription>Full version history and release notes</DialogDescription> - </DialogHeader> - <ChangelogPage /> - </DialogContent> - </Dialog> - </> - ); -} diff --git a/src/renderer/features/git-overview/components/CommitPanel.tsx b/src/renderer/features/git-overview/components/CommitPanel.tsx deleted file mode 100644 index c71adac4..00000000 --- a/src/renderer/features/git-overview/components/CommitPanel.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/** - * CommitPanel — Staged files list, commit message, commit/push/PR actions. - */ - -import { useState } from 'react'; - -import { AlertTriangle, GitCommit, Upload } from 'lucide-react'; - -import { - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Label, - Spinner, - Text, - Textarea, -} from '@ui'; - -import { useGitCommit, useGitPush, useGitStatus } from '../api/useGit'; - -import { CreatePrDialog } from './CreatePrDialog'; - -export interface CommitPanelProps { - repoPath: string; - projectId: string; -} - -function renderError(message: string) { - return ( - <div className="rounded-md bg-red-500/10 p-3"> - <div className="flex items-center gap-2 text-sm text-red-400"> - <AlertTriangle className="h-4 w-4 shrink-0" /> - {message} - </div> - </div> - ); -} - -export function CommitPanel({ repoPath }: CommitPanelProps) { - // Hooks - const { data: status } = useGitStatus(repoPath); - const gitCommit = useGitCommit(); - const gitPush = useGitPush(); - - const [commitMessage, setCommitMessage] = useState(''); - const [commitError, setCommitError] = useState<string | null>(null); - const [pushError, setPushError] = useState<string | null>(null); - - // Derived state - const staged = status?.staged ?? []; - const hasStagedFiles = staged.length > 0; - const hasCommitMessage = commitMessage.trim().length > 0; - const canCommit = hasStagedFiles && hasCommitMessage; - const headBranch = status?.branch ?? ''; - - // Event handlers - function handleCommit() { - if (!canCommit) return; - setCommitError(null); - gitCommit.mutate( - { projectPath: repoPath, message: commitMessage.trim(), files: staged }, - { - onSuccess: () => { - setCommitMessage(''); - setCommitError(null); - }, - onError: (err) => { - setCommitError(err instanceof Error ? err.message : 'Commit failed'); - }, - }, - ); - } - - function handlePush() { - setPushError(null); - gitPush.mutate( - { projectPath: repoPath }, - { - onError: (err) => { - setPushError(err instanceof Error ? err.message : 'Push failed'); - }, - }, - ); - } - - // Render - return ( - <Card> - <CardHeader className="pb-2"> - <CardTitle className="text-sm font-medium">Commit & Push</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {/* Staged files */} - <div className="space-y-1.5"> - <Label>Staged Files</Label> - {hasStagedFiles ? ( - <div className="border-border rounded-md border p-2 space-y-1"> - {staged.map((file) => ( - <Text - key={file} - className="text-xs font-mono text-foreground truncate block" - > - {file} - </Text> - ))} - </div> - ) : ( - <div className="border-border rounded-md border p-2"> - <Text className="text-muted-foreground text-xs">No staged files.</Text> - </div> - )} - </div> - - {/* Commit message */} - <div className="space-y-1.5"> - <Label htmlFor="commit-message">Commit Message</Label> - <Textarea - className="min-h-[80px]" - id="commit-message" - placeholder="Describe your changes..." - resize="none" - value={commitMessage} - onChange={(e) => setCommitMessage(e.target.value)} - /> - </div> - - {/* Commit error */} - {commitError === null ? null : renderError(commitError)} - - {/* Push error */} - {pushError === null ? null : renderError(pushError)} - - {/* Action buttons */} - <div className="flex flex-wrap gap-2"> - {/* Commit */} - <Button - disabled={!canCommit || gitCommit.isPending} - size="sm" - variant="primary" - onClick={handleCommit} - > - {gitCommit.isPending ? ( - <> - <Spinner size="sm" /> - Committing... - </> - ) : ( - <> - <GitCommit className="h-4 w-4" /> - Commit - </> - )} - </Button> - - {/* Push */} - <Button - disabled={gitPush.isPending} - size="sm" - variant="secondary" - onClick={handlePush} - > - {gitPush.isPending ? ( - <> - <Spinner size="sm" /> - Pushing... - </> - ) : ( - <> - <Upload className="h-4 w-4" /> - Push - </> - )} - </Button> - - {/* Create PR */} - <CreatePrDialog - headBranch={headBranch} - renderError={renderError} - repoPath={repoPath} - /> - </div> - </CardContent> - </Card> - ); -} diff --git a/src/renderer/features/git-overview/components/CreatePrDialog.tsx b/src/renderer/features/git-overview/components/CreatePrDialog.tsx deleted file mode 100644 index 8dcaaae6..00000000 --- a/src/renderer/features/git-overview/components/CreatePrDialog.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/** - * CreatePrDialog — Dialog for creating a GitHub pull request. - * Extracted from CommitPanel for size compliance. - */ - -import { useState } from 'react'; - -import { GitPullRequest } from 'lucide-react'; - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - Input, - Label, - Spinner, - Textarea, -} from '@ui'; - -import { useCreatePr } from '../api/useGit'; - -interface CreatePrDialogProps { - repoPath: string; - headBranch: string; - renderError: (message: string) => React.ReactNode; -} - -export function CreatePrDialog({ repoPath, headBranch, renderError }: CreatePrDialogProps) { - const createPr = useCreatePr(); - - const [prTitle, setPrTitle] = useState(''); - const [prBody, setPrBody] = useState(''); - const [prBaseBranch, setPrBaseBranch] = useState('main'); - const [prError, setPrError] = useState<string | null>(null); - const [open, setOpen] = useState(false); - - function handleOpenChange(next: boolean) { - setOpen(next); - if (!next) { - setPrTitle(''); - setPrBody(''); - setPrBaseBranch('main'); - setPrError(null); - } - } - - function handleCreatePr() { - if (prTitle.trim().length === 0) return; - setPrError(null); - createPr.mutate( - { - projectPath: repoPath, - title: prTitle.trim(), - body: prBody.trim(), - baseBranch: prBaseBranch.trim() || 'main', - headBranch, - }, - { - onSuccess: () => { - handleOpenChange(false); - }, - onError: (err) => { - setPrError(err instanceof Error ? err.message : 'Failed to create PR'); - }, - }, - ); - } - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogTrigger asChild> - <Button size="sm" variant="outline"> - <GitPullRequest className="h-4 w-4" /> - Create PR - </Button> - </DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>Create Pull Request</DialogTitle> - </DialogHeader> - - <div className="space-y-4"> - <div className="space-y-1.5"> - <Label htmlFor="pr-title">Title</Label> - <Input - aria-required="true" - id="pr-title" - placeholder="Pull request title..." - type="text" - value={prTitle} - onChange={(e) => setPrTitle(e.target.value)} - /> - </div> - - <div className="space-y-1.5"> - <Label htmlFor="pr-body">Description</Label> - <Textarea - className="min-h-[80px]" - id="pr-body" - placeholder="Describe the changes..." - resize="none" - value={prBody} - onChange={(e) => setPrBody(e.target.value)} - /> - </div> - - <div className="space-y-1.5"> - <Label htmlFor="pr-base-branch">Base Branch</Label> - <Input - id="pr-base-branch" - placeholder="main" - type="text" - value={prBaseBranch} - onChange={(e) => setPrBaseBranch(e.target.value)} - /> - </div> - - {prError === null ? null : renderError(prError)} - </div> - - <DialogFooter> - <Button variant="ghost" onClick={() => handleOpenChange(false)}> - Cancel - </Button> - <Button - disabled={prTitle.trim().length === 0 || createPr.isPending} - onClick={handleCreatePr} - > - {createPr.isPending ? ( - <> - <Spinner size="sm" /> - Creating... - </> - ) : ( - <> - <GitPullRequest className="h-4 w-4" /> - Create PR - </> - )} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/git-overview/api/queryKeys.ts b/src/renderer/features/git/api/queryKeys.ts similarity index 100% rename from src/renderer/features/git-overview/api/queryKeys.ts rename to src/renderer/features/git/api/queryKeys.ts diff --git a/src/renderer/features/git-overview/api/useCommitHistory.ts b/src/renderer/features/git/api/useCommitHistory.ts similarity index 100% rename from src/renderer/features/git-overview/api/useCommitHistory.ts rename to src/renderer/features/git/api/useCommitHistory.ts diff --git a/src/renderer/features/git-overview/api/useGit.ts b/src/renderer/features/git/api/useGit.ts similarity index 100% rename from src/renderer/features/git-overview/api/useGit.ts rename to src/renderer/features/git/api/useGit.ts diff --git a/src/renderer/features/git/components/BranchDiffPanel/BranchDiffPanel.tsx b/src/renderer/features/git/components/BranchDiffPanel/BranchDiffPanel.tsx new file mode 100644 index 00000000..6eaccd22 --- /dev/null +++ b/src/renderer/features/git/components/BranchDiffPanel/BranchDiffPanel.tsx @@ -0,0 +1,127 @@ +/** + * BranchDiffPanel — Compare two branches with a file list and GitHub-style diff view. + * + * Rendered as the "Diff" tab inside GitPage. Uses the diff-viewer feature's + * DiffViewer + DiffFileList components with data fetched via the merge IPC channels. + */ + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Text, +} from '@ui'; + +import { DiffFileList, DiffViewer } from '@features/diff-viewer'; + +import { useBranchDiffPanel } from './useBranchDiffPanel'; + +interface BranchDiffPanelProps { + repoPath: string; +} + +export function BranchDiffPanel({ repoPath }: BranchDiffPanelProps) { + const { + branches, + sourceBranch, + setSourceBranch, + targetBranch, + setTargetBranch, + expandedContext, + selectedFile, + selectFile, + setViewMode, + toggleExpandedContext, + viewMode, + diffSummary, + fileDiff, + files, + selectedFileStats, + } = useBranchDiffPanel(repoPath); + + return ( + <div className="flex h-full flex-col gap-3"> + {/* Branch selectors */} + <div className="flex items-center gap-2"> + <Text className="shrink-0 text-sm" variant="muted"> + Compare + </Text> + <Select value={sourceBranch ?? ''} onValueChange={setSourceBranch}> + <SelectTrigger className="w-52"> + <SelectValue placeholder="Source branch" /> + </SelectTrigger> + <SelectContent> + {branches.map((b) => ( + <SelectItem key={b.name} value={b.name}> + {b.name} + </SelectItem> + ))} + </SelectContent> + </Select> + <Text className="shrink-0 text-sm" variant="muted"> + into + </Text> + <Select value={targetBranch ?? ''} onValueChange={setTargetBranch}> + <SelectTrigger className="w-52"> + <SelectValue placeholder="Target branch" /> + </SelectTrigger> + <SelectContent> + {branches.map((b) => ( + <SelectItem key={b.name} value={b.name}> + {b.name} + </SelectItem> + ))} + </SelectContent> + </Select> + {diffSummary !== undefined && ( + <Text className="ml-auto text-xs" variant="muted"> + {diffSummary.changedFileCount} files   + <span className="text-success">+{diffSummary.totalInsertions}</span> + {' / '} + <span className="text-destructive">-{diffSummary.totalDeletions}</span> + </Text> + )} + </div> + + {/* File list + diff area */} + {sourceBranch !== null && targetBranch !== null ? ( + <div className="flex min-h-0 flex-1 gap-3"> + <div className="w-64 shrink-0 overflow-y-auto"> + <DiffFileList + grouped + files={files} + selectedFile={selectedFile} + onSelectFile={selectFile} + /> + </div> + <div className="min-w-0 flex-1 overflow-auto"> + {selectedFile !== null && fileDiff !== undefined ? ( + <DiffViewer + deletions={selectedFileStats?.deletions} + diffText={fileDiff.diff} + expandedContext={expandedContext} + filePath={selectedFile} + insertions={selectedFileStats?.insertions} + viewMode={viewMode} + onToggleExpand={toggleExpandedContext} + onToggleViewMode={() => setViewMode(viewMode === 'split' ? 'unified' : 'split')} + /> + ) : ( + <div className="text-muted-foreground flex h-full items-center justify-center text-sm"> + {files.length > 0 + ? 'Select a file to view its diff.' + : 'No changes between these branches.'} + </div> + )} + </div> + </div> + ) : ( + <div className="text-muted-foreground flex flex-1 items-center justify-center text-sm"> + Select two branches to compare. + </div> + )} + </div> + ); +} diff --git a/src/renderer/features/git/components/BranchDiffPanel/index.ts b/src/renderer/features/git/components/BranchDiffPanel/index.ts new file mode 100644 index 00000000..79e8aece --- /dev/null +++ b/src/renderer/features/git/components/BranchDiffPanel/index.ts @@ -0,0 +1 @@ +export { BranchDiffPanel } from './BranchDiffPanel'; diff --git a/src/renderer/features/git/components/BranchDiffPanel/useBranchDiffPanel.ts b/src/renderer/features/git/components/BranchDiffPanel/useBranchDiffPanel.ts new file mode 100644 index 00000000..91796cc9 --- /dev/null +++ b/src/renderer/features/git/components/BranchDiffPanel/useBranchDiffPanel.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; + +import { + useDiffSummary, + useDiffViewerUI, + useFileDiffContent, +} from '@features/diff-viewer'; + +import { useGitBranches } from '../../api/useGit'; + +export function useBranchDiffPanel(repoPath: string) { + const { data: branches = [] } = useGitBranches(repoPath); + + const [sourceBranch, setSourceBranch] = useState<string | null>(null); + const [targetBranch, setTargetBranch] = useState<string | null>(null); + + // Set sensible defaults once branches load + useEffect(() => { + if (branches.length === 0) return; + setSourceBranch((prev) => { + if (prev !== null) return prev; + return branches.find((b) => b.current)?.name ?? branches[0].name; + }); + setTargetBranch((prev) => { + if (prev !== null) return prev; + return ( + branches.find((b) => b.name === 'main' && !b.current)?.name ?? + branches.find((b) => b.name === 'master' && !b.current)?.name ?? + branches.find((b) => !b.current)?.name ?? + null + ); + }); + }, [branches]); + + const { expandedContext, selectedFile, selectFile, setViewMode, toggleExpandedContext, viewMode } = + useDiffViewerUI(); + + const { data: diffSummary } = useDiffSummary(repoPath, sourceBranch, targetBranch); + const { data: fileDiff } = useFileDiffContent(repoPath, sourceBranch, targetBranch, selectedFile); + + const files = diffSummary?.files ?? []; + const selectedFileStats = files.find((f) => f.filePath === selectedFile); + + return { + branches, + sourceBranch, + setSourceBranch, + targetBranch, + setTargetBranch, + expandedContext, + selectedFile, + selectFile, + setViewMode, + toggleExpandedContext, + viewMode, + diffSummary, + fileDiff, + files, + selectedFileStats, + }; +} diff --git a/src/renderer/features/git-overview/components/BranchList.tsx b/src/renderer/features/git/components/BranchList/BranchList.tsx similarity index 76% rename from src/renderer/features/git-overview/components/BranchList.tsx rename to src/renderer/features/git/components/BranchList/BranchList.tsx index 504263c3..fd2855cb 100644 --- a/src/renderer/features/git-overview/components/BranchList.tsx +++ b/src/renderer/features/git/components/BranchList/BranchList.tsx @@ -3,8 +3,6 @@ * a "New Branch" dialog, and a switch branch action. */ -import { useState } from 'react'; - import { GitBranch, Plus } from 'lucide-react'; import { @@ -27,37 +25,26 @@ import { Text, } from '@ui'; -import { useCreateBranch, useGitBranches } from '../api/useGit'; +import { useBranchList } from './useBranchList'; interface BranchListProps { repoPath: string; } export function BranchList({ repoPath }: BranchListProps) { - const { data: branches, isLoading } = useGitBranches(repoPath); - const createBranch = useCreateBranch(); - - const [dialogOpen, setDialogOpen] = useState(false); - const [branchName, setBranchName] = useState(''); - const [baseBranch, setBaseBranch] = useState(''); - - async function handleCreate() { - if (!branchName) return; - await createBranch.mutateAsync({ - repoPath, - branchName, - baseBranch: baseBranch || undefined, - }); - setBranchName(''); - setBaseBranch(''); - setDialogOpen(false); - } - - function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { - if (e.key === 'Enter') { - void handleCreate(); - } - } + const { + branches, + isLoading, + isCreating, + dialogOpen, + setDialogOpen, + branchName, + setBranchName, + baseBranch, + setBaseBranch, + handleCreate, + handleKeyDown, + } = useBranchList(repoPath); return ( <Card> @@ -81,7 +68,7 @@ export function BranchList({ repoPath }: BranchListProps) { id="branch-name" placeholder="feature/my-branch" value={branchName} - onChange={(e) => setBranchName(e.target.value)} + onChange={(e) => { setBranchName(e.target.value); }} onKeyDown={handleKeyDown} /> </div> @@ -94,22 +81,19 @@ export function BranchList({ repoPath }: BranchListProps) { id="base-branch" placeholder="main" value={baseBranch} - onChange={(e) => setBaseBranch(e.target.value)} + onChange={(e) => { setBaseBranch(e.target.value); }} /> </div> </div> <DialogFooter> - <Button - variant="outline" - onClick={() => setDialogOpen(false)} - > + <Button variant="outline" onClick={() => { setDialogOpen(false); }}> Cancel </Button> <Button - disabled={!branchName || createBranch.isPending} - onClick={handleCreate} + disabled={!branchName || isCreating} + onClick={() => { void handleCreate(); }} > - {createBranch.isPending ? 'Creating...' : 'Create'} + {isCreating ? 'Creating...' : 'Create'} </Button> </DialogFooter> </DialogContent> diff --git a/src/renderer/features/git/components/BranchList/index.ts b/src/renderer/features/git/components/BranchList/index.ts new file mode 100644 index 00000000..302d624c --- /dev/null +++ b/src/renderer/features/git/components/BranchList/index.ts @@ -0,0 +1 @@ +export { BranchList } from './BranchList'; diff --git a/src/renderer/features/git/components/BranchList/useBranchList.ts b/src/renderer/features/git/components/BranchList/useBranchList.ts new file mode 100644 index 00000000..a48a7847 --- /dev/null +++ b/src/renderer/features/git/components/BranchList/useBranchList.ts @@ -0,0 +1,44 @@ +import { useState } from 'react'; + +import { useCreateBranch, useGitBranches } from '../../api/useGit'; + +export function useBranchList(repoPath: string) { + const { data: branches, isLoading } = useGitBranches(repoPath); + const createBranch = useCreateBranch(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [branchName, setBranchName] = useState(''); + const [baseBranch, setBaseBranch] = useState(''); + + async function handleCreate() { + if (!branchName) return; + await createBranch.mutateAsync({ + repoPath, + branchName, + baseBranch: baseBranch || undefined, + }); + setBranchName(''); + setBaseBranch(''); + setDialogOpen(false); + } + + function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { + if (e.key === 'Enter') { + void handleCreate(); + } + } + + return { + branches, + isLoading, + isCreating: createBranch.isPending, + dialogOpen, + setDialogOpen, + branchName, + setBranchName, + baseBranch, + setBaseBranch, + handleCreate, + handleKeyDown, + }; +} diff --git a/src/renderer/features/git/components/ChangelogSummary/ChangelogSummary.tsx b/src/renderer/features/git/components/ChangelogSummary/ChangelogSummary.tsx new file mode 100644 index 00000000..325b1a6a --- /dev/null +++ b/src/renderer/features/git/components/ChangelogSummary/ChangelogSummary.tsx @@ -0,0 +1,94 @@ +/** + * ChangelogSummary — Compact display of the latest changelog entry + * + * Shows a truncated summary (~100 chars) of the most recent changelog entry + * with Expand (opens Dialog with full ChangelogPage), Copy (clipboard), + * and Update (opens generate form) actions. + */ + +import { ClipboardCopy, Expand, RefreshCw, ScrollText } from 'lucide-react'; + +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Text, +} from '@ui'; + +import { ChangelogPage } from '@features/changelog'; + +import { useChangelogSummary } from './useChangelogSummary'; + +export function ChangelogSummary() { + const { isLoading, latest, summary, dialogOpen, setDialogOpen, copied, handleCopy } = + useChangelogSummary(); + + if (isLoading) { + return ( + <Text className="text-muted-foreground text-xs">Loading changelog...</Text> + ); + } + + if (!latest) { + return ( + <div className="flex items-center gap-2"> + <ScrollText className="text-muted-foreground h-4 w-4" /> + <Text className="text-muted-foreground text-xs">No changelog entries</Text> + </div> + ); + } + + return ( + <> + <div className="flex items-center gap-2"> + <ScrollText className="text-muted-foreground h-4 w-4 shrink-0" /> + <Text className="text-muted-foreground max-w-[280px] truncate text-xs" title={summary}> + v{latest.version}: {summary} + </Text> + + <Button + aria-label="Expand changelog" + className="h-6 w-6" + size="icon" + variant="ghost" + onClick={() => { setDialogOpen(true); }} + > + <Expand className="h-3 w-3" /> + </Button> + + <Button + aria-label={copied ? 'Copied' : 'Copy latest changelog'} + className="h-6 w-6" + size="icon" + variant="ghost" + onClick={handleCopy} + > + <ClipboardCopy className="h-3 w-3" /> + </Button> + + <Button + aria-label="Update changelog" + className="h-6 w-6" + size="icon" + variant="ghost" + onClick={() => { setDialogOpen(true); }} + > + <RefreshCw className="h-3 w-3" /> + </Button> + </div> + + <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> + <DialogContent className="max-h-[80vh] max-w-3xl overflow-y-auto"> + <DialogHeader> + <DialogTitle>Changelog</DialogTitle> + <DialogDescription>Full version history and release notes</DialogDescription> + </DialogHeader> + <ChangelogPage /> + </DialogContent> + </Dialog> + </> + ); +} diff --git a/src/renderer/features/git/components/ChangelogSummary/index.ts b/src/renderer/features/git/components/ChangelogSummary/index.ts new file mode 100644 index 00000000..e560da26 --- /dev/null +++ b/src/renderer/features/git/components/ChangelogSummary/index.ts @@ -0,0 +1 @@ +export { ChangelogSummary } from './ChangelogSummary'; diff --git a/src/renderer/features/git/components/ChangelogSummary/useChangelogSummary.ts b/src/renderer/features/git/components/ChangelogSummary/useChangelogSummary.ts new file mode 100644 index 00000000..df397ad4 --- /dev/null +++ b/src/renderer/features/git/components/ChangelogSummary/useChangelogSummary.ts @@ -0,0 +1,57 @@ +import { useState } from 'react'; + +import type { ChangelogEntry } from '@shared/types'; + +import { useChangelog } from '@features/changelog'; + +/** Build a plain-text summary from a changelog entry, truncated to maxLen chars */ +function summariseEntry(entry: ChangelogEntry, maxLen = 100): string { + const parts: string[] = []; + for (const cat of entry.categories) { + for (const item of cat.items) { + parts.push(item); + } + } + const joined = parts.join('; '); + return joined.length > maxLen ? `${joined.slice(0, maxLen)}...` : joined; +} + +/** Build full plain-text for clipboard copy */ +function entryToClipboard(entry: ChangelogEntry): string { + const lines: string[] = [`v${entry.version} — ${entry.date}`]; + for (const cat of entry.categories) { + lines.push(`\n### ${cat.type}`); + for (const item of cat.items) { + lines.push(`- ${item}`); + } + } + return lines.join('\n'); +} + +export function useChangelogSummary() { + const { data: entries, isLoading } = useChangelog(); + const [dialogOpen, setDialogOpen] = useState(false); + const [copied, setCopied] = useState(false); + + const latest = entries?.[0] ?? null; + const summary = latest === null ? '' : summariseEntry(latest); + + function handleCopy(): void { + if (!latest) return; + void (async () => { + await navigator.clipboard.writeText(entryToClipboard(latest)); + setCopied(true); + setTimeout(() => { setCopied(false); }, 2000); + })(); + } + + return { + isLoading, + latest, + summary, + dialogOpen, + setDialogOpen, + copied, + handleCopy, + }; +} diff --git a/src/renderer/features/git-overview/components/CommitHistory.tsx b/src/renderer/features/git/components/CommitHistory/CommitHistory.tsx similarity index 81% rename from src/renderer/features/git-overview/components/CommitHistory.tsx rename to src/renderer/features/git/components/CommitHistory/CommitHistory.tsx index f4ada8b4..4243797a 100644 --- a/src/renderer/features/git-overview/components/CommitHistory.tsx +++ b/src/renderer/features/git/components/CommitHistory/CommitHistory.tsx @@ -5,18 +5,26 @@ * Supports "Load more" to incrementally fetch additional commits. */ -import { useState } from 'react'; - import { GitCommit } from 'lucide-react'; import type { GitCommit as GitCommitType } from '@shared/ipc/git/schemas'; import { RelativeTime } from '@renderer/shared/components/RelativeTime'; -import { Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Separator, Skeleton, Text } from '@ui'; - - -import { useCommitHistory } from '../api/useGit'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Code, + EmptyState, + Separator, + Skeleton, + Text, +} from '@ui'; + +import { useCommitHistory } from './useCommitHistory'; interface CommitHistoryProps { repoPath: string; @@ -38,9 +46,7 @@ function CommitRowSkeleton() { function CommitRow({ commit }: { commit: GitCommitType }) { return ( <div className="flex items-start gap-3 py-3"> - <code className="font-mono text-xs text-muted-foreground bg-accent px-1 rounded mt-0.5 shrink-0"> - {commit.shortHash} - </code> + <Code className="mt-0.5 shrink-0 text-xs">{commit.shortHash}</Code> <div className="min-w-0 flex-1"> <Text className="truncate" size="sm">{commit.message}</Text> <Text className="text-xs text-muted-foreground">{commit.author}</Text> @@ -51,15 +57,7 @@ function CommitRow({ commit }: { commit: GitCommitType }) { } export function CommitHistory({ repoPath }: CommitHistoryProps) { - const [limit, setLimit] = useState(50); - - const { data: commits, isLoading } = useCommitHistory({ repoPath, limit }); - - const hasCommits = (commits?.length ?? 0) > 0; - - function handleLoadMore() { - setLimit((prev) => prev + 50); - } + const { commits, isLoading, hasCommits, handleLoadMore } = useCommitHistory(repoPath); function renderContent() { if (isLoading) { diff --git a/src/renderer/features/git/components/CommitHistory/index.ts b/src/renderer/features/git/components/CommitHistory/index.ts new file mode 100644 index 00000000..8d667341 --- /dev/null +++ b/src/renderer/features/git/components/CommitHistory/index.ts @@ -0,0 +1 @@ +export { CommitHistory } from './CommitHistory'; diff --git a/src/renderer/features/git/components/CommitHistory/useCommitHistory.ts b/src/renderer/features/git/components/CommitHistory/useCommitHistory.ts new file mode 100644 index 00000000..f615a175 --- /dev/null +++ b/src/renderer/features/git/components/CommitHistory/useCommitHistory.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +import { useCommitHistory as useCommitHistoryQuery } from '../../api/useGit'; + +export function useCommitHistory(repoPath: string) { + const [limit, setLimit] = useState(50); + + // Reset pagination when switching projects + useEffect(() => { + setLimit(50); + }, [repoPath]); + + const { data: commits, isLoading } = useCommitHistoryQuery({ repoPath, limit }); + + const hasCommits = (commits?.length ?? 0) > 0; + + function handleLoadMore() { + setLimit((prev) => prev + 50); + } + + return { + commits, + isLoading, + hasCommits, + handleLoadMore, + }; +} diff --git a/src/renderer/features/git/components/CommitPanel/CommitPanel.tsx b/src/renderer/features/git/components/CommitPanel/CommitPanel.tsx new file mode 100644 index 00000000..15839521 --- /dev/null +++ b/src/renderer/features/git/components/CommitPanel/CommitPanel.tsx @@ -0,0 +1,147 @@ +/** + * CommitPanel — Staged files list, commit message, commit/push/PR actions. + */ + +import { AlertTriangle, GitCommit, Upload } from 'lucide-react'; + +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Label, + Spinner, + Text, + Textarea, +} from '@ui'; + +import { CreatePrDialog } from '../CreatePrDialog'; + +import { useCommitPanel } from './useCommitPanel'; + +export interface CommitPanelProps { + repoPath: string; + projectId: string; +} + +function renderError(message: string) { + return ( + <div className="bg-destructive/10 rounded-md p-3"> + <div className="text-destructive flex items-center gap-2 text-sm"> + <AlertTriangle className="h-4 w-4 shrink-0" /> + {message} + </div> + </div> + ); +} + +export function CommitPanel({ repoPath }: CommitPanelProps) { + const { + staged, + hasStagedFiles, + canCommit, + headBranch, + commitMessage, + setCommitMessage, + commitError, + pushError, + isCommitting, + isPushing, + handleCommit, + handlePush, + } = useCommitPanel(repoPath); + + return ( + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">Commit & Push</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* Staged files */} + <div className="space-y-1.5"> + <Label>Staged Files</Label> + {hasStagedFiles ? ( + <div className="border-border rounded-md border p-2 space-y-1"> + {staged.map((file) => ( + <Text + key={file} + className="text-xs font-mono text-foreground truncate block" + > + {file} + </Text> + ))} + </div> + ) : ( + <div className="border-border rounded-md border p-2"> + <Text className="text-muted-foreground text-xs">No staged files.</Text> + </div> + )} + </div> + + {/* Commit message */} + <div className="space-y-1.5"> + <Label htmlFor="commit-message">Commit Message</Label> + <Textarea + className="min-h-[80px]" + id="commit-message" + placeholder="Describe your changes..." + resize="none" + value={commitMessage} + onChange={(e) => { setCommitMessage(e.target.value); }} + /> + </div> + + {commitError === null ? null : renderError(commitError)} + {pushError === null ? null : renderError(pushError)} + + {/* Action buttons */} + <div className="flex flex-wrap gap-2"> + <Button + disabled={!canCommit || isCommitting} + size="sm" + variant="primary" + onClick={handleCommit} + > + {isCommitting ? ( + <> + <Spinner size="sm" /> + Committing... + </> + ) : ( + <> + <GitCommit className="h-4 w-4" /> + Commit + </> + )} + </Button> + + <Button + disabled={isPushing} + size="sm" + variant="secondary" + onClick={handlePush} + > + {isPushing ? ( + <> + <Spinner size="sm" /> + Pushing... + </> + ) : ( + <> + <Upload className="h-4 w-4" /> + Push + </> + )} + </Button> + + <CreatePrDialog + headBranch={headBranch} + renderError={renderError} + repoPath={repoPath} + /> + </div> + </CardContent> + </Card> + ); +} diff --git a/src/renderer/features/git/components/CommitPanel/index.ts b/src/renderer/features/git/components/CommitPanel/index.ts new file mode 100644 index 00000000..1147427c --- /dev/null +++ b/src/renderer/features/git/components/CommitPanel/index.ts @@ -0,0 +1,2 @@ +export { CommitPanel } from './CommitPanel'; +export type { CommitPanelProps } from './CommitPanel'; diff --git a/src/renderer/features/git/components/CommitPanel/useCommitPanel.ts b/src/renderer/features/git/components/CommitPanel/useCommitPanel.ts new file mode 100644 index 00000000..70e97455 --- /dev/null +++ b/src/renderer/features/git/components/CommitPanel/useCommitPanel.ts @@ -0,0 +1,63 @@ +import { useState } from 'react'; + +import { useGitCommit, useGitPush, useGitStatus } from '../../api/useGit'; + +export function useCommitPanel(repoPath: string) { + const { data: status } = useGitStatus(repoPath); + const gitCommit = useGitCommit(); + const gitPush = useGitPush(); + + const [commitMessage, setCommitMessage] = useState(''); + const [commitError, setCommitError] = useState<string | null>(null); + const [pushError, setPushError] = useState<string | null>(null); + + const staged = status?.staged ?? []; + const hasStagedFiles = staged.length > 0; + const hasCommitMessage = commitMessage.trim().length > 0; + const canCommit = hasStagedFiles && hasCommitMessage; + const headBranch = status?.branch ?? ''; + + function handleCommit() { + if (!canCommit) return; + setCommitError(null); + gitCommit.mutate( + { projectPath: repoPath, message: commitMessage.trim(), files: staged }, + { + onSuccess: () => { + setCommitMessage(''); + setCommitError(null); + }, + onError: (err) => { + setCommitError(err instanceof Error ? err.message : 'Commit failed'); + }, + }, + ); + } + + function handlePush() { + setPushError(null); + gitPush.mutate( + { projectPath: repoPath }, + { + onError: (err) => { + setPushError(err instanceof Error ? err.message : 'Push failed'); + }, + }, + ); + } + + return { + staged, + hasStagedFiles, + canCommit, + headBranch, + commitMessage, + setCommitMessage, + commitError, + pushError, + isCommitting: gitCommit.isPending, + isPushing: gitPush.isPending, + handleCommit, + handlePush, + }; +} diff --git a/src/renderer/features/git/components/CreatePrDialog/CreatePrDialog.tsx b/src/renderer/features/git/components/CreatePrDialog/CreatePrDialog.tsx new file mode 100644 index 00000000..c4b0d7c0 --- /dev/null +++ b/src/renderer/features/git/components/CreatePrDialog/CreatePrDialog.tsx @@ -0,0 +1,121 @@ +/** + * CreatePrDialog — Dialog for creating a GitHub pull request. + * Extracted from CommitPanel for size compliance. + */ + +import { GitPullRequest } from 'lucide-react'; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Input, + Label, + Spinner, + Textarea, +} from '@ui'; + +import { useCreatePrDialog } from './useCreatePrDialog'; + +interface CreatePrDialogProps { + repoPath: string; + headBranch: string; + renderError: (message: string) => React.ReactNode; +} + +export function CreatePrDialog({ repoPath, headBranch, renderError }: CreatePrDialogProps) { + const { + prTitle, + setPrTitle, + prBody, + setPrBody, + prBaseBranch, + setPrBaseBranch, + prError, + open, + isPending, + handleOpenChange, + handleCreatePr, + } = useCreatePrDialog(); + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button size="sm" variant="outline"> + <GitPullRequest className="h-4 w-4" /> + Create PR + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Create Pull Request</DialogTitle> + </DialogHeader> + + <div className="space-y-4"> + <div className="space-y-1.5"> + <Label htmlFor="pr-title">Title</Label> + <Input + aria-required="true" + id="pr-title" + placeholder="Pull request title..." + type="text" + value={prTitle} + onChange={(e) => { setPrTitle(e.target.value); }} + /> + </div> + + <div className="space-y-1.5"> + <Label htmlFor="pr-body">Description</Label> + <Textarea + className="min-h-[80px]" + id="pr-body" + placeholder="Describe the changes..." + resize="none" + value={prBody} + onChange={(e) => { setPrBody(e.target.value); }} + /> + </div> + + <div className="space-y-1.5"> + <Label htmlFor="pr-base-branch">Base Branch</Label> + <Input + id="pr-base-branch" + placeholder="main" + type="text" + value={prBaseBranch} + onChange={(e) => { setPrBaseBranch(e.target.value); }} + /> + </div> + + {prError === null ? null : renderError(prError)} + </div> + + <DialogFooter> + <Button variant="ghost" onClick={() => { handleOpenChange(false); }}> + Cancel + </Button> + <Button + disabled={prTitle.trim().length === 0 || isPending} + onClick={() => { handleCreatePr(repoPath, headBranch); }} + > + {isPending ? ( + <> + <Spinner size="sm" /> + Creating... + </> + ) : ( + <> + <GitPullRequest className="h-4 w-4" /> + Create PR + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/git/components/CreatePrDialog/index.ts b/src/renderer/features/git/components/CreatePrDialog/index.ts new file mode 100644 index 00000000..eec8b88c --- /dev/null +++ b/src/renderer/features/git/components/CreatePrDialog/index.ts @@ -0,0 +1 @@ +export { CreatePrDialog } from './CreatePrDialog'; diff --git a/src/renderer/features/git/components/CreatePrDialog/useCreatePrDialog.ts b/src/renderer/features/git/components/CreatePrDialog/useCreatePrDialog.ts new file mode 100644 index 00000000..bf9a5ad6 --- /dev/null +++ b/src/renderer/features/git/components/CreatePrDialog/useCreatePrDialog.ts @@ -0,0 +1,60 @@ +import { useCallback, useState } from 'react'; + +import { useDialogWithMutation } from '@renderer/shared/hooks/useDialogWithMutation'; + +import { useCreatePr } from '../../api/useGit'; + +export function useCreatePrDialog() { + const createPr = useCreatePr(); + + const [prTitle, setPrTitle] = useState(''); + const [prBody, setPrBody] = useState(''); + const [prBaseBranch, setPrBaseBranch] = useState('main'); + const [prError, setPrError] = useState<string | null>(null); + const [open, setOpen] = useState(false); + + const resetForm = useCallback(() => { + setPrTitle(''); + setPrBody(''); + setPrBaseBranch('main'); + setPrError(null); + }, []); + + function handleOpenChange(next: boolean) { + setOpen(next); + if (!next) { + resetForm(); + } + } + + const { handleSubmit: submitMutation, isPending } = useDialogWithMutation(createPr, { + onClose: () => handleOpenChange(false), + resetForm, + }); + + function handleCreatePr(repoPath: string, headBranch: string) { + if (prTitle.trim().length === 0) return; + setPrError(null); + submitMutation({ + projectPath: repoPath, + title: prTitle.trim(), + body: prBody.trim(), + baseBranch: prBaseBranch.trim() || 'main', + headBranch, + }); + } + + return { + prTitle, + setPrTitle, + prBody, + setPrBody, + prBaseBranch, + setPrBaseBranch, + prError, + open, + isPending, + handleOpenChange, + handleCreatePr, + }; +} diff --git a/src/renderer/features/git-overview/components/GitPage.tsx b/src/renderer/features/git/components/GitPage.tsx similarity index 79% rename from src/renderer/features/git-overview/components/GitPage.tsx rename to src/renderer/features/git/components/GitPage.tsx index 2b394177..9aa07274 100644 --- a/src/renderer/features/git-overview/components/GitPage.tsx +++ b/src/renderer/features/git/components/GitPage.tsx @@ -1,7 +1,9 @@ /** - * GitPage — Source control dashboard with Local and GitHub tabs. + * GitPage — Source control dashboard with Local, History, Diff, and GitHub tabs. * * Local tab: GitStatusCard, BranchList, WorktreeList, CommitPanel. + * History tab: CommitHistory. + * Diff tab: BranchDiffPanel (branch-to-branch diff via @git-diff-view/react). * GitHub tab: GitHubPanel (existing integration). * * repoPath is derived from the active project via the $projectId route param. @@ -14,6 +16,7 @@ import { PageContent, PageHeader, PageLayout } from '@ui'; import { GitHubPanel } from '@features/integrations'; import { useProjects } from '@features/projects'; +import { BranchDiffPanel } from './BranchDiffPanel'; import { BranchList } from './BranchList'; import { CommitHistory } from './CommitHistory'; import { CommitPanel } from './CommitPanel'; @@ -38,6 +41,7 @@ export function GitPage() { <PageHeader.TabList> <PageHeader.Tab value="local">Local</PageHeader.Tab> <PageHeader.Tab value="history">History</PageHeader.Tab> + <PageHeader.Tab value="diff">Diff</PageHeader.Tab> <PageHeader.Tab value="github">GitHub</PageHeader.Tab> </PageHeader.TabList> </PageHeader> @@ -65,6 +69,15 @@ export function GitPage() { <CommitHistory repoPath={repoPath} /> )} </PageHeader.TabContent> + <PageHeader.TabContent value="diff"> + {repoPath === null ? ( + <div className="text-muted-foreground flex h-full items-center justify-center text-sm"> + Open a project to compare branches. + </div> + ) : ( + <BranchDiffPanel repoPath={repoPath} /> + )} + </PageHeader.TabContent> <PageHeader.TabContent value="github"> <GitHubPanel /> </PageHeader.TabContent> diff --git a/src/renderer/features/git-overview/components/GitStatusCard.tsx b/src/renderer/features/git/components/GitStatusCard.tsx similarity index 100% rename from src/renderer/features/git-overview/components/GitStatusCard.tsx rename to src/renderer/features/git/components/GitStatusCard.tsx diff --git a/src/renderer/features/git-overview/components/WorktreeList.tsx b/src/renderer/features/git/components/WorktreeList/WorktreeList.tsx similarity index 77% rename from src/renderer/features/git-overview/components/WorktreeList.tsx rename to src/renderer/features/git/components/WorktreeList/WorktreeList.tsx index 6eb8f9fa..e9121630 100644 --- a/src/renderer/features/git-overview/components/WorktreeList.tsx +++ b/src/renderer/features/git/components/WorktreeList/WorktreeList.tsx @@ -7,8 +7,6 @@ * Each row has a "Remove" button that shows a confirmation dialog before deleting. */ -import { useState } from 'react'; - import { GitFork, Plus, Trash2 } from 'lucide-react'; import { formatRelativeTime } from '@renderer/shared/lib/utils'; @@ -41,7 +39,7 @@ import { Text, } from '@ui'; -import { useCreateWorktree, useListWorktrees, useRemoveWorktree } from '../api/useGit'; +import { useWorktreeList } from './useWorktreeList'; export interface WorktreeListProps { repoPath: string; @@ -49,27 +47,20 @@ export interface WorktreeListProps { } export function WorktreeList({ repoPath, projectId }: WorktreeListProps) { - const { data: worktrees, isLoading } = useListWorktrees(projectId); - const createWorktree = useCreateWorktree(); - const removeWorktree = useRemoveWorktree(); - - const [addOpen, setAddOpen] = useState(false); - const [worktreePath, setWorktreePath] = useState(''); - const [branch, setBranch] = useState(''); - - async function handleCreate() { - if (!worktreePath || !branch) return; - await createWorktree.mutateAsync({ repoPath, worktreePath, branch }); - setWorktreePath(''); - setBranch(''); - setAddOpen(false); - } - - function handleInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { - if (e.key === 'Enter') { - void handleCreate(); - } - } + const { + worktrees, + isLoading, + isCreating, + addOpen, + setAddOpen, + worktreePath, + setWorktreePath, + branch, + setBranch, + handleCreate, + handleInputKeyDown, + handleRemove, + } = useWorktreeList(repoPath, projectId); function renderContent() { if (isLoading) { @@ -127,11 +118,7 @@ export function WorktreeList({ repoPath, projectId }: WorktreeListProps) { </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> - <AlertDialogAction - onClick={() => { - removeWorktree.mutate({ repoPath, worktreePath: worktree.path }); - }} - > + <AlertDialogAction onClick={() => { handleRemove(worktree.path); }}> Remove </AlertDialogAction> </AlertDialogFooter> @@ -167,7 +154,7 @@ export function WorktreeList({ repoPath, projectId }: WorktreeListProps) { id="worktree-path" placeholder="/path/to/worktree" value={worktreePath} - onChange={(e) => setWorktreePath(e.target.value)} + onChange={(e) => { setWorktreePath(e.target.value); }} onKeyDown={handleInputKeyDown} /> </div> @@ -177,25 +164,20 @@ export function WorktreeList({ repoPath, projectId }: WorktreeListProps) { id="worktree-branch" placeholder="feature/my-branch" value={branch} - onChange={(e) => setBranch(e.target.value)} + onChange={(e) => { setBranch(e.target.value); }} onKeyDown={handleInputKeyDown} /> </div> </div> <DialogFooter> - <Button - variant="outline" - onClick={() => setAddOpen(false)} - > + <Button variant="outline" onClick={() => { setAddOpen(false); }}> Cancel </Button> <Button - disabled={!worktreePath || !branch || createWorktree.isPending} - onClick={() => { - void handleCreate(); - }} + disabled={!worktreePath || !branch || isCreating} + onClick={() => { void handleCreate(); }} > - {createWorktree.isPending ? 'Adding...' : 'Add'} + {isCreating ? 'Adding...' : 'Add'} </Button> </DialogFooter> </DialogContent> diff --git a/src/renderer/features/git/components/WorktreeList/index.ts b/src/renderer/features/git/components/WorktreeList/index.ts new file mode 100644 index 00000000..60829188 --- /dev/null +++ b/src/renderer/features/git/components/WorktreeList/index.ts @@ -0,0 +1,2 @@ +export { WorktreeList } from './WorktreeList'; +export type { WorktreeListProps } from './WorktreeList'; diff --git a/src/renderer/features/git/components/WorktreeList/useWorktreeList.ts b/src/renderer/features/git/components/WorktreeList/useWorktreeList.ts new file mode 100644 index 00000000..4b05d550 --- /dev/null +++ b/src/renderer/features/git/components/WorktreeList/useWorktreeList.ts @@ -0,0 +1,46 @@ +import { useState } from 'react'; + +import { useCreateWorktree, useListWorktrees, useRemoveWorktree } from '../../api/useGit'; + +export function useWorktreeList(repoPath: string, projectId: string) { + const { data: worktrees, isLoading } = useListWorktrees(projectId); + const createWorktree = useCreateWorktree(); + const removeWorktree = useRemoveWorktree(); + + const [addOpen, setAddOpen] = useState(false); + const [worktreePath, setWorktreePath] = useState(''); + const [branch, setBranch] = useState(''); + + async function handleCreate() { + if (!worktreePath || !branch) return; + await createWorktree.mutateAsync({ repoPath, worktreePath, branch }); + setWorktreePath(''); + setBranch(''); + setAddOpen(false); + } + + function handleInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { + if (e.key === 'Enter') { + void handleCreate(); + } + } + + function handleRemove(path: string) { + removeWorktree.mutate({ repoPath, worktreePath: path }); + } + + return { + worktrees, + isLoading, + isCreating: createWorktree.isPending, + addOpen, + setAddOpen, + worktreePath, + setWorktreePath, + branch, + setBranch, + handleCreate, + handleInputKeyDown, + handleRemove, + }; +} diff --git a/src/renderer/features/git-overview/index.ts b/src/renderer/features/git/index.ts similarity index 100% rename from src/renderer/features/git-overview/index.ts rename to src/renderer/features/git/index.ts diff --git a/src/renderer/features/hub-setup/api/queryKeys.ts b/src/renderer/features/hub-setup/api/queryKeys.ts deleted file mode 100644 index 05f48216..00000000 --- a/src/renderer/features/hub-setup/api/queryKeys.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const hubSetupKeys = { - all: ['hub-setup'] as const, -}; diff --git a/src/renderer/features/hub-setup/api/useDocker.ts b/src/renderer/features/hub-setup/api/useDocker.ts deleted file mode 100644 index 059e2e0f..00000000 --- a/src/renderer/features/hub-setup/api/useDocker.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * React Query hooks for Docker auto-setup - */ - -import { useMutation, useQuery } from '@tanstack/react-query'; - -import { DOCKER } from '@shared/ipc/docker/channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -export const dockerKeys = { - all: ['docker'] as const, - status: () => [...dockerKeys.all, 'status'] as const, -}; - -/** Check if Docker Desktop is installed and running. */ -export function useDockerStatus() { - return useQuery({ - queryKey: dockerKeys.status(), - queryFn: () => ipc(DOCKER.GET.STATUS, {}), - staleTime: 30_000, - }); -} - -/** Auto-setup Hub: pull image, start container, generate API key. */ -export function useDockerSetupHub() { - return useMutation({ - mutationFn: () => ipc(DOCKER.SETUP.HUB, {}), - }); -} diff --git a/src/renderer/features/hub-setup/components/HubSetupPage.tsx b/src/renderer/features/hub-setup/components/HubSetupPage.tsx deleted file mode 100644 index 0da8bdcb..00000000 --- a/src/renderer/features/hub-setup/components/HubSetupPage.tsx +++ /dev/null @@ -1,451 +0,0 @@ -/** - * HubSetupPage — Pre-auth screen for first-time Hub configuration. - * - * Auto-detects Docker Desktop and offers one-click Hub setup. - * Falls back to manual URL + API key entry for advanced users. - */ - -import { useState } from 'react'; - -import { - ArrowRight, - CheckCircle2, - ChevronDown, - ChevronRight, - Download, - ExternalLink, - Loader2, - Server, - Sparkles, -} from 'lucide-react'; - -import { Button, Card, CardContent, InlineAlert, Input, Label } from '@ui'; - -import { useHubConnect } from '@features/settings/api/useHub'; - -import { useDockerSetupHub, useDockerStatus } from '../api/useDocker'; -import { validateHubUrl } from '../lib/validateHubUrl'; - -import type { UseMutationResult } from '@tanstack/react-query'; - -const DOCKER_DOWNLOAD_URL = 'https://www.docker.com/products/docker-desktop/'; - -// ─── Extracted sub-components ──────────────────────────────── - -function DockerLoading() { - return ( - <Card> - <CardContent className="flex items-center justify-center gap-2 p-6"> - <Loader2 className="text-muted-foreground size-4 animate-spin" /> - <span className="text-muted-foreground text-sm">Checking for Docker Desktop...</span> - </CardContent> - </Card> - ); -} - -interface DockerReadyProps { - setupMutation: UseMutationResult< - { success: boolean; url?: string; apiKey?: string; error?: string; step?: string }, - Error, - void - >; - isAutoSetupPending: boolean; - autoSetupLabel: string; - onAutoSetup: () => void; -} - -function DockerReady({ setupMutation, isAutoSetupPending, autoSetupLabel, onAutoSetup }: DockerReadyProps) { - return ( - <Card> - <CardContent className="space-y-3 p-4"> - <div className="flex items-center gap-2"> - <CheckCircle2 className="text-success size-4" /> - <span className="text-card-foreground text-sm font-medium"> - Docker Desktop detected - </span> - </div> - <p className="text-muted-foreground text-xs"> - We can automatically set up and configure the Hub server for you. - No terminal commands required. - </p> - - {setupMutation.isError ? ( - <InlineAlert variant="error"> - {setupMutation.error instanceof Error - ? setupMutation.error.message - : 'Auto setup failed'} - </InlineAlert> - ) : null} - - {setupMutation.data && !setupMutation.data.success ? ( - <InlineAlert variant="error"> - {setupMutation.data.error} - </InlineAlert> - ) : null} - - <Button - className="w-full" - disabled={isAutoSetupPending} - type="button" - onClick={onAutoSetup} - > - {isAutoSetupPending ? ( - <Loader2 className="size-4 animate-spin" /> - ) : ( - <Sparkles className="size-4" /> - )} - {autoSetupLabel} - </Button> - </CardContent> - </Card> - ); -} - -function DockerNotRunning({ onRetry }: { onRetry: () => void }) { - return ( - <Card> - <CardContent className="space-y-3 p-4"> - <div className="flex items-center gap-2"> - <Download className="text-warning size-4" /> - <span className="text-card-foreground text-sm font-medium"> - Docker Desktop is installed but not running - </span> - </div> - <p className="text-muted-foreground text-xs"> - Start Docker Desktop, then come back here. The setup button will appear - automatically. - </p> - <Button className="w-full" type="button" variant="outline" onClick={onRetry}> - Check Again - </Button> - </CardContent> - </Card> - ); -} - -function DockerNotInstalled({ onRetry }: { onRetry: () => void }) { - return ( - <Card> - <CardContent className="space-y-3 p-4"> - <div className="flex items-center gap-2"> - <Download className="text-muted-foreground size-4" /> - <span className="text-card-foreground text-sm font-medium"> - Docker Desktop required - </span> - </div> - <p className="text-muted-foreground text-xs"> - ADC Hub runs as a Docker container on your machine. Install Docker - Desktop (free), then come back here for automatic setup. - </p> - <a - className="border-border bg-background text-card-foreground hover:bg-accent hover:text-accent-foreground inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium no-underline transition-colors" - href={DOCKER_DOWNLOAD_URL} - rel="noopener noreferrer" - target="_blank" - > - <ExternalLink className="size-4" /> - Download Docker Desktop - </a> - <Button className="w-full" type="button" variant="outline" onClick={onRetry}> - I've installed it — check again - </Button> - </CardContent> - </Card> - ); -} - -// ─── Docker status resolver ────────────────────────────────── - -interface DockerSectionProps { - isLoading: boolean; - isRunning: boolean; - isInstalled: boolean; - setupMutation: DockerReadyProps['setupMutation']; - isAutoSetupPending: boolean; - autoSetupLabel: string; - onAutoSetup: () => void; - onRetry: () => void; -} - -function DockerSection({ - isLoading, - isRunning, - isInstalled, - setupMutation, - isAutoSetupPending, - autoSetupLabel, - onAutoSetup, - onRetry, -}: DockerSectionProps) { - if (isLoading) return <DockerLoading />; - if (isRunning) { - return ( - <DockerReady - autoSetupLabel={autoSetupLabel} - isAutoSetupPending={isAutoSetupPending} - setupMutation={setupMutation} - onAutoSetup={onAutoSetup} - /> - ); - } - if (isInstalled) return <DockerNotRunning onRetry={onRetry} />; - return <DockerNotInstalled onRetry={onRetry} />; -} - -// ─── Main page component ───────────────────────────────────── - -interface HubSetupPageProps { - onSuccess: () => void; - onNavigateToLogin: () => void; - onSkip?: () => void; -} - -// eslint-disable-next-line sonarjs/cognitive-complexity -- +1 from onSkip conditional render -export function HubSetupPage({ onSuccess, onNavigateToLogin, onSkip }: HubSetupPageProps) { - const [showManual, setShowManual] = useState(false); - const [hubUrl, setHubUrl] = useState(''); - const [apiKey, setApiKey] = useState(''); - const [isValidating, setIsValidating] = useState(false); - const [validationError, setValidationError] = useState<string | null>(null); - const [isGeneratingKey, setIsGeneratingKey] = useState(false); - const [generateKeyError, setGenerateKeyError] = useState<string | null>(null); - - const dockerStatus = useDockerStatus(); - const setupMutation = useDockerSetupHub(); - const connectMutation = useHubConnect(); - - const isFormValid = hubUrl.length > 0 && apiKey.length > 0; - const isManualPending = isValidating || connectMutation.isPending; - const isAutoSetupPending = setupMutation.isPending || (connectMutation.isPending && setupMutation.isSuccess); - - function handleAutoSetup() { - setupMutation.mutate(undefined, { - onSuccess: (result) => { - if (result.success && result.url && result.apiKey) { - connectMutation.mutate( - { url: result.url, apiKey: result.apiKey }, - { onSuccess }, - ); - } - }, - }); - } - - async function handleManualConnect() { - if (!isFormValid) return; - - setValidationError(null); - setIsValidating(true); - - const validation = await validateHubUrl(hubUrl); - setIsValidating(false); - - if (!validation.reachable) { - setValidationError(validation.error ?? 'Hub server is unreachable'); - return; - } - - connectMutation.mutate( - { url: hubUrl, apiKey }, - { onSuccess }, - ); - } - - async function handleGenerateKey() { - if (hubUrl.length === 0) { - setGenerateKeyError('Enter the Hub URL first'); - return; - } - setGenerateKeyError(null); - setIsGeneratingKey(true); - try { - const cleanUrl = hubUrl.replace(/\/+$/, ''); - const response = await fetch(`${cleanUrl}/api/auth/generate-key`, { method: 'POST' }); - if (!response.ok) { - const body = await response.text(); - setGenerateKeyError(`Failed (${String(response.status)}): ${body}`); - return; - } - const data = (await response.json()) as { key: string }; - setApiKey(data.key); - } catch (error) { - setGenerateKeyError(error instanceof Error ? error.message : 'Failed to reach Hub server'); - } finally { - setIsGeneratingKey(false); - } - } - - function getAutoSetupLabel(): string { - if (setupMutation.isPending) return 'Setting up Hub...'; - if (connectMutation.isPending && setupMutation.isSuccess) return 'Connecting...'; - return 'Set Up Hub Automatically'; - } - - function getManualButtonLabel(): string { - if (isValidating) return 'Checking connection...'; - if (connectMutation.isPending && !setupMutation.isSuccess) return 'Connecting...'; - return 'Connect'; - } - - const showManualConnectError = - connectMutation.isError && validationError === null && !setupMutation.isSuccess; - - return ( - <div className="flex min-h-screen items-center justify-center bg-background"> - <Card className="w-full max-w-lg shadow-lg"> - <CardContent className="space-y-6 p-8"> - {/* Header */} - <div className="space-y-2 text-center"> - <div className="mx-auto mb-2 flex size-12 items-center justify-center rounded-lg bg-primary/10"> - <Server className="text-primary size-6" /> - </div> - <h1 className="text-card-foreground text-2xl font-bold">Welcome to ADC</h1> - <p className="text-muted-foreground text-sm"> - ADC Hub is your personal server that syncs tasks, projects, and agent - activity across devices. Let's get it running. - </p> - </div> - - {/* Docker auto-setup section */} - <DockerSection - autoSetupLabel={getAutoSetupLabel()} - isAutoSetupPending={isAutoSetupPending} - isInstalled={dockerStatus.data?.installed === true} - isLoading={dockerStatus.isLoading} - isRunning={dockerStatus.data?.running === true} - setupMutation={setupMutation} - onAutoSetup={handleAutoSetup} - onRetry={() => { void dockerStatus.refetch(); }} - /> - - {/* Manual connection — collapsible for advanced users */} - <div className="border-border border-t pt-4"> - <Button - className="text-muted-foreground hover:text-card-foreground w-full justify-start gap-2" - type="button" - variant="ghost" - onClick={() => { setShowManual(!showManual); }} - > - {showManual ? ( - <ChevronDown className="size-4" /> - ) : ( - <ChevronRight className="size-4" /> - )} - I have my own Hub server - </Button> - - {showManual ? ( - <form - className="mt-4 space-y-4" - onSubmit={(e) => { - e.preventDefault(); - void handleManualConnect(); - }} - > - <div className="space-y-2"> - <Label htmlFor="setup-hub-url">Hub URL</Label> - <Input - autoComplete="url" - id="setup-hub-url" - placeholder="http://localhost:3200" - type="url" - value={hubUrl} - onChange={(e) => { setHubUrl(e.target.value); }} - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="setup-api-key">API Key</Label> - <div className="flex gap-2"> - <Input - autoComplete="off" - className="flex-1" - id="setup-api-key" - placeholder="Your Hub API key" - type="password" - value={apiKey} - onChange={(e) => { setApiKey(e.target.value); }} - /> - <Button - disabled={hubUrl.length === 0 || isGeneratingKey} - size="sm" - type="button" - variant="outline" - onClick={() => { void handleGenerateKey(); }} - > - {isGeneratingKey ? ( - <Loader2 className="size-4 animate-spin" /> - ) : ( - <Sparkles className="size-4" /> - )} - {isGeneratingKey ? 'Generating...' : 'Generate'} - </Button> - </div> - {generateKeyError === null ? null : ( - <InlineAlert variant="error"> - {generateKeyError} - </InlineAlert> - )} - </div> - - {validationError === null ? null : ( - <InlineAlert variant="error"> - Hub unreachable: {validationError} - </InlineAlert> - )} - - {showManualConnectError ? ( - <InlineAlert variant="error"> - {connectMutation.error instanceof Error - ? connectMutation.error.message - : 'Connection failed. Check your URL and API key.'} - </InlineAlert> - ) : null} - - <Button - className="w-full" - disabled={!isFormValid || isManualPending} - type="submit" - > - {isManualPending ? ( - <Loader2 className="size-4 animate-spin" /> - ) : ( - <ArrowRight className="size-4" /> - )} - {getManualButtonLabel()} - </Button> - </form> - ) : null} - </div> - - {/* Footer links */} - <div className="space-y-2 text-center"> - <p className="text-muted-foreground text-sm"> - Already connected?{' '} - <Button - className="h-auto p-0 font-medium underline-offset-4 hover:underline" - type="button" - variant="link" - onClick={onNavigateToLogin} - > - Sign in - </Button> - </p> - {onSkip ? ( - <p className="text-muted-foreground text-sm"> - <Button - className="h-auto p-0 font-medium underline-offset-4 hover:underline" - type="button" - variant="link" - onClick={onSkip} - > - Skip — use locally without Hub - </Button> - </p> - ) : null} - </div> - </CardContent> - </Card> - </div> - ); -} diff --git a/src/renderer/features/hub-setup/index.ts b/src/renderer/features/hub-setup/index.ts deleted file mode 100644 index 64081d64..00000000 --- a/src/renderer/features/hub-setup/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Hub Setup feature — public API - */ - -export { HubSetupPage } from './components/HubSetupPage'; -export { validateHubUrl } from './lib/validateHubUrl'; diff --git a/src/renderer/features/hub-setup/lib/validateHubUrl.ts b/src/renderer/features/hub-setup/lib/validateHubUrl.ts deleted file mode 100644 index 7a561747..00000000 --- a/src/renderer/features/hub-setup/lib/validateHubUrl.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Validate a Hub URL by pinging its health endpoint. - * - * Shared between HubSetupPage (pre-auth) and HubSettings (in-app). - */ - -export async function validateHubUrl(url: string): Promise<{ reachable: boolean; error?: string }> { - const trimmedUrl = url.replace(/\/+$/, ''); - const healthUrl = `${trimmedUrl}/api/health`; - - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, 5000); - - const response = await fetch(healthUrl, { - method: 'GET', - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - return { reachable: false, error: `Server returned ${String(response.status)}` }; - } - - return { reachable: true }; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return { reachable: false, error: 'Connection timed out' }; - } - const message = error instanceof Error ? error.message : 'Network error'; - return { reachable: false, error: message }; - } -} diff --git a/src/renderer/features/hub-setup/store.ts b/src/renderer/features/hub-setup/store.ts deleted file mode 100644 index 60aa1323..00000000 --- a/src/renderer/features/hub-setup/store.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { create } from 'zustand'; - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Scaffold placeholder -interface HubSetupState {} - -export const useHubSetupStore = create<HubSetupState>()(() => ({})); diff --git a/src/renderer/features/ideation/api/queryKeys.ts b/src/renderer/features/ideas/api/queryKeys.ts similarity index 100% rename from src/renderer/features/ideation/api/queryKeys.ts rename to src/renderer/features/ideas/api/queryKeys.ts diff --git a/src/renderer/features/ideation/api/useIdeas.ts b/src/renderer/features/ideas/api/useIdeas.ts similarity index 97% rename from src/renderer/features/ideation/api/useIdeas.ts rename to src/renderer/features/ideas/api/useIdeas.ts index 621fe72b..dc9aa3f1 100644 --- a/src/renderer/features/ideation/api/useIdeas.ts +++ b/src/renderer/features/ideas/api/useIdeas.ts @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { IDEAS } from '@shared/ipc/misc/ideas.channels'; +import { IDEAS } from '@shared/ipc/ideas'; import type { IdeaCategory, IdeaStatus } from '@shared/types'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/ideation/components/IdeaCard.tsx b/src/renderer/features/ideas/components/IdeaCard.tsx similarity index 100% rename from src/renderer/features/ideation/components/IdeaCard.tsx rename to src/renderer/features/ideas/components/IdeaCard.tsx diff --git a/src/renderer/features/ideas/components/IdeaEditForm/IdeaEditForm.tsx b/src/renderer/features/ideas/components/IdeaEditForm/IdeaEditForm.tsx new file mode 100644 index 00000000..f6b55fd3 --- /dev/null +++ b/src/renderer/features/ideas/components/IdeaEditForm/IdeaEditForm.tsx @@ -0,0 +1,231 @@ +/** + * IdeaEditForm — Modal dialog for editing an existing idea. + */ + +import { Pencil, X } from 'lucide-react'; + +import type { Idea, IdeaCategory, IdeaStatus } from '@shared/types'; + +import { + Badge, + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + InlineAlert, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, + Textarea, +} from '@ui'; + +import { useIdeaEditForm } from './useIdeaEditForm'; + +const CATEGORY_OPTIONS: readonly IdeaCategory[] = ['feature', 'improvement', 'bug', 'performance']; +const STATUS_OPTIONS: readonly IdeaStatus[] = ['new', 'exploring', 'accepted', 'rejected', 'implemented']; +const CATEGORY_LABELS: Record<IdeaCategory, string> = { + feature: 'Feature', + improvement: 'Improvement', + bug: 'Bug', + performance: 'Performance', +}; +const STATUS_LABELS: Record<IdeaStatus, string> = { + new: 'New', + exploring: 'Exploring', + accepted: 'Accepted', + rejected: 'Rejected', + implemented: 'Implemented', +}; + +interface IdeaEditFormProps { + idea: Idea | null; + onClose: () => void; +} + +export function IdeaEditForm({ idea, onClose }: IdeaEditFormProps) { + const { + title, + setTitle, + description, + setDescription, + category, + setCategory, + status, + setStatus, + tags, + tagInput, + setTagInput, + errorMessage, + tagInputRef, + titleIsEmpty, + isSaving, + addTag, + removeTag, + handleTagKeyDown, + handleSave, + } = useIdeaEditForm(idea, onClose); + + return ( + <Dialog open={idea !== null} onOpenChange={(open) => { if (!open) onClose(); }}> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle className="flex items-center gap-3"> + <Pencil className="text-primary h-5 w-5" /> + Edit Idea + </DialogTitle> + </DialogHeader> + + <div className="space-y-4"> + {/* Title */} + <div className="space-y-1.5"> + <Label htmlFor="edit-idea-title"> + Title <span className="text-destructive">*</span> + </Label> + <Input + aria-required="true" + id="edit-idea-title" + placeholder="Idea title" + type="text" + value={title} + onChange={(e) => { setTitle(e.target.value); }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !titleIsEmpty && !isSaving) { + handleSave(); + } + }} + /> + </div> + + {/* Description */} + <div className="space-y-1.5"> + <Label htmlFor="edit-idea-description">Description</Label> + <Textarea + id="edit-idea-description" + placeholder="Describe the idea..." + resize="none" + rows={3} + value={description} + onChange={(e) => { setDescription(e.target.value); }} + /> + </div> + + {/* Category */} + <div className="space-y-1.5"> + <Label htmlFor="edit-idea-category">Category</Label> + <Select + value={category} + onValueChange={(v) => { setCategory(v as IdeaCategory); }} + > + <SelectTrigger id="edit-idea-category"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {CATEGORY_OPTIONS.map((cat) => ( + <SelectItem key={cat} value={cat}> + {CATEGORY_LABELS[cat]} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* Status */} + <div className="space-y-1.5"> + <Label htmlFor="edit-idea-status">Status</Label> + <Select + value={status} + onValueChange={(v) => { setStatus(v as IdeaStatus); }} + > + <SelectTrigger id="edit-idea-status"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {STATUS_OPTIONS.map((s) => ( + <SelectItem key={s} value={s}> + {STATUS_LABELS[s]} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* Tags */} + <div className="space-y-1.5"> + <Label htmlFor="edit-idea-tags">Tags</Label> + <div + aria-label="Tag chips" + className="border-input bg-background focus-within:ring-ring flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border px-3 py-1.5 focus-within:ring-2" + role="group" + > + {tags.map((tag) => ( + <span key={tag} className="flex items-center gap-0.5"> + <Badge variant="secondary">{tag}</Badge> + <Button + aria-label={`Remove tag ${tag}`} + className="text-muted-foreground hover:text-foreground ml-0.5 h-auto rounded-full p-0.5" + size="icon" + type="button" + variant="ghost" + onClick={(e) => { + e.stopPropagation(); + removeTag(tag); + }} + > + <X className="h-2.5 w-2.5" /> + </Button> + </span> + ))} + <Input + ref={tagInputRef} + aria-label="Add a tag" + className="min-w-20 flex-1 border-0 bg-transparent p-0 shadow-none outline-none focus-visible:ring-0" + id="edit-idea-tags" + placeholder={tags.length > 0 ? '' : 'Add tags (Enter or comma to confirm)'} + type="text" + value={tagInput} + onChange={(e) => { setTagInput(e.target.value); }} + onKeyDown={handleTagKeyDown} + onBlur={() => { + if (tagInput.trim().length > 0) addTag(tagInput); + }} + /> + </div> + </div> + + {errorMessage === null ? null : ( + <InlineAlert variant="error"> + {errorMessage} + </InlineAlert> + )} + </div> + + <DialogFooter> + <Button type="button" variant="ghost" onClick={onClose}> + Cancel + </Button> + <Button + disabled={titleIsEmpty || isSaving} + type="button" + onClick={handleSave} + > + {isSaving ? ( + <> + <Spinner className="h-4 w-4" /> + Saving... + </> + ) : ( + 'Save' + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/ideas/components/IdeaEditForm/index.ts b/src/renderer/features/ideas/components/IdeaEditForm/index.ts new file mode 100644 index 00000000..18f30b09 --- /dev/null +++ b/src/renderer/features/ideas/components/IdeaEditForm/index.ts @@ -0,0 +1 @@ +export { IdeaEditForm } from './IdeaEditForm'; diff --git a/src/renderer/features/ideas/components/IdeaEditForm/useIdeaEditForm.ts b/src/renderer/features/ideas/components/IdeaEditForm/useIdeaEditForm.ts new file mode 100644 index 00000000..67200a8e --- /dev/null +++ b/src/renderer/features/ideas/components/IdeaEditForm/useIdeaEditForm.ts @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { Idea, IdeaCategory, IdeaStatus } from '@shared/types'; + +import { useUpdateIdea } from '../../api/useIdeas'; + +export function useIdeaEditForm(idea: Idea | null, onClose: () => void) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [category, setCategory] = useState<IdeaCategory>('feature'); + const [status, setStatus] = useState<IdeaStatus>('new'); + const [tags, setTags] = useState<string[]>([]); + const [tagInput, setTagInput] = useState(''); + const [errorMessage, setErrorMessage] = useState<string | null>(null); + const tagInputRef = useRef<HTMLInputElement>(null); + + const updateIdea = useUpdateIdea(); + + useEffect(() => { + if (idea !== null) { + setTitle(idea.title); + setDescription(idea.description); + setCategory(idea.category); + setStatus(idea.status); + setTags(idea.tags); + setTagInput(''); + setErrorMessage(null); + } + }, [idea]); + + function addTag(raw: string): void { + const trimmed = raw.trim().toLowerCase(); + if (trimmed.length === 0) return; + if (tags.includes(trimmed)) { + setTagInput(''); + return; + } + setTags([...tags, trimmed]); + setTagInput(''); + } + + function removeTag(tag: string): void { + setTags(tags.filter((t) => t !== tag)); + } + + function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>): void { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + addTag(tagInput); + } else if (e.key === 'Backspace' && tagInput === '' && tags.length > 0) { + setTags(tags.slice(0, -1)); + } + } + + const titleIsEmpty = title.trim().length === 0; + + function handleSave(): void { + if (idea === null || titleIsEmpty) return; + + setErrorMessage(null); + updateIdea.mutate( + { + id: idea.id, + title: title.trim(), + description: description.trim(), + category, + status, + tags, + }, + { + onSuccess: () => { + onClose(); + }, + onError: (error) => { + setErrorMessage(error instanceof Error ? error.message : 'Failed to update idea'); + }, + }, + ); + } + + return { + title, + setTitle, + description, + setDescription, + category, + setCategory, + status, + setStatus, + tags, + tagInput, + setTagInput, + errorMessage, + tagInputRef, + titleIsEmpty, + isSaving: updateIdea.isPending, + addTag, + removeTag, + handleTagKeyDown, + handleSave, + }; +} diff --git a/src/renderer/features/ideation/components/IdeationFilterRow.tsx b/src/renderer/features/ideas/components/IdeasFilterRow.tsx similarity index 100% rename from src/renderer/features/ideation/components/IdeationFilterRow.tsx rename to src/renderer/features/ideas/components/IdeasFilterRow.tsx diff --git a/src/renderer/features/ideas/components/IdeasPage/IdeasPage.tsx b/src/renderer/features/ideas/components/IdeasPage/IdeasPage.tsx new file mode 100644 index 00000000..d385bed1 --- /dev/null +++ b/src/renderer/features/ideas/components/IdeasPage/IdeasPage.tsx @@ -0,0 +1,236 @@ +import { Lightbulb, Plus, Sparkles } from 'lucide-react'; + +import type { IdeaCategory } from '@shared/types'; + +import { + Button, + Card, + CardContent, + EmptyState, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, + Textarea, +} from '@ui'; + +import { IdeaCard } from '../IdeaCard'; +import { IdeaEditForm } from '../IdeaEditForm'; +import { IdeationFilterRow } from '../IdeasFilterRow'; + +import { useIdeasPage } from './useIdeasPage'; + +const CATEGORY_OPTIONS: IdeaCategory[] = ['feature', 'improvement', 'bug', 'performance']; + +const CATEGORY_LABELS: Record<IdeaCategory, string> = { + feature: 'Feature', + improvement: 'Improvement', + bug: 'Bug', + performance: 'Performance', +}; + +export function IdeationPage() { + const { + items, + allTags, + isLoading, + activeFilter, + setActiveFilter, + searchQuery, + setSearchQuery, + selectedTags, + showForm, + setShowForm, + formTitle, + setFormTitle, + formDescription, + setFormDescription, + formCategory, + setFormCategory, + editingIdea, + setEditingIdea, + showGenerate, + setShowGenerate, + generatePrompt, + setGeneratePrompt, + isGenerating, + handleTagToggle, + handleClearTags, + handleCreate, + handleGenerate, + onDelete, + onVote, + } = useIdeasPage(); + + return ( + <div className="space-y-6 p-6"> + {/* Actions */} + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + onClick={() => { + setShowGenerate(!showGenerate); + setShowForm(false); + }} + > + <Sparkles className="h-4 w-4" /> + Generate with AI + </Button> + <Button + type="button" + onClick={() => { + setShowForm(!showForm); + setShowGenerate(false); + }} + > + <Plus className="h-4 w-4" /> + New Idea + </Button> + </div> + + {/* Generate with AI Panel */} + {showGenerate ? ( + <Card className="mb-6"> + <CardContent className="space-y-3 p-4"> + <p className="text-muted-foreground text-sm"> + Describe what ideas you want the assistant to generate. It will create them directly in your ideation board. + </p> + <Textarea + resize="none" + rows={3} + value={generatePrompt} + onChange={(e) => { setGeneratePrompt(e.target.value); }} + onKeyDown={(e) => { + if (e.key === 'Enter' && e.metaKey) handleGenerate(); + }} + /> + <div className="flex gap-2"> + <Button + disabled={isGenerating || !generatePrompt.trim()} + type="button" + onClick={handleGenerate} + > + {isGenerating ? ( + <> + <Spinner className="h-4 w-4" /> + Generating... + </> + ) : ( + <> + <Sparkles className="h-4 w-4" /> + Generate + </> + )} + </Button> + <Button + type="button" + variant="outline" + onClick={() => { setShowGenerate(false); }} + > + Cancel + </Button> + </div> + </CardContent> + </Card> + ) : null} + + {/* Create Form */} + {showForm ? ( + <Card className="mb-6"> + <CardContent className="space-y-3 p-4"> + <Input + placeholder="Idea title" + type="text" + value={formTitle} + onChange={(e) => { setFormTitle(e.target.value); }} + /> + <Textarea + placeholder="Description" + resize="none" + rows={2} + value={formDescription} + onChange={(e) => { setFormDescription(e.target.value); }} + /> + <Select + value={formCategory} + onValueChange={(v) => { setFormCategory(v as IdeaCategory); }} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {CATEGORY_OPTIONS.map((cat) => ( + <SelectItem key={cat} value={cat}> + {CATEGORY_LABELS[cat]} + </SelectItem> + ))} + </SelectContent> + </Select> + <div className="flex gap-2"> + <Button type="button" onClick={handleCreate}> + Create + </Button> + <Button + type="button" + variant="outline" + onClick={() => { setShowForm(false); }} + > + Cancel + </Button> + </div> + </CardContent> + </Card> + ) : null} + + {/* Filters */} + <div className="mb-6"> + <IdeationFilterRow + activeFilter={activeFilter} + allTags={allTags} + searchQuery={searchQuery} + selectedTags={selectedTags} + onClearTags={handleClearTags} + onFilterChange={setActiveFilter} + onSearchChange={setSearchQuery} + onTagToggle={handleTagToggle} + /> + </div> + + {/* Ideas Grid */} + {isLoading ? ( + <div className="text-muted-foreground flex items-center justify-center py-12"> + Loading ideas... + </div> + ) : null} + + {!isLoading && items.length > 0 ? ( + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> + {items.map((idea) => ( + <IdeaCard + key={idea.id} + idea={idea} + onDelete={onDelete} + onEdit={setEditingIdea} + onVote={onVote} + /> + ))} + </div> + ) : null} + + {!isLoading && items.length === 0 ? ( + <EmptyState + description="Try a different filter or add a new idea" + icon={Lightbulb} + title="No ideas in this category" + /> + ) : null} + + {/* Edit dialog */} + <IdeaEditForm idea={editingIdea} onClose={() => { setEditingIdea(null); }} /> + </div> + ); +} diff --git a/src/renderer/features/ideas/components/IdeasPage/index.ts b/src/renderer/features/ideas/components/IdeasPage/index.ts new file mode 100644 index 00000000..957c3289 --- /dev/null +++ b/src/renderer/features/ideas/components/IdeasPage/index.ts @@ -0,0 +1 @@ +export { IdeationPage } from './IdeasPage'; diff --git a/src/renderer/features/ideas/components/IdeasPage/useIdeasPage.ts b/src/renderer/features/ideas/components/IdeasPage/useIdeasPage.ts new file mode 100644 index 00000000..1d28279c --- /dev/null +++ b/src/renderer/features/ideas/components/IdeasPage/useIdeasPage.ts @@ -0,0 +1,135 @@ +import { useMemo, useState } from 'react'; + +import type { Idea, IdeaCategory } from '@shared/types'; + +import { useAssistantWidgetStore, useLayoutStore } from '@renderer/shared/stores'; + +import { useSendCommand } from '@features/assistant'; + +import { useCreateIdea, useDeleteIdea, useIdeas, useVoteIdea } from '../../api/useIdeas'; +import { useIdeaEvents } from '../../hooks/useIdeaEvents'; + +const DEFAULT_GENERATE_PROMPT = + 'Suggest 3 new feature ideas for my developer productivity project. For each, call the create_idea tool with a clear title and description.'; + +export function useIdeasPage() { + useIdeaEvents(); + + const [activeFilter, setActiveFilter] = useState<IdeaCategory | 'all'>('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedTags, setSelectedTags] = useState<string[]>([]); + const [showForm, setShowForm] = useState(false); + const [formTitle, setFormTitle] = useState(''); + const [formDescription, setFormDescription] = useState(''); + const [formCategory, setFormCategory] = useState<IdeaCategory>('feature'); + const [editingIdea, setEditingIdea] = useState<Idea | null>(null); + const [showGenerate, setShowGenerate] = useState(false); + const [generatePrompt, setGeneratePrompt] = useState(DEFAULT_GENERATE_PROMPT); + + const category = activeFilter === 'all' ? undefined : activeFilter; + const { data: ideas, isLoading } = useIdeas(undefined, undefined, category); + const createIdea = useCreateIdea(); + const deleteIdea = useDeleteIdea(); + const voteIdea = useVoteIdea(); + const sendCommand = useSendCommand(); + const openWidget = useAssistantWidgetStore((s) => s.open); + const activeProjectId = useLayoutStore((s) => s.activeProjectId); + + const allItems = useMemo(() => ideas ?? [], [ideas]); + + const allTags = useMemo(() => { + const tagSet = new Set<string>(); + for (const idea of allItems) { + for (const tag of idea.tags) { + tagSet.add(tag); + } + } + return Array.from(tagSet).sort(); + }, [allItems]); + + const items = useMemo(() => { + const hasTagFilters = selectedTags.length > 0; + const query = searchQuery.trim().toLowerCase(); + const hasSearch = query.length > 0; + + return allItems.filter((idea) => { + if (hasTagFilters && !selectedTags.some((tag) => idea.tags.includes(tag))) { + return false; + } + if ( + hasSearch && + !idea.title.toLowerCase().includes(query) && + !idea.description.toLowerCase().includes(query) + ) { + return false; + } + return true; + }); + }, [allItems, selectedTags, searchQuery]); + + function handleTagToggle(tag: string): void { + setSelectedTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], + ); + } + + function handleClearTags(): void { + setSelectedTags([]); + } + + function handleCreate(): void { + if (!formTitle.trim()) return; + createIdea.mutate({ + title: formTitle.trim(), + description: formDescription.trim(), + category: formCategory, + }); + setFormTitle(''); + setFormDescription(''); + setFormCategory('feature'); + setShowForm(false); + } + + function handleGenerate(): void { + if (!generatePrompt.trim() || sendCommand.isPending) return; + openWidget(); + sendCommand.mutate({ + input: generatePrompt.trim(), + context: { activeView: 'ideation', activeProjectId: activeProjectId ?? undefined }, + }); + setShowGenerate(false); + setGeneratePrompt(DEFAULT_GENERATE_PROMPT); + } + + return { + items, + allTags, + isLoading, + activeFilter, + setActiveFilter, + searchQuery, + setSearchQuery, + selectedTags, + showForm, + setShowForm, + formTitle, + setFormTitle, + formDescription, + setFormDescription, + formCategory, + setFormCategory, + editingIdea, + setEditingIdea, + showGenerate, + setShowGenerate, + generatePrompt, + setGeneratePrompt, + isGenerating: sendCommand.isPending, + handleTagToggle, + handleClearTags, + handleCreate, + handleGenerate, + onDelete: (id: string) => { deleteIdea.mutate(id); }, + onVote: (id: string, delta: number) => { voteIdea.mutate({ id, delta }); }, + }; +} diff --git a/src/renderer/features/ideation/hooks/useIdeaEvents.ts b/src/renderer/features/ideas/hooks/useIdeaEvents.ts similarity index 86% rename from src/renderer/features/ideation/hooks/useIdeaEvents.ts rename to src/renderer/features/ideas/hooks/useIdeaEvents.ts index 967f40b1..5c3c6544 100644 --- a/src/renderer/features/ideation/hooks/useIdeaEvents.ts +++ b/src/renderer/features/ideas/hooks/useIdeaEvents.ts @@ -4,7 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'; -import { IDEAS_EVENTS } from '@shared/ipc/misc/ideas.channels'; +import { IDEAS_EVENTS } from '@shared/ipc/ideas'; import { useIpcEvent } from '@renderer/shared/hooks'; diff --git a/src/renderer/features/ideas/index.ts b/src/renderer/features/ideas/index.ts new file mode 100644 index 00000000..318c417a --- /dev/null +++ b/src/renderer/features/ideas/index.ts @@ -0,0 +1,12 @@ +/** + * Ideation feature — idea board with voting and filtering + */ + +export { IdeaCard } from './components/IdeaCard'; +export { IdeaEditForm } from './components/IdeaEditForm'; +export { IdeationFilterRow } from './components/IdeasFilterRow'; +export { IdeationPage } from './components/IdeasPage'; +export { useIdeas } from './api/useIdeas'; +export { ideaKeys } from './api/queryKeys'; +export { useIdeaEvents } from './hooks/useIdeaEvents'; +export { useIdeationUI } from './store'; diff --git a/src/renderer/features/ideation/store.ts b/src/renderer/features/ideas/store.ts similarity index 100% rename from src/renderer/features/ideation/store.ts rename to src/renderer/features/ideas/store.ts diff --git a/src/renderer/features/ideation/components/IdeaEditForm.tsx b/src/renderer/features/ideation/components/IdeaEditForm.tsx deleted file mode 100644 index 2db84062..00000000 --- a/src/renderer/features/ideation/components/IdeaEditForm.tsx +++ /dev/null @@ -1,277 +0,0 @@ -/** - * IdeaEditForm — Modal dialog for editing an existing idea. - */ - -import { useEffect, useRef, useState } from 'react'; - -import { Pencil, X } from 'lucide-react'; - -import type { Idea, IdeaCategory, IdeaStatus } from '@shared/types'; - -import { - Badge, - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - InlineAlert, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Spinner, - Textarea, -} from '@ui'; - -import { useUpdateIdea } from '../api/useIdeas'; - -const CATEGORY_OPTIONS: readonly IdeaCategory[] = ['feature', 'improvement', 'bug', 'performance']; -const STATUS_OPTIONS: readonly IdeaStatus[] = ['new', 'exploring', 'accepted', 'rejected', 'implemented']; -const CATEGORY_LABELS: Record<IdeaCategory, string> = { feature: 'Feature', improvement: 'Improvement', bug: 'Bug', performance: 'Performance' }; -const STATUS_LABELS: Record<IdeaStatus, string> = { new: 'New', exploring: 'Exploring', accepted: 'Accepted', rejected: 'Rejected', implemented: 'Implemented' }; - -interface IdeaEditFormProps { - idea: Idea | null; - onClose: () => void; -} - -export function IdeaEditForm({ idea, onClose }: IdeaEditFormProps) { - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [category, setCategory] = useState<IdeaCategory>('feature'); - const [status, setStatus] = useState<IdeaStatus>('new'); - const [tags, setTags] = useState<string[]>([]); - const [tagInput, setTagInput] = useState(''); - const [errorMessage, setErrorMessage] = useState<string | null>(null); - const tagInputRef = useRef<HTMLInputElement>(null); - - const updateIdea = useUpdateIdea(); - - // Initialize form state when idea changes - useEffect(() => { - if (idea !== null) { - setTitle(idea.title); - setDescription(idea.description); - setCategory(idea.category); - setStatus(idea.status); - setTags(idea.tags); - setTagInput(''); - setErrorMessage(null); - } - }, [idea]); - - function addTag(raw: string): void { - const trimmed = raw.trim().toLowerCase(); - if (trimmed.length === 0) return; - if (tags.includes(trimmed)) { - setTagInput(''); - return; - } - setTags([...tags, trimmed]); - setTagInput(''); - } - - function removeTag(tag: string): void { - setTags(tags.filter((t) => t !== tag)); - } - - function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>): void { - if (e.key === 'Enter' || e.key === ',') { - e.preventDefault(); - addTag(tagInput); - } else if (e.key === 'Backspace' && tagInput === '' && tags.length > 0) { - setTags(tags.slice(0, -1)); - } - } - - const titleIsEmpty = title.trim().length === 0; - function handleSave() { - if (idea === null || titleIsEmpty) { - return; - } - - setErrorMessage(null); - - updateIdea.mutate( - { - id: idea.id, - title: title.trim(), - description: description.trim(), - category, - status, - tags, - }, - { - onSuccess: () => { - onClose(); - }, - onError: (error) => { - setErrorMessage(error instanceof Error ? error.message : 'Failed to update idea'); - }, - }, - ); - } - - return ( - <Dialog open={idea !== null} onOpenChange={(open) => { if (!open) onClose(); }}> - <DialogContent className="max-w-lg"> - <DialogHeader> - <DialogTitle className="flex items-center gap-3"> - <Pencil className="text-primary h-5 w-5" /> - Edit Idea - </DialogTitle> - </DialogHeader> - - <div className="space-y-4"> - {/* Title */} - <div className="space-y-1.5"> - <Label htmlFor="edit-idea-title"> - Title <span className="text-destructive">*</span> - </Label> - <Input - aria-required="true" - id="edit-idea-title" - placeholder="Idea title" - type="text" - value={title} - onChange={(e) => setTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !titleIsEmpty && !updateIdea.isPending) { - handleSave(); - } - }} - /> - </div> - - {/* Description */} - <div className="space-y-1.5"> - <Label htmlFor="edit-idea-description">Description</Label> - <Textarea - id="edit-idea-description" - placeholder="Describe the idea..." - resize="none" - rows={3} - value={description} - onChange={(e) => setDescription(e.target.value)} - /> - </div> - - {/* Category */} - <div className="space-y-1.5"> - <Label htmlFor="edit-idea-category">Category</Label> - <Select - value={category} - onValueChange={(v) => setCategory(v as IdeaCategory)} - > - <SelectTrigger id="edit-idea-category"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {CATEGORY_OPTIONS.map((cat) => ( - <SelectItem key={cat} value={cat}> - {CATEGORY_LABELS[cat]} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - {/* Status */} - <div className="space-y-1.5"> - <Label htmlFor="edit-idea-status">Status</Label> - <Select - value={status} - onValueChange={(v) => setStatus(v as IdeaStatus)} - > - <SelectTrigger id="edit-idea-status"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {STATUS_OPTIONS.map((s) => ( - <SelectItem key={s} value={s}> - {STATUS_LABELS[s]} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - {/* Tags */} - <div className="space-y-1.5"> - <Label htmlFor="edit-idea-tags">Tags</Label> - <div - aria-label="Tag chips" - className="border-input bg-background focus-within:ring-ring flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border px-3 py-1.5 focus-within:ring-2" - role="group" - > - {tags.map((tag) => ( - <span key={tag} className="flex items-center gap-0.5"> - <Badge variant="secondary">{tag}</Badge> - <Button - aria-label={`Remove tag ${tag}`} - className="text-muted-foreground hover:text-foreground ml-0.5 h-auto rounded-full p-0.5" - size="icon" - type="button" - variant="ghost" - onClick={(e) => { - e.stopPropagation(); - removeTag(tag); - }} - > - <X className="h-2.5 w-2.5" /> - </Button> - </span> - ))} - <Input - ref={tagInputRef} - aria-label="Add a tag" - className="min-w-20 flex-1 border-0 bg-transparent p-0 shadow-none outline-none focus-visible:ring-0" - id="edit-idea-tags" - placeholder={(tags.length > 0) ? '' : 'Add tags (Enter or comma to confirm)'} - type="text" - value={tagInput} - onChange={(e) => setTagInput(e.target.value)} - onKeyDown={handleTagKeyDown} - onBlur={() => { - if (tagInput.trim().length > 0) addTag(tagInput); - }} - /> - </div> - </div> - - {/* Error message */} - {errorMessage === null ? null : ( - <InlineAlert variant="error"> - {errorMessage} - </InlineAlert> - )} - </div> - - <DialogFooter> - <Button type="button" variant="ghost" onClick={onClose}> - Cancel - </Button> - <Button - disabled={titleIsEmpty || updateIdea.isPending} - type="button" - onClick={handleSave} - > - {updateIdea.isPending ? ( - <> - <Spinner className="h-4 w-4" /> - Saving... - </> - ) : ( - 'Save' - )} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/ideation/components/IdeationPage.tsx b/src/renderer/features/ideation/components/IdeationPage.tsx deleted file mode 100644 index 5ad24919..00000000 --- a/src/renderer/features/ideation/components/IdeationPage.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { Lightbulb, Plus, Sparkles } from 'lucide-react'; - -import type { Idea, IdeaCategory } from '@shared/types'; - -import { useAssistantWidgetStore, useLayoutStore } from '@renderer/shared/stores'; - -import { - Button, - Card, - CardContent, - EmptyState, - Input, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Spinner, - Textarea, -} from '@ui'; - -import { useSendCommand } from '@features/assistant'; - - -import { useCreateIdea, useDeleteIdea, useIdeas, useVoteIdea } from '../api/useIdeas'; -import { useIdeaEvents } from '../hooks/useIdeaEvents'; - -import { IdeaCard } from './IdeaCard'; -import { IdeaEditForm } from './IdeaEditForm'; -import { IdeationFilterRow } from './IdeationFilterRow'; - -const CATEGORY_OPTIONS: IdeaCategory[] = ['feature', 'improvement', 'bug', 'performance']; - -const CATEGORY_LABELS: Record<IdeaCategory, string> = { - feature: 'Feature', - improvement: 'Improvement', - bug: 'Bug', - performance: 'Performance', -}; - -const DEFAULT_GENERATE_PROMPT = - 'Suggest 3 new feature ideas for my developer productivity project. For each, call the create_idea tool with a clear title and description.'; - -export function IdeationPage() { - useIdeaEvents(); - const [activeFilter, setActiveFilter] = useState<IdeaCategory | 'all'>('all'); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedTags, setSelectedTags] = useState<string[]>([]); - const [showForm, setShowForm] = useState(false); - const [formTitle, setFormTitle] = useState(''); - const [formDescription, setFormDescription] = useState(''); - const [formCategory, setFormCategory] = useState<IdeaCategory>('feature'); - const [editingIdea, setEditingIdea] = useState<Idea | null>(null); - const [showGenerate, setShowGenerate] = useState(false); - const [generatePrompt, setGeneratePrompt] = useState(DEFAULT_GENERATE_PROMPT); - - const category = activeFilter === 'all' ? undefined : activeFilter; - const { data: ideas, isLoading } = useIdeas(undefined, undefined, category); - const createIdea = useCreateIdea(); - const deleteIdea = useDeleteIdea(); - const voteIdea = useVoteIdea(); - const sendCommand = useSendCommand(); - const openWidget = useAssistantWidgetStore((s) => s.open); - const activeProjectId = useLayoutStore((s) => s.activeProjectId); - - const allItems = useMemo(() => ideas ?? [], [ideas]); - - // Collect all unique tags from all ideas for the tag filter - const allTags = useMemo(() => { - const tagSet = new Set<string>(); - for (const idea of allItems) { - for (const tag of idea.tags) { - tagSet.add(tag); - } - } - return Array.from(tagSet).sort(); - }, [allItems]); - - // Client-side filters: search query + tag (all compose together) - const items = useMemo(() => { - const hasTagFilters = selectedTags.length > 0; - const query = searchQuery.trim().toLowerCase(); - const hasSearch = query.length > 0; - - return allItems.filter((idea) => { - if (hasTagFilters && !selectedTags.some((tag) => idea.tags.includes(tag))) { - return false; - } - if ( - hasSearch && - !idea.title.toLowerCase().includes(query) && - !idea.description.toLowerCase().includes(query) - ) { - return false; - } - return true; - }); - }, [allItems, selectedTags, searchQuery]); - - function handleTagToggle(tag: string): void { - setSelectedTags((prev) => - prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], - ); - } - - function handleClearTags(): void { - setSelectedTags([]); - } - - function handleCreate(): void { - if (!formTitle.trim()) return; - createIdea.mutate({ - title: formTitle.trim(), - description: formDescription.trim(), - category: formCategory, - }); - setFormTitle(''); - setFormDescription(''); - setFormCategory('feature'); - setShowForm(false); - } - - function handleGenerate(): void { - if (!generatePrompt.trim() || sendCommand.isPending) return; - openWidget(); - sendCommand.mutate({ - input: generatePrompt.trim(), - context: { activeView: 'ideation', activeProjectId: activeProjectId ?? undefined }, - }); - setShowGenerate(false); - setGeneratePrompt(DEFAULT_GENERATE_PROMPT); - } - - - return ( - <div className="space-y-6 p-6"> - {/* Actions */} - <div className="flex items-center gap-2"> - <Button - type="button" - variant="outline" - onClick={() => { - setShowGenerate(!showGenerate); - setShowForm(false); - }} - > - <Sparkles className="h-4 w-4" /> - Generate with AI - </Button> - <Button - type="button" - onClick={() => { - setShowForm(!showForm); - setShowGenerate(false); - }} - > - <Plus className="h-4 w-4" /> - New Idea - </Button> - </div> - {/* Generate with AI Panel */} - {showGenerate ? ( - <Card className="mb-6"> - <CardContent className="space-y-3 p-4"> - <p className="text-muted-foreground text-sm"> - Describe what ideas you want the assistant to generate. It will create them directly in your ideation board. - </p> - <Textarea - resize="none" - rows={3} - value={generatePrompt} - onChange={(e) => setGeneratePrompt(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && e.metaKey) handleGenerate(); - }} - /> - <div className="flex gap-2"> - <Button - disabled={sendCommand.isPending || !generatePrompt.trim()} - type="button" - onClick={handleGenerate} - > - {sendCommand.isPending ? ( - <> - <Spinner className="h-4 w-4" /> - Generating... - </> - ) : ( - <> - <Sparkles className="h-4 w-4" /> - Generate - </> - )} - </Button> - <Button - type="button" - variant="outline" - onClick={() => setShowGenerate(false)} - > - Cancel - </Button> - </div> - </CardContent> - </Card> - ) : null} - - {/* Create Form */} - {showForm ? ( - <Card className="mb-6"> - <CardContent className="space-y-3 p-4"> - <Input - placeholder="Idea title" - type="text" - value={formTitle} - onChange={(e) => setFormTitle(e.target.value)} - /> - <Textarea - placeholder="Description" - resize="none" - rows={2} - value={formDescription} - onChange={(e) => setFormDescription(e.target.value)} - /> - <Select - value={formCategory} - onValueChange={(v) => setFormCategory(v as IdeaCategory)} - > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {CATEGORY_OPTIONS.map((cat) => ( - <SelectItem key={cat} value={cat}> - {CATEGORY_LABELS[cat]} - </SelectItem> - ))} - </SelectContent> - </Select> - <div className="flex gap-2"> - <Button type="button" onClick={handleCreate}> - Create - </Button> - <Button - type="button" - variant="outline" - onClick={() => setShowForm(false)} - > - Cancel - </Button> - </div> - </CardContent> - </Card> - ) : null} - - {/* Filters */} - <div className="mb-6"> - <IdeationFilterRow - activeFilter={activeFilter} - allTags={allTags} - searchQuery={searchQuery} - selectedTags={selectedTags} - onClearTags={handleClearTags} - onFilterChange={setActiveFilter} - onSearchChange={setSearchQuery} - onTagToggle={handleTagToggle} - /> - </div> - - {/* Ideas Grid */} - {isLoading ? ( - <div className="text-muted-foreground flex items-center justify-center py-12"> - Loading ideas... - </div> - ) : null} - - {!isLoading && (items.length > 0) ? ( - <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> - {items.map((idea) => ( - <IdeaCard - key={idea.id} - idea={idea} - onDelete={(id) => deleteIdea.mutate(id)} - onEdit={setEditingIdea} - onVote={(id, delta) => voteIdea.mutate({ id, delta })} - /> - ))} - </div> - ) : null} - - {!isLoading && (items.length === 0) ? ( - <EmptyState - description="Try a different filter or add a new idea" - icon={Lightbulb} - title="No ideas in this category" - /> - ) : null} - {/* Edit dialog */} - <IdeaEditForm idea={editingIdea} onClose={() => setEditingIdea(null)} /> - </div> - ); -} diff --git a/src/renderer/features/ideation/index.ts b/src/renderer/features/ideation/index.ts deleted file mode 100644 index d250780b..00000000 --- a/src/renderer/features/ideation/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Ideation feature — idea board with voting and filtering - */ - -export { IdeaCard } from './components/IdeaCard'; -export { IdeaEditForm } from './components/IdeaEditForm'; -export { IdeationFilterRow } from './components/IdeationFilterRow'; -export { IdeationPage } from './components/IdeationPage'; -export { useIdeas } from './api/useIdeas'; -export { ideaKeys } from './api/queryKeys'; -export { useIdeaEvents } from './hooks/useIdeaEvents'; -export { useIdeationUI } from './store'; diff --git a/src/renderer/features/insights/api/useInsights.ts b/src/renderer/features/insights/api/useInsights.ts index 3b618db6..6f34c90f 100644 --- a/src/renderer/features/insights/api/useInsights.ts +++ b/src/renderer/features/insights/api/useInsights.ts @@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query'; -import { INSIGHTS } from '@shared/ipc/misc/insights.channels'; +import { INSIGHTS } from '@shared/ipc/insights'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/insights/components/InsightsPage.tsx b/src/renderer/features/insights/components/InsightsPage.tsx index da918b20..83bcbfd4 100644 --- a/src/renderer/features/insights/components/InsightsPage.tsx +++ b/src/renderer/features/insights/components/InsightsPage.tsx @@ -5,6 +5,7 @@ import type { InsightMetrics, TaskDistribution } from '@shared/types'; import { Card, CardContent, + Heading, MetricCard, Progress, Text, @@ -117,7 +118,7 @@ export function InsightsPage() { <CardContent className="p-4"> <div className="mb-4 flex items-center gap-2"> <Activity className="text-muted-foreground h-4 w-4" /> - <h2 className="text-foreground text-sm font-semibold">Task Distribution</h2> + <Heading as="h2" className="text-sm">Task Distribution</Heading> </div> {distItems.length > 0 ? ( <div className="space-y-3"> @@ -136,7 +137,7 @@ export function InsightsPage() { <CardContent className="p-4"> <div className="mb-4 flex items-center gap-2"> <BarChart3 className="text-muted-foreground h-4 w-4" /> - <h2 className="text-foreground text-sm font-semibold">Project Breakdown</h2> + <Heading as="h2" className="text-sm">Project Breakdown</Heading> </div> {projectItems.length > 0 ? ( <div className="space-y-3"> diff --git a/src/renderer/features/integrations/api/useMcpTool.ts b/src/renderer/features/integrations/api/useMcpTool.ts index d6523deb..a38d04f4 100644 --- a/src/renderer/features/integrations/api/useMcpTool.ts +++ b/src/renderer/features/integrations/api/useMcpTool.ts @@ -6,7 +6,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; -import { MCP } from '@shared/ipc/misc/mcp.channels'; +import { MCP } from '@shared/ipc/mcp'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/integrations/communications-store.ts b/src/renderer/features/integrations/communications-store.ts index a6c19c0c..b5375737 100644 --- a/src/renderer/features/integrations/communications-store.ts +++ b/src/renderer/features/integrations/communications-store.ts @@ -23,7 +23,7 @@ export type IntegrationsTab = | 'email' | 'notifications'; -interface NotificationRule { +export interface NotificationRule { id: string; service: 'slack' | 'discord'; pattern: string; diff --git a/src/renderer/features/integrations/components/CalendarPanel.tsx b/src/renderer/features/integrations/components/CalendarPanel.tsx index 069c7dc3..7d3e98c5 100644 --- a/src/renderer/features/integrations/components/CalendarPanel.tsx +++ b/src/renderer/features/integrations/components/CalendarPanel.tsx @@ -8,13 +8,15 @@ import { useNavigate } from '@tanstack/react-router'; import { Calendar } from 'lucide-react'; +import { ROUTES } from '@shared/constants'; + import { Button, EmptyState } from '@ui'; export function CalendarPanel() { const navigate = useNavigate(); function handleConnect(): void { - void navigate({ to: '/settings' }); + void navigate({ to: ROUTES.SETTINGS }); } return ( diff --git a/src/renderer/features/integrations/components/DiscordActionModal.tsx b/src/renderer/features/integrations/components/DiscordActionModal.tsx deleted file mode 100644 index b2d9ceb7..00000000 --- a/src/renderer/features/integrations/components/DiscordActionModal.tsx +++ /dev/null @@ -1,304 +0,0 @@ -/** - * DiscordActionModal — Modal for Discord action input - */ - -import type { ChangeEvent } from 'react'; -import { useState } from 'react'; - -import * as Dialog from '@radix-ui/react-dialog'; -import { X } from 'lucide-react'; - -import { - Button, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Textarea, -} from '@ui'; - -import { useMcpToolCall } from '../api/useMcpTool'; - -export type DiscordActionType = 'send_message' | 'call_user' | 'list_servers' | 'set_status'; - -interface DiscordActionModalProps { - actionType: DiscordActionType | null; - onClose: () => void; -} - -type DiscordStatus = 'online' | 'dnd' | 'idle' | 'invisible'; - -interface FormState { - channelId: string; - content: string; - userId: string; - status: DiscordStatus; - activityName: string; -} - -const ACTION_LABELS: Record<DiscordActionType, string> = { - send_message: 'Send Message', - call_user: 'Call User', - list_servers: 'List Servers', - set_status: 'Set Status', -}; - -const ACTION_DESCRIPTIONS: Record<DiscordActionType, string> = { - send_message: 'Send a message to a Discord channel', - call_user: 'Start a voice/video call with a user', - list_servers: 'Browse servers you are a member of', - set_status: 'Update your Discord presence', -}; - -export function DiscordActionModal({ actionType, onClose }: DiscordActionModalProps) { - const [form, setForm] = useState<FormState>({ - channelId: '', - content: '', - userId: '', - status: 'online', - activityName: '', - }); - const [result, setResult] = useState<string | null>(null); - const [error, setError] = useState<string | null>(null); - - const mcpCall = useMcpToolCall(); - - function handleInputChange( - field: keyof FormState, - e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, - ): void { - setForm((prev) => ({ ...prev, [field]: e.target.value })); - setError(null); - setResult(null); - } - - function handleStatusChange(value: string): void { - setForm((prev) => ({ ...prev, status: value as DiscordStatus })); - setError(null); - setResult(null); - } - - async function handleSubmit(): Promise<void> { - if (!actionType) return; - - setError(null); - setResult(null); - - try { - let toolName: string; - let args: Record<string, unknown>; - - switch (actionType) { - case 'send_message': - toolName = 'discord_send_message'; - args = { channelId: form.channelId, content: form.content }; - break; - case 'call_user': - toolName = 'discord_call_user'; - args = { userId: form.userId }; - break; - case 'list_servers': - toolName = 'discord_list_servers'; - args = {}; - break; - case 'set_status': - toolName = 'discord_set_status'; - args = { - status: form.status, - activityName: form.activityName.length > 0 ? form.activityName : undefined, - }; - break; - default: - return; - } - - const response = await mcpCall.mutateAsync({ - server: 'discord', - tool: toolName, - args, - }); - - if (response.isError) { - const errorText = response.content[0]?.text ?? 'Unknown error'; - setError(errorText); - } else { - const resultText = response.content[0]?.text ?? 'Success'; - setResult(resultText); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setError(message); - } - } - - function renderForm(): React.ReactNode { - if (!actionType) return null; - - switch (actionType) { - case 'send_message': - return ( - <> - <div> - <Label htmlFor="discord-channel">Channel ID</Label> - <Input - className="mt-1" - id="discord-channel" - placeholder="1234567890123456789" - type="text" - value={form.channelId} - onChange={(e: ChangeEvent<HTMLInputElement>) => { - handleInputChange('channelId', e); - }} - /> - </div> - <div> - <Label htmlFor="discord-content">Message</Label> - <Textarea - className="mt-1" - id="discord-content" - placeholder="Your message..." - rows={3} - value={form.content} - onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { - handleInputChange('content', e); - }} - /> - </div> - </> - ); - - case 'call_user': - return ( - <div> - <Label htmlFor="discord-user">User ID</Label> - <Input - className="mt-1" - id="discord-user" - placeholder="1234567890123456789" - type="text" - value={form.userId} - onChange={(e: ChangeEvent<HTMLInputElement>) => { - handleInputChange('userId', e); - }} - /> - </div> - ); - - case 'list_servers': - return ( - <p className="text-muted-foreground text-sm"> - Click Execute to list all servers you are a member of. - </p> - ); - - case 'set_status': - return ( - <> - <div> - <Label htmlFor="discord-status">Status</Label> - <Select value={form.status} onValueChange={handleStatusChange}> - <SelectTrigger className="mt-1" id="discord-status"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="online">Online</SelectItem> - <SelectItem value="dnd">Do Not Disturb</SelectItem> - <SelectItem value="idle">Idle</SelectItem> - <SelectItem value="invisible">Invisible</SelectItem> - </SelectContent> - </Select> - </div> - <div> - <Label htmlFor="discord-activity">Activity Name (optional)</Label> - <Input - className="mt-1" - id="discord-activity" - placeholder="Playing a game..." - type="text" - value={form.activityName} - onChange={(e: ChangeEvent<HTMLInputElement>) => { - handleInputChange('activityName', e); - }} - /> - </div> - </> - ); - - default: - return null; - } - } - - return ( - <Dialog.Root open={actionType !== null} onOpenChange={(isOpen) => !isOpen && onClose()}> - <Dialog.Portal> - <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" /> - <Dialog.Content - className="bg-background border-border fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 max-h-[80vh] overflow-y-auto rounded-lg border shadow-2xl" - > - {/* Header */} - <div className="border-border flex items-center justify-between border-b p-4"> - <div> - <Dialog.Title className="text-lg font-semibold"> - {actionType ? ACTION_LABELS[actionType] : ''} - </Dialog.Title> - <Dialog.Description className="text-muted-foreground text-sm"> - {actionType ? ACTION_DESCRIPTIONS[actionType] : ''} - </Dialog.Description> - </div> - <Dialog.Close asChild> - <Button - aria-label="Close" - size="icon" - type="button" - variant="ghost" - > - <X className="h-4 w-4" /> - </Button> - </Dialog.Close> - </div> - - {/* Form */} - <div className="space-y-4 p-4">{renderForm()}</div> - - {/* Error */} - {error ? ( - <div className="bg-destructive/10 text-destructive mx-4 rounded-md p-3 text-sm"> - {error} - </div> - ) : null} - - {/* Result */} - {result ? ( - <div className="bg-muted mx-4 max-h-40 overflow-y-auto rounded-md p-3"> - <pre className="text-muted-foreground text-xs whitespace-pre-wrap">{result}</pre> - </div> - ) : null} - - {/* Actions */} - <div className="border-border flex justify-end gap-2 border-t p-4"> - <Button - type="button" - variant="outline" - onClick={onClose} - > - Close - </Button> - <Button - disabled={mcpCall.isPending} - type="button" - onClick={() => { - void handleSubmit(); - }} - > - {mcpCall.isPending ? 'Processing...' : 'Execute'} - </Button> - </div> - </Dialog.Content> - </Dialog.Portal> - </Dialog.Root> - ); -} diff --git a/src/renderer/features/integrations/components/DiscordActionModal/DiscordActionModal.tsx b/src/renderer/features/integrations/components/DiscordActionModal/DiscordActionModal.tsx new file mode 100644 index 00000000..4e5e582c --- /dev/null +++ b/src/renderer/features/integrations/components/DiscordActionModal/DiscordActionModal.tsx @@ -0,0 +1,219 @@ +/** + * DiscordActionModal — Modal for Discord action input + */ + +import type { ChangeEvent } from 'react'; + +import * as Dialog from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import { + Button, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from '@ui'; + +import { useDiscordActionModal } from './useDiscordActionModal'; + +import type { DiscordActionType } from './useDiscordActionModal'; + + +export type { DiscordActionType }; + +interface DiscordActionModalProps { + actionType: DiscordActionType | null; + onClose: () => void; +} + +const ACTION_LABELS: Record<DiscordActionType, string> = { + send_message: 'Send Message', + call_user: 'Call User', + list_servers: 'List Servers', + set_status: 'Set Status', +}; + +const ACTION_DESCRIPTIONS: Record<DiscordActionType, string> = { + send_message: 'Send a message to a Discord channel', + call_user: 'Start a voice/video call with a user', + list_servers: 'Browse servers you are a member of', + set_status: 'Update your Discord presence', +}; + +export function DiscordActionModal({ actionType, onClose }: DiscordActionModalProps) { + const { form, result, error, mcpCall, handleInputChange, handleStatusChange, handleSubmit } = + useDiscordActionModal(actionType); + + function renderForm(): React.ReactNode { + if (!actionType) return null; + + switch (actionType) { + case 'send_message': + return ( + <> + <div> + <Label htmlFor="discord-channel">Channel ID</Label> + <Input + className="mt-1" + id="discord-channel" + placeholder="1234567890123456789" + type="text" + value={form.channelId} + onChange={(e: ChangeEvent<HTMLInputElement>) => { + handleInputChange('channelId', e); + }} + /> + </div> + <div> + <Label htmlFor="discord-content">Message</Label> + <Textarea + className="mt-1" + id="discord-content" + placeholder="Your message..." + rows={3} + value={form.content} + onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { + handleInputChange('content', e); + }} + /> + </div> + </> + ); + + case 'call_user': + return ( + <div> + <Label htmlFor="discord-user">User ID</Label> + <Input + className="mt-1" + id="discord-user" + placeholder="1234567890123456789" + type="text" + value={form.userId} + onChange={(e: ChangeEvent<HTMLInputElement>) => { + handleInputChange('userId', e); + }} + /> + </div> + ); + + case 'list_servers': + return ( + <p className="text-muted-foreground text-sm"> + Click Execute to list all servers you are a member of. + </p> + ); + + case 'set_status': + return ( + <> + <div> + <Label htmlFor="discord-status">Status</Label> + <Select value={form.status} onValueChange={handleStatusChange}> + <SelectTrigger className="mt-1" id="discord-status"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="online">Online</SelectItem> + <SelectItem value="dnd">Do Not Disturb</SelectItem> + <SelectItem value="idle">Idle</SelectItem> + <SelectItem value="invisible">Invisible</SelectItem> + </SelectContent> + </Select> + </div> + <div> + <Label htmlFor="discord-activity">Activity Name (optional)</Label> + <Input + className="mt-1" + id="discord-activity" + placeholder="Playing a game..." + type="text" + value={form.activityName} + onChange={(e: ChangeEvent<HTMLInputElement>) => { + handleInputChange('activityName', e); + }} + /> + </div> + </> + ); + + default: + return null; + } + } + + return ( + <Dialog.Root open={actionType !== null} onOpenChange={(isOpen) => !isOpen && onClose()}> + <Dialog.Portal> + <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" /> + <Dialog.Content + className="bg-background border-border fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 max-h-[80vh] overflow-y-auto rounded-lg border shadow-2xl" + > + {/* Header */} + <div className="border-border flex items-center justify-between border-b p-4"> + <div> + <Dialog.Title className="text-lg font-semibold"> + {actionType ? ACTION_LABELS[actionType] : ''} + </Dialog.Title> + <Dialog.Description className="text-muted-foreground text-sm"> + {actionType ? ACTION_DESCRIPTIONS[actionType] : ''} + </Dialog.Description> + </div> + <Dialog.Close asChild> + <Button + aria-label="Close" + size="icon" + type="button" + variant="ghost" + > + <X className="h-4 w-4" /> + </Button> + </Dialog.Close> + </div> + + {/* Form */} + <div className="space-y-4 p-4">{renderForm()}</div> + + {/* Error */} + {error ? ( + <div className="bg-destructive/10 text-destructive mx-4 rounded-md p-3 text-sm"> + {error} + </div> + ) : null} + + {/* Result */} + {result ? ( + <div className="bg-muted mx-4 max-h-40 overflow-y-auto rounded-md p-3"> + <pre className="text-muted-foreground text-xs whitespace-pre-wrap">{result}</pre> + </div> + ) : null} + + {/* Actions */} + <div className="border-border flex justify-end gap-2 border-t p-4"> + <Button + type="button" + variant="outline" + onClick={onClose} + > + Close + </Button> + <Button + disabled={mcpCall.isPending} + type="button" + onClick={() => { + void handleSubmit(); + }} + > + {mcpCall.isPending ? 'Processing...' : 'Execute'} + </Button> + </div> + </Dialog.Content> + </Dialog.Portal> + </Dialog.Root> + ); +} diff --git a/src/renderer/features/integrations/components/DiscordActionModal/index.ts b/src/renderer/features/integrations/components/DiscordActionModal/index.ts new file mode 100644 index 00000000..4db87920 --- /dev/null +++ b/src/renderer/features/integrations/components/DiscordActionModal/index.ts @@ -0,0 +1,2 @@ +export { DiscordActionModal } from './DiscordActionModal'; +export type { DiscordActionType } from './useDiscordActionModal'; diff --git a/src/renderer/features/integrations/components/DiscordActionModal/useDiscordActionModal.ts b/src/renderer/features/integrations/components/DiscordActionModal/useDiscordActionModal.ts new file mode 100644 index 00000000..80db5fb2 --- /dev/null +++ b/src/renderer/features/integrations/components/DiscordActionModal/useDiscordActionModal.ts @@ -0,0 +1,70 @@ +/** + * useDiscordActionModal — Logic hook for DiscordActionModal + */ + +import { useModalToolAction } from '../../hooks/useModalToolAction'; + +export type DiscordActionType = 'send_message' | 'call_user' | 'list_servers' | 'set_status'; + +export interface DiscordFormState { + channelId: string; + content: string; + userId: string; + status: string; + activityName: string; +} + +export function useDiscordActionModal(actionType: DiscordActionType | null) { + const { + form, + result, + error, + mcpCall, + handleInputChange, + updateField, + handleSubmit: submitTool, + } = useModalToolAction<DiscordFormState>({ + channelId: '', + content: '', + userId: '', + status: 'online', + activityName: '', + }); + + function handleStatusChange(value: string): void { + updateField('status', value); + } + + async function handleSubmit(): Promise<void> { + if (!actionType) return; + + await submitTool((f) => { + switch (actionType) { + case 'send_message': + return { server: 'discord', tool: 'discord_send_message', args: { channelId: f.channelId, content: f.content } }; + case 'call_user': + return { server: 'discord', tool: 'discord_call_user', args: { userId: f.userId } }; + case 'list_servers': + return { server: 'discord', tool: 'discord_list_servers', args: {} }; + case 'set_status': + return { + server: 'discord', + tool: 'discord_set_status', + args: { status: f.status, activityName: f.activityName.length > 0 ? f.activityName : undefined }, + }; + default: + return null; + } + }); + } + + return { + form, + result, + error, + mcpCall, + handleInputChange, + handleStatusChange, + handleSubmit, + }; +} diff --git a/src/renderer/features/integrations/components/DiscordPanel.tsx b/src/renderer/features/integrations/components/DiscordPanel/DiscordPanel.tsx similarity index 79% rename from src/renderer/features/integrations/components/DiscordPanel.tsx rename to src/renderer/features/integrations/components/DiscordPanel/DiscordPanel.tsx index fc14d8e8..76215468 100644 --- a/src/renderer/features/integrations/components/DiscordPanel.tsx +++ b/src/renderer/features/integrations/components/DiscordPanel/DiscordPanel.tsx @@ -2,20 +2,18 @@ * DiscordPanel — Quick Discord actions and status display */ -import { useState } from 'react'; - -import { useNavigate } from '@tanstack/react-router'; import { MessageSquare, Phone, Server, Settings, UserCircle } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; import { Button, StatusIndicator } from '@ui'; -import { useIntegrationsStore } from '../store'; -import { DiscordActionModal } from './DiscordActionModal'; +import { DiscordActionModal } from '../DiscordActionModal'; + +import { useDiscordPanel } from './useDiscordPanel'; -import type { DiscordActionType } from './DiscordActionModal'; +import type { DiscordActionType } from '../DiscordActionModal'; type ServiceStatus = 'connected' | 'disconnected' | 'error'; @@ -60,25 +58,8 @@ const discordActions: QuickAction[] = [ ]; export function DiscordPanel() { - const { discordStatus } = useIntegrationsStore(); - const navigate = useNavigate(); - const [activeAction, setActiveAction] = useState<DiscordActionType | null>(null); - - function handleAction(actionType: DiscordActionType): void { - if (discordStatus !== 'connected') { - void navigate({ to: '/settings' }); - return; - } - setActiveAction(actionType); - } - - function handleConnect(): void { - void navigate({ to: '/settings' }); - } - - function handleCloseModal(): void { - setActiveAction(null); - } + const { discordStatus, activeAction, handleAction, handleConnect, handleCloseModal } = + useDiscordPanel(); return ( <> diff --git a/src/renderer/features/integrations/components/DiscordPanel/index.ts b/src/renderer/features/integrations/components/DiscordPanel/index.ts new file mode 100644 index 00000000..f6eb9683 --- /dev/null +++ b/src/renderer/features/integrations/components/DiscordPanel/index.ts @@ -0,0 +1 @@ +export { DiscordPanel } from './DiscordPanel'; diff --git a/src/renderer/features/integrations/components/DiscordPanel/useDiscordPanel.ts b/src/renderer/features/integrations/components/DiscordPanel/useDiscordPanel.ts new file mode 100644 index 00000000..6e296ea5 --- /dev/null +++ b/src/renderer/features/integrations/components/DiscordPanel/useDiscordPanel.ts @@ -0,0 +1,26 @@ +/** + * useDiscordPanel — Logic hook for DiscordPanel + */ + +import { useConnectedServicePanel } from '../../hooks/useConnectedServicePanel'; +import { useIntegrationsStore } from '../../store'; + +import type { DiscordActionType } from '../DiscordActionModal'; + +export function useDiscordPanel() { + const { discordStatus } = useIntegrationsStore(); + + const { status, activeAction, handleAction, handleConnect, handleCloseModal } = + useConnectedServicePanel<typeof discordStatus, DiscordActionType>({ + status: discordStatus, + connectedValue: 'connected', + }); + + return { + discordStatus: status, + activeAction, + handleAction, + handleConnect, + handleCloseModal, + }; +} diff --git a/src/renderer/features/integrations/components/EmailPanel.tsx b/src/renderer/features/integrations/components/EmailPanel/EmailPanel.tsx similarity index 76% rename from src/renderer/features/integrations/components/EmailPanel.tsx rename to src/renderer/features/integrations/components/EmailPanel/EmailPanel.tsx index ef636d5c..fce86e6a 100644 --- a/src/renderer/features/integrations/components/EmailPanel.tsx +++ b/src/renderer/features/integrations/components/EmailPanel/EmailPanel.tsx @@ -2,8 +2,6 @@ * EmailPanel — SMTP configuration, connection testing, and email queue management */ -import { useState } from 'react'; - import { RefreshCw, Trash2 } from 'lucide-react'; import { @@ -25,72 +23,39 @@ import { Text, } from '@ui'; -import { - useEmailConfig, - useEmailQueue, - useRemoveEmailQueued, - useRetryEmailQueued, - useSendTestEmail, - useTestEmailConnection, - useUpdateEmailConfig, -} from '../api/useEmail'; - -type QueueFilter = 'pending' | 'sent' | 'failed'; - -export function EmailPanel() { - // 1. Hooks - const { data: config, isLoading: configLoading } = useEmailConfig(); - const { data: queue, isLoading: queueLoading } = useEmailQueue(); - const updateConfig = useUpdateEmailConfig(); - const testConnection = useTestEmailConnection(); - const sendTestEmail = useSendTestEmail(); - const retryQueued = useRetryEmailQueued(); - const removeQueued = useRemoveEmailQueued(); - - // 2. Local form state - const [host, setHost] = useState(config === null || config === undefined ? '' : config.host); - const [port, setPort] = useState(config === null || config === undefined ? '587' : String(config.port)); - const [user, setUser] = useState(config === null || config === undefined ? '' : config.auth.user); - const [password, setPassword] = useState(''); - const [secure, setSecure] = useState(config === null || config === undefined ? false : config.secure); - const [queueFilter, setQueueFilter] = useState<QueueFilter>('pending'); +import { useEmailPanel } from './useEmailPanel'; - // 3. Derived state - const connectionResult = testConnection.data; - const filteredQueue = (queue ?? []).filter((item) => item.status === queueFilter); +import type { QueueFilter } from './useEmailPanel'; - // 4. Handlers - function handleSaveConfig(): void { - updateConfig.mutate({ - host, - port: parseInt(port, 10), - secure, - auth: { user, pass: password }, - from: user, - }); - } - function handleTestConnection(): void { - testConnection.mutate(); - } - - function handleSendTestEmail(): void { - sendTestEmail.mutate({ - to: [user], - subject: 'ADC Test Email', - body: 'This is a test email from Agent Desktop Command.', - }); - } - - function handleRetry(emailId: string): void { - retryQueued.mutate({ emailId }); - } - - function handleRemove(emailId: string): void { - removeQueued.mutate({ emailId }); - } +export function EmailPanel() { + const { + configLoading, + queueLoading, + updateConfig, + testConnection, + sendTestEmail, + connectionResult, + filteredQueue, + host, + setHost, + port, + setPort, + user, + setUser, + password, + setPassword, + secure, + setSecure, + queueFilter, + setQueueFilter, + handleSaveConfig, + handleTestConnection, + handleSendTestEmail, + handleRetry, + handleRemove, + } = useEmailPanel(); - // 5. Render helpers function renderConnectionBadge() { if (testConnection.isPending) { return <Spinner size="sm" />; diff --git a/src/renderer/features/integrations/components/EmailPanel/index.ts b/src/renderer/features/integrations/components/EmailPanel/index.ts new file mode 100644 index 00000000..5db2134e --- /dev/null +++ b/src/renderer/features/integrations/components/EmailPanel/index.ts @@ -0,0 +1 @@ +export { EmailPanel } from './EmailPanel'; diff --git a/src/renderer/features/integrations/components/EmailPanel/useEmailPanel.ts b/src/renderer/features/integrations/components/EmailPanel/useEmailPanel.ts new file mode 100644 index 00000000..6b16118a --- /dev/null +++ b/src/renderer/features/integrations/components/EmailPanel/useEmailPanel.ts @@ -0,0 +1,94 @@ +/** + * useEmailPanel — Logic hook for EmailPanel + */ + +import { useState } from 'react'; + +import { + useEmailConfig, + useEmailQueue, + useRemoveEmailQueued, + useRetryEmailQueued, + useSendTestEmail, + useTestEmailConnection, + useUpdateEmailConfig, +} from '../../api/useEmail'; + +export type QueueFilter = 'pending' | 'sent' | 'failed'; + +export function useEmailPanel() { + const { data: config, isLoading: configLoading } = useEmailConfig(); + const { data: queue, isLoading: queueLoading } = useEmailQueue(); + const updateConfig = useUpdateEmailConfig(); + const testConnection = useTestEmailConnection(); + const sendTestEmail = useSendTestEmail(); + const retryQueued = useRetryEmailQueued(); + const removeQueued = useRemoveEmailQueued(); + + const [host, setHost] = useState(config === null || config === undefined ? '' : config.host); + const [port, setPort] = useState(config === null || config === undefined ? '587' : String(config.port)); + const [user, setUser] = useState(config === null || config === undefined ? '' : config.auth.user); + const [password, setPassword] = useState(''); + const [secure, setSecure] = useState(config === null || config === undefined ? false : config.secure); + const [queueFilter, setQueueFilter] = useState<QueueFilter>('pending'); + + const connectionResult = testConnection.data; + const filteredQueue = (queue ?? []).filter((item) => item.status === queueFilter); + + function handleSaveConfig(): void { + updateConfig.mutate({ + host, + port: parseInt(port, 10), + secure, + auth: { user, pass: password }, + from: user, + }); + } + + function handleTestConnection(): void { + testConnection.mutate(); + } + + function handleSendTestEmail(): void { + sendTestEmail.mutate({ + to: [user], + subject: 'ADC Test Email', + body: 'This is a test email from Agent Desktop Command.', + }); + } + + function handleRetry(emailId: string): void { + retryQueued.mutate({ emailId }); + } + + function handleRemove(emailId: string): void { + removeQueued.mutate({ emailId }); + } + + return { + configLoading, + queueLoading, + updateConfig, + testConnection, + sendTestEmail, + connectionResult, + filteredQueue, + host, + setHost, + port, + setPort, + user, + setUser, + password, + setPassword, + secure, + setSecure, + queueFilter, + setQueueFilter, + handleSaveConfig, + handleTestConnection, + handleSendTestEmail, + handleRetry, + handleRemove, + }; +} diff --git a/src/renderer/features/integrations/components/GitHubPanel.tsx b/src/renderer/features/integrations/components/GitHubPanel.tsx deleted file mode 100644 index a8a4440f..00000000 --- a/src/renderer/features/integrations/components/GitHubPanel.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/** - * GitHubPanel — GitHub integration content for the Integrations page - * - * Shows connection status, repo selector, and sub-tabs for PRs, Issues, Notifications. - */ - -import { useState } from 'react'; - -import { Bell, CircleDot, GitPullRequest } from 'lucide-react'; - -import { Badge, Button, Spinner, Tabs, TabsContent, TabsList, TabsTrigger } from '@ui'; - -import { useGitHubIssues, useGitHubNotifications, useGitHubPrs } from '../api/useGitHub'; -import { useGitHubEvents } from '../hooks/useGitHubEvents'; -import { useGitHubProjectSync } from '../hooks/useGitHubProjectSync'; -import { useGitHubStore } from '../store'; - -import { GitHubConnectionStatus } from './GitHubConnectionStatus'; -import { IssueCreateForm } from './IssueCreateForm'; -import { IssueList } from './IssueList'; -import { NotificationList } from './NotificationList'; -import { PrDetailModal } from './PrDetailModal'; -import { PrDiffView } from './PrDiffView'; -import { PrList } from './PrList'; - -// ── Types ──────────────────────────────────────────────────── - -type GitHubSubTab = 'prs' | 'issues' | 'notifications'; - -// ── Helper ─────────────────────────────────────────────────── - -function LoadingSpinner() { - return ( - <div className="flex items-center justify-center py-12"> - <Spinner className="text-muted-foreground" size="md" /> - </div> - ); -} - -// ── Component ──────────────────────────────────────────────── - -export function GitHubPanel() { - const [showDiff, setShowDiff] = useState(false); - - const { - githubActiveTab: activeTab, - githubSelectedPrNumber: selectedPrNumber, - setGitHubActiveTab: setActiveTab, - selectPr, - } = useGitHubStore(); - - const { data: prs, isLoading: prsLoading } = useGitHubPrs(); - const { data: issues, isLoading: issuesLoading } = useGitHubIssues(); - const { data: notifications, isLoading: notificationsLoading } = useGitHubNotifications(); - - useGitHubEvents(); - useGitHubProjectSync(); - - const openPrCount = prs?.filter((pr) => pr.state === 'open').length ?? 0; - const openIssueCount = issues?.filter((i) => i.state === 'open').length ?? 0; - const unreadNotifCount = notifications?.filter((n) => n.unread).length ?? 0; - - function renderTabContent(tab: string): React.ReactNode { - if (tab === 'prs') { - if (prsLoading) return <LoadingSpinner />; - return <PrList prs={prs ?? []} onSelectPr={selectPr} />; - } - - if (tab === 'issues') { - if (issuesLoading) return <LoadingSpinner />; - return <IssueList issues={issues ?? []} />; - } - - if (notificationsLoading) return <LoadingSpinner />; - return <NotificationList notifications={notifications ?? []} />; - } - - return ( - <div className="space-y-4"> - <GitHubConnectionStatus /> - - <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as GitHubSubTab)}> - <TabsList className="mb-4"> - <TabsTrigger value="prs"> - <GitPullRequest className="h-4 w-4" /> - Pull Requests - {openPrCount > 0 ? ( - <Badge size="sm" variant="secondary"> - {String(openPrCount)} - </Badge> - ) : null} - </TabsTrigger> - <TabsTrigger value="issues"> - <CircleDot className="h-4 w-4" /> - Issues - {openIssueCount > 0 ? ( - <Badge size="sm" variant="secondary"> - {String(openIssueCount)} - </Badge> - ) : null} - </TabsTrigger> - <TabsTrigger value="notifications"> - <Bell className="h-4 w-4" /> - Notifications - {unreadNotifCount > 0 ? ( - <Badge size="sm" variant="secondary"> - {String(unreadNotifCount)} - </Badge> - ) : null} - </TabsTrigger> - </TabsList> - - <TabsContent value="prs">{renderTabContent('prs')}</TabsContent> - <TabsContent value="issues">{renderTabContent('issues')}</TabsContent> - <TabsContent value="notifications">{renderTabContent('notifications')}</TabsContent> - </Tabs> - - {/* PR Detail Modal */} - {selectedPrNumber === null ? null : ( - <PrDetailModal prNumber={selectedPrNumber} onClose={() => { selectPr(null); setShowDiff(false); }} /> - )} - - {/* View Files toggle — only shown when a PR is selected */} - {selectedPrNumber === null ? null : ( - <div className="space-y-2"> - <Button - size="sm" - variant="outline" - onClick={() => setShowDiff(!showDiff)} - > - {showDiff ? 'Hide Files' : 'View Files'} - </Button> - {showDiff ? <PrDiffView prNumber={selectedPrNumber} /> : null} - </div> - )} - - {/* Issue Create Dialog */} - <IssueCreateForm /> - </div> - ); -} diff --git a/src/renderer/features/integrations/components/GitHubPanel/GitHubPanel.tsx b/src/renderer/features/integrations/components/GitHubPanel/GitHubPanel.tsx new file mode 100644 index 00000000..9a24b93b --- /dev/null +++ b/src/renderer/features/integrations/components/GitHubPanel/GitHubPanel.tsx @@ -0,0 +1,129 @@ +/** + * GitHubPanel — GitHub integration content for the Integrations page + * + * Shows connection status, repo selector, and sub-tabs for PRs, Issues, Notifications. + */ + +import { Bell, CircleDot, GitPullRequest } from 'lucide-react'; + +import { Badge, Button, Spinner, Tabs, TabsContent, TabsList, TabsTrigger } from '@ui'; + +import { GitHubConnectionStatus } from '../GitHubConnectionStatus'; +import { IssueCreateForm } from '../IssueCreateForm'; +import { IssueList } from '../IssueList'; +import { NotificationList } from '../NotificationList'; +import { PrDetailModal } from '../PrDetailModal'; +import { PrDiffView } from '../PrDiffView'; +import { PrList } from '../PrList'; + +import { useGitHubPanel } from './useGitHubPanel'; + +import type { GitHubSubTab } from './useGitHubPanel'; + + +function LoadingSpinner() { + return ( + <div className="flex items-center justify-center py-12"> + <Spinner className="text-muted-foreground" size="md" /> + </div> + ); +} + +export function GitHubPanel() { + const { + showDiff, + setShowDiff, + activeTab, + selectedPrNumber, + setActiveTab, + selectPr, + prs, + prsLoading, + issues, + issuesLoading, + notifications, + notificationsLoading, + openPrCount, + openIssueCount, + unreadNotifCount, + } = useGitHubPanel(); + + function renderTabContent(tab: string): React.ReactNode { + if (tab === 'prs') { + if (prsLoading) return <LoadingSpinner />; + return <PrList prs={prs ?? []} onSelectPr={selectPr} />; + } + + if (tab === 'issues') { + if (issuesLoading) return <LoadingSpinner />; + return <IssueList issues={issues ?? []} />; + } + + if (notificationsLoading) return <LoadingSpinner />; + return <NotificationList notifications={notifications ?? []} />; + } + + return ( + <div className="space-y-4"> + <GitHubConnectionStatus /> + + <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as GitHubSubTab)}> + <TabsList className="mb-4"> + <TabsTrigger value="prs"> + <GitPullRequest className="h-4 w-4" /> + Pull Requests + {openPrCount > 0 ? ( + <Badge size="sm" variant="secondary"> + {String(openPrCount)} + </Badge> + ) : null} + </TabsTrigger> + <TabsTrigger value="issues"> + <CircleDot className="h-4 w-4" /> + Issues + {openIssueCount > 0 ? ( + <Badge size="sm" variant="secondary"> + {String(openIssueCount)} + </Badge> + ) : null} + </TabsTrigger> + <TabsTrigger value="notifications"> + <Bell className="h-4 w-4" /> + Notifications + {unreadNotifCount > 0 ? ( + <Badge size="sm" variant="secondary"> + {String(unreadNotifCount)} + </Badge> + ) : null} + </TabsTrigger> + </TabsList> + + <TabsContent value="prs">{renderTabContent('prs')}</TabsContent> + <TabsContent value="issues">{renderTabContent('issues')}</TabsContent> + <TabsContent value="notifications">{renderTabContent('notifications')}</TabsContent> + </Tabs> + + {/* PR Detail Modal */} + {selectedPrNumber === null ? null : ( + <PrDetailModal prNumber={selectedPrNumber} onClose={() => { selectPr(null); setShowDiff(false); }} /> + )} + + {/* View Files toggle — only shown when a PR is selected */} + {selectedPrNumber === null ? null : ( + <div className="space-y-2"> + <Button + size="sm" + variant="outline" + onClick={() => setShowDiff(!showDiff)} + > + {showDiff ? 'Hide Files' : 'View Files'} + </Button> + {showDiff ? <PrDiffView prNumber={selectedPrNumber} /> : null} + </div> + )} + + {/* Issue Create Dialog */} + <IssueCreateForm /> + </div> + ); +} diff --git a/src/renderer/features/integrations/components/GitHubPanel/index.ts b/src/renderer/features/integrations/components/GitHubPanel/index.ts new file mode 100644 index 00000000..9645a45a --- /dev/null +++ b/src/renderer/features/integrations/components/GitHubPanel/index.ts @@ -0,0 +1 @@ +export { GitHubPanel } from './GitHubPanel'; diff --git a/src/renderer/features/integrations/components/GitHubPanel/useGitHubPanel.ts b/src/renderer/features/integrations/components/GitHubPanel/useGitHubPanel.ts new file mode 100644 index 00000000..15981112 --- /dev/null +++ b/src/renderer/features/integrations/components/GitHubPanel/useGitHubPanel.ts @@ -0,0 +1,52 @@ +/** + * useGitHubPanel — Logic hook for GitHubPanel + */ + +import { useState } from 'react'; + +import { useGitHubIssues, useGitHubNotifications, useGitHubPrs } from '../../api/useGitHub'; +import { useGitHubEvents } from '../../hooks/useGitHubEvents'; +import { useGitHubProjectSync } from '../../hooks/useGitHubProjectSync'; +import { useGitHubStore } from '../../store'; + +export type GitHubSubTab = 'prs' | 'issues' | 'notifications'; + +export function useGitHubPanel() { + const [showDiff, setShowDiff] = useState(false); + + const { + githubActiveTab: activeTab, + githubSelectedPrNumber: selectedPrNumber, + setGitHubActiveTab: setActiveTab, + selectPr, + } = useGitHubStore(); + + const { data: prs, isLoading: prsLoading } = useGitHubPrs(); + const { data: issues, isLoading: issuesLoading } = useGitHubIssues(); + const { data: notifications, isLoading: notificationsLoading } = useGitHubNotifications(); + + useGitHubEvents(); + useGitHubProjectSync(); + + const openPrCount = prs?.filter((pr) => pr.state === 'open').length ?? 0; + const openIssueCount = issues?.filter((i) => i.state === 'open').length ?? 0; + const unreadNotifCount = notifications?.filter((n) => n.unread).length ?? 0; + + return { + showDiff, + setShowDiff, + activeTab, + selectedPrNumber, + setActiveTab, + selectPr, + prs, + prsLoading, + issues, + issuesLoading, + notifications, + notificationsLoading, + openPrCount, + openIssueCount, + unreadNotifCount, + }; +} diff --git a/src/renderer/features/integrations/components/IntegrationsPage.tsx b/src/renderer/features/integrations/components/IntegrationsPage.tsx index 088703a6..dd8ddda7 100644 --- a/src/renderer/features/integrations/components/IntegrationsPage.tsx +++ b/src/renderer/features/integrations/components/IntegrationsPage.tsx @@ -7,7 +7,6 @@ import { PageContent, PageHeader, PageLayout } from '@ui'; -import { useIntegrationsEvents } from '../hooks/useIntegrationsEvents'; import { useIntegrationsStore } from '../store'; import { CalendarPanel } from './CalendarPanel'; @@ -31,8 +30,6 @@ const TABS: Array<{ id: IntegrationsTab; label: string }> = [ ]; export function IntegrationsPage() { - useIntegrationsEvents(); - const { activeTab, setActiveTab } = useIntegrationsStore(); return ( diff --git a/src/renderer/features/integrations/components/IssueCreateForm.tsx b/src/renderer/features/integrations/components/IssueCreateForm.tsx deleted file mode 100644 index 1fe0bffc..00000000 --- a/src/renderer/features/integrations/components/IssueCreateForm.tsx +++ /dev/null @@ -1,185 +0,0 @@ -/** - * IssueCreateForm -- Dialog for creating a new GitHub issue - */ - -import { useCallback, useEffect, useState } from 'react'; - -import { AlertTriangle, CircleDot, Loader2 } from 'lucide-react'; - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - InlineAlert, - Input, - Label, - Textarea, -} from '@ui'; - -import { useCreateIssue } from '../api/useGitHub'; -import { useGitHubStore } from '../store'; - -// ── Component ──────────────────────────────────────────────── - -export function IssueCreateForm() { - const { - githubIssueCreateDialogOpen: issueCreateDialogOpen, - githubOwner: owner, - githubRepo: repo, - setIssueCreateDialogOpen, - } = useGitHubStore(); - const createIssue = useCreateIssue(); - - const [title, setTitle] = useState(''); - const [body, setBody] = useState(''); - const [labelsInput, setLabelsInput] = useState(''); - const [error, setError] = useState<string | null>(null); - - const resetForm = useCallback(() => { - setTitle(''); - setBody(''); - setLabelsInput(''); - setError(null); - }, []); - - useEffect(() => { - resetForm(); - }, [issueCreateDialogOpen, resetForm]); - - function handleClose() { - setIssueCreateDialogOpen(false); - } - - function handleSubmit() { - if (title.trim().length === 0) { - setError('Title is required'); - return; - } - - if (owner.length === 0 || repo.length === 0) { - setError('Repository owner and name must be configured'); - return; - } - - setError(null); - - const labels = labelsInput - .split(',') - .map((l) => l.trim()) - .filter((l) => l.length > 0); - - createIssue.mutate( - { - title: title.trim(), - body: body.trim().length > 0 ? body.trim() : undefined, - labels: labels.length > 0 ? labels : undefined, - }, - { - onSuccess: () => { - handleClose(); - }, - onError: (err) => { - setError(err instanceof Error ? err.message : 'Failed to create issue'); - }, - }, - ); - } - - const isFormValid = title.trim().length > 0; - - return ( - <Dialog open={issueCreateDialogOpen} onOpenChange={setIssueCreateDialogOpen}> - <DialogContent className="max-w-lg"> - <DialogHeader> - <DialogTitle className="flex items-center gap-3"> - <CircleDot className="text-primary h-5 w-5" /> - New Issue - <span className="text-muted-foreground text-xs font-normal"> - {owner}/{repo} - </span> - </DialogTitle> - </DialogHeader> - - <div className="space-y-4"> - {/* Title field */} - <div className="space-y-1.5"> - <Label htmlFor="create-issue-title"> - Title <span className="text-destructive">*</span> - </Label> - <Input - aria-required="true" - id="create-issue-title" - placeholder="Issue title..." - type="text" - value={title} - onChange={(e) => setTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && isFormValid && !createIssue.isPending) { - handleSubmit(); - } - }} - /> - </div> - - {/* Body field */} - <div className="space-y-1.5"> - <Label htmlFor="create-issue-body">Body</Label> - <Textarea - id="create-issue-body" - placeholder="Describe the issue..." - resize="none" - rows={4} - value={body} - onChange={(e) => setBody(e.target.value)} - /> - </div> - - {/* Labels field */} - <div className="space-y-1.5"> - <Label htmlFor="create-issue-labels">Labels</Label> - <Input - id="create-issue-labels" - placeholder="bug, enhancement, help wanted (comma-separated)" - type="text" - value={labelsInput} - onChange={(e) => setLabelsInput(e.target.value)} - /> - </div> - - {/* Error message */} - {error === null ? null : ( - <InlineAlert icon={AlertTriangle} variant="error"> - {error} - </InlineAlert> - )} - </div> - - <DialogFooter> - <Button type="button" variant="ghost" onClick={handleClose}> - Cancel - </Button> - <Button - disabled={!isFormValid || createIssue.isPending} - type="button" - onClick={handleSubmit} - > - {createIssue.isPending ? ( - <> - <Loader2 className="h-4 w-4 animate-spin" /> - Creating... - </> - ) : ( - <> - <CircleDot className="h-4 w-4" /> - Create Issue - </> - )} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/integrations/components/IssueCreateForm/IssueCreateForm.tsx b/src/renderer/features/integrations/components/IssueCreateForm/IssueCreateForm.tsx new file mode 100644 index 00000000..7fe62f1f --- /dev/null +++ b/src/renderer/features/integrations/components/IssueCreateForm/IssueCreateForm.tsx @@ -0,0 +1,133 @@ +/** + * IssueCreateForm -- Dialog for creating a new GitHub issue + */ + +import { AlertTriangle, CircleDot, Loader2 } from 'lucide-react'; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + InlineAlert, + Input, + Label, + Textarea, +} from '@ui'; + +import { useIssueCreateForm } from './useIssueCreateForm'; + +export function IssueCreateForm() { + const { + issueCreateDialogOpen, + setIssueCreateDialogOpen, + owner, + repo, + createIssue, + title, + setTitle, + body, + setBody, + labelsInput, + setLabelsInput, + error, + isFormValid, + handleClose, + handleSubmit, + } = useIssueCreateForm(); + + return ( + <Dialog open={issueCreateDialogOpen} onOpenChange={setIssueCreateDialogOpen}> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle className="flex items-center gap-3"> + <CircleDot className="text-primary h-5 w-5" /> + New Issue + <span className="text-muted-foreground text-xs font-normal"> + {owner}/{repo} + </span> + </DialogTitle> + </DialogHeader> + + <div className="space-y-4"> + {/* Title field */} + <div className="space-y-1.5"> + <Label htmlFor="create-issue-title"> + Title <span className="text-destructive">*</span> + </Label> + <Input + aria-required="true" + id="create-issue-title" + placeholder="Issue title..." + type="text" + value={title} + onChange={(e) => setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && isFormValid && !createIssue.isPending) { + handleSubmit(); + } + }} + /> + </div> + + {/* Body field */} + <div className="space-y-1.5"> + <Label htmlFor="create-issue-body">Body</Label> + <Textarea + id="create-issue-body" + placeholder="Describe the issue..." + resize="none" + rows={4} + value={body} + onChange={(e) => setBody(e.target.value)} + /> + </div> + + {/* Labels field */} + <div className="space-y-1.5"> + <Label htmlFor="create-issue-labels">Labels</Label> + <Input + id="create-issue-labels" + placeholder="bug, enhancement, help wanted (comma-separated)" + type="text" + value={labelsInput} + onChange={(e) => setLabelsInput(e.target.value)} + /> + </div> + + {/* Error message */} + {error === null ? null : ( + <InlineAlert icon={AlertTriangle} variant="error"> + {error} + </InlineAlert> + )} + </div> + + <DialogFooter> + <Button type="button" variant="ghost" onClick={handleClose}> + Cancel + </Button> + <Button + disabled={!isFormValid || createIssue.isPending} + type="button" + onClick={handleSubmit} + > + {createIssue.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Creating... + </> + ) : ( + <> + <CircleDot className="h-4 w-4" /> + Create Issue + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/integrations/components/IssueCreateForm/index.ts b/src/renderer/features/integrations/components/IssueCreateForm/index.ts new file mode 100644 index 00000000..2004a992 --- /dev/null +++ b/src/renderer/features/integrations/components/IssueCreateForm/index.ts @@ -0,0 +1 @@ +export { IssueCreateForm } from './IssueCreateForm'; diff --git a/src/renderer/features/integrations/components/IssueCreateForm/useIssueCreateForm.ts b/src/renderer/features/integrations/components/IssueCreateForm/useIssueCreateForm.ts new file mode 100644 index 00000000..d30e6104 --- /dev/null +++ b/src/renderer/features/integrations/components/IssueCreateForm/useIssueCreateForm.ts @@ -0,0 +1,93 @@ +/** + * useIssueCreateForm — Logic hook for IssueCreateForm + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { useCreateIssue } from '../../api/useGitHub'; +import { useGitHubStore } from '../../store'; + +export function useIssueCreateForm() { + const { + githubIssueCreateDialogOpen: issueCreateDialogOpen, + githubOwner: owner, + githubRepo: repo, + setIssueCreateDialogOpen, + } = useGitHubStore(); + const createIssue = useCreateIssue(); + + const [title, setTitle] = useState(''); + const [body, setBody] = useState(''); + const [labelsInput, setLabelsInput] = useState(''); + const [error, setError] = useState<string | null>(null); + + const resetForm = useCallback(() => { + setTitle(''); + setBody(''); + setLabelsInput(''); + setError(null); + }, []); + + useEffect(() => { + resetForm(); + }, [issueCreateDialogOpen, resetForm]); + + function handleClose(): void { + setIssueCreateDialogOpen(false); + } + + function handleSubmit(): void { + if (title.trim().length === 0) { + setError('Title is required'); + return; + } + + if (owner.length === 0 || repo.length === 0) { + setError('Repository owner and name must be configured'); + return; + } + + setError(null); + + const labels = labelsInput + .split(',') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + createIssue.mutate( + { + title: title.trim(), + body: body.trim().length > 0 ? body.trim() : undefined, + labels: labels.length > 0 ? labels : undefined, + }, + { + onSuccess: () => { + handleClose(); + }, + onError: (err) => { + setError(err instanceof Error ? err.message : 'Failed to create issue'); + }, + }, + ); + } + + const isFormValid = title.trim().length > 0; + + return { + issueCreateDialogOpen, + setIssueCreateDialogOpen, + owner, + repo, + createIssue, + title, + setTitle, + body, + setBody, + labelsInput, + setLabelsInput, + error, + isFormValid, + handleClose, + handleSubmit, + }; +} diff --git a/src/renderer/features/integrations/components/IssueList.tsx b/src/renderer/features/integrations/components/IssueList.tsx index a25813b5..79ff985b 100644 --- a/src/renderer/features/integrations/components/IssueList.tsx +++ b/src/renderer/features/integrations/components/IssueList.tsx @@ -6,7 +6,7 @@ import { CircleDot, MessageSquare, Plus } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; -import { Badge, Button, Card, EmptyState } from '@ui'; +import { Badge, Button, Card, EmptyState, Heading } from '@ui'; import { useGitHubStore } from '../store'; @@ -41,9 +41,9 @@ export function IssueList({ issues }: IssueListProps) { <div> {/* Header with New Issue button */} <div className="mb-4 flex items-center justify-between"> - <h3 className="text-foreground text-sm font-medium"> + <Heading as="h3" className="text-sm font-medium"> {issues.length > 0 ? `${String(issues.length)} issues` : 'Issues'} - </h3> + </Heading> <Button type="button" onClick={() => setIssueCreateDialogOpen(true)} diff --git a/src/renderer/features/integrations/components/NotificationRules.tsx b/src/renderer/features/integrations/components/NotificationRules/NotificationRules.tsx similarity index 78% rename from src/renderer/features/integrations/components/NotificationRules.tsx rename to src/renderer/features/integrations/components/NotificationRules/NotificationRules.tsx index 45f63f38..f7db050c 100644 --- a/src/renderer/features/integrations/components/NotificationRules.tsx +++ b/src/renderer/features/integrations/components/NotificationRules/NotificationRules.tsx @@ -2,13 +2,12 @@ * NotificationRules — Configure notification filtering rules */ -import { useState } from 'react'; - import { Plus, Trash2 } from 'lucide-react'; import { Button, Checkbox, + Heading, Input, Select, SelectContent, @@ -17,31 +16,24 @@ import { SelectValue, } from '@ui'; -import { useIntegrationsStore } from '../store'; +import { useNotificationRules } from './useNotificationRules'; export function NotificationRules() { - const { notificationRules, addNotificationRule, removeNotificationRule, toggleNotificationRule } = - useIntegrationsStore(); - - const [newPattern, setNewPattern] = useState(''); - const [newService, setNewService] = useState<'slack' | 'discord'>('slack'); - - function handleAdd() { - const trimmed = newPattern.trim(); - if (trimmed.length === 0) return; - addNotificationRule({ service: newService, pattern: trimmed, enabled: true }); - setNewPattern(''); - } - - function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { - if (e.key === 'Enter') { - handleAdd(); - } - } + const { + notificationRules, + removeNotificationRule, + toggleNotificationRule, + newPattern, + setNewPattern, + newService, + setNewService, + handleAdd, + handleKeyDown, + } = useNotificationRules(); return ( <div className="bg-card border-border rounded-lg border p-4"> - <h3 className="text-foreground mb-3 text-sm font-semibold">Notification Rules</h3> + <Heading as="h3" className="mb-3 text-sm">Notification Rules</Heading> <p className="text-muted-foreground mb-4 text-xs"> Filter notifications by keyword pattern. Matching messages will trigger desktop alerts. </p> diff --git a/src/renderer/features/integrations/components/NotificationRules/index.ts b/src/renderer/features/integrations/components/NotificationRules/index.ts new file mode 100644 index 00000000..ef437d9e --- /dev/null +++ b/src/renderer/features/integrations/components/NotificationRules/index.ts @@ -0,0 +1 @@ +export { NotificationRules } from './NotificationRules'; diff --git a/src/renderer/features/integrations/components/NotificationRules/useNotificationRules.ts b/src/renderer/features/integrations/components/NotificationRules/useNotificationRules.ts new file mode 100644 index 00000000..57f0d581 --- /dev/null +++ b/src/renderer/features/integrations/components/NotificationRules/useNotificationRules.ts @@ -0,0 +1,40 @@ +/** + * useNotificationRules — Logic hook for NotificationRules + */ + +import { useState } from 'react'; + +import { useIntegrationsStore } from '../../store'; + +export function useNotificationRules() { + const { notificationRules, addNotificationRule, removeNotificationRule, toggleNotificationRule } = + useIntegrationsStore(); + + const [newPattern, setNewPattern] = useState(''); + const [newService, setNewService] = useState<'slack' | 'discord'>('slack'); + + function handleAdd(): void { + const trimmed = newPattern.trim(); + if (trimmed.length === 0) return; + addNotificationRule({ service: newService, pattern: trimmed, enabled: true }); + setNewPattern(''); + } + + function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>): void { + if (e.key === 'Enter') { + handleAdd(); + } + } + + return { + notificationRules, + removeNotificationRule, + toggleNotificationRule, + newPattern, + setNewPattern, + newService, + setNewService, + handleAdd, + handleKeyDown, + }; +} diff --git a/src/renderer/features/integrations/components/NotificationsPanel.tsx b/src/renderer/features/integrations/components/NotificationsPanel.tsx deleted file mode 100644 index 504a541d..00000000 --- a/src/renderer/features/integrations/components/NotificationsPanel.tsx +++ /dev/null @@ -1,285 +0,0 @@ -/** - * NotificationsPanel — Watcher status, config, and notification list management - */ - -import { useState } from 'react'; - -import { Bell } from 'lucide-react'; - -import type { NotificationSource } from '@shared/types/notifications'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Checkbox, - Input, - Label, - Spinner, - Text, -} from '@ui'; - -import { - useAllNotifications, - useMarkAllRead, - useMarkNotificationRead, - useNotificationsConfig, - useStartWatching, - useStopWatching, - useUpdateNotificationsConfig, - useWatcherStatus, -} from '../api/useNotifications'; - - -type WatcherStatusVariant = 'success' | 'error' | 'neutral'; - -interface WatcherDisplay { - variant: WatcherStatusVariant; - label: string; - dotClass: string; -} - -function getWatcherDisplay(isWatching: boolean, hasErrors: boolean): WatcherDisplay { - if (hasErrors) return { variant: 'error', label: 'Error', dotClass: 'bg-red-500' }; - if (isWatching) return { variant: 'success', label: 'Watching', dotClass: 'bg-green-500' }; - return { variant: 'neutral', label: 'Stopped', dotClass: 'bg-muted-foreground' }; -} - -function relativeTime(timestamp: string): string { - const diffMin = Math.floor((Date.now() - new Date(timestamp).getTime()) / 60_000); - if (diffMin < 1) return 'just now'; - if (diffMin < 60) return `${diffMin}m ago`; - const diffHr = Math.floor(diffMin / 60); - if (diffHr < 24) return `${diffHr}h ago`; - return `${Math.floor(diffHr / 24)}d ago`; -} - -export function NotificationsPanel() { - // 1. Hooks - const { data: watcherStatus, isLoading: statusLoading } = useWatcherStatus(); - const { data: config } = useNotificationsConfig(); - const { data: notifications, isLoading: notificationsLoading } = useAllNotifications(); - const startWatching = useStartWatching(); - const stopWatching = useStopWatching(); - const markRead = useMarkNotificationRead(); - const markAllRead = useMarkAllRead(); - const updateConfig = useUpdateNotificationsConfig(); - - // 2. Local config state - const [refreshInterval, setRefreshInterval] = useState( - config === undefined ? '60' : String(config.slack.pollIntervalSeconds), - ); - const [slackEnabled, setSlackEnabled] = useState(config === undefined ? false : config.slack.enabled); - const [githubEnabled, setGithubEnabled] = useState(config === undefined ? false : config.github.enabled); - - // 3. Derived state - const isWatching = watcherStatus === undefined ? false : watcherStatus.isWatching; - const hasErrors = Object.keys(watcherStatus?.errors ?? {}).length > 0; - const { label: statusLabel, dotClass: statusDotClass } = getWatcherDisplay(isWatching, hasErrors); - const unreadCount = (notifications ?? []).filter((n) => !n.read).length; - - // 4. Handlers - function handleSaveConfig(): void { - const interval = parseInt(refreshInterval, 10); - const ms = isNaN(interval) ? 60 : interval; - updateConfig.mutate({ - slack: { enabled: slackEnabled, pollIntervalSeconds: ms }, - github: { enabled: githubEnabled, pollIntervalSeconds: ms }, - }); - } - - function handleSourceToggle(source: NotificationSource, checked: boolean): void { - if (source === 'slack') setSlackEnabled(checked); - else setGithubEnabled(checked); - } - - // 5. Render helpers - function renderNotifications() { - if (notificationsLoading) { - return ( - <div className="flex justify-center py-4"> - <Spinner size="sm" /> - </div> - ); - } - - const items = notifications ?? []; - if (items.length === 0) { - return ( - <div className="flex flex-col items-center gap-2 py-8"> - <Bell className="text-muted-foreground h-8 w-8" /> - <Text className="text-muted-foreground" size="sm">No notifications</Text> - </div> - ); - } - - return ( - <div className="flex flex-col gap-2"> - {items.map((notification) => ( - <div - key={notification.id} - className={cn( - 'border-border flex items-start justify-between rounded-md border p-3', - notification.read ? 'opacity-60' : '', - )} - > - <div className="flex flex-col gap-1"> - <Text className="font-medium" size="sm"> - {notification.title} - </Text> - <Text className="text-muted-foreground" size="sm"> - {notification.body} - </Text> - <Text className="text-muted-foreground text-xs"> - {relativeTime(notification.timestamp)} - </Text> - </div> - {notification.read ? null : ( - <Button - size="sm" - type="button" - variant="ghost" - onClick={() => { markRead.mutate({ id: notification.id }); }} - > - Mark Read - </Button> - )} - </div> - ))} - </div> - ); - } - - return ( - <div className="flex flex-col gap-4"> - {/* Watcher status */} - <Card> - <CardHeader> - <CardTitle>Watcher Status</CardTitle> - </CardHeader> - <CardContent className="flex flex-col gap-3"> - {statusLoading ? ( - <Spinner size="sm" /> - ) : ( - <div className="flex items-center gap-2"> - <span className={cn('h-2.5 w-2.5 shrink-0 rounded-full', statusDotClass)} /> - <Text className="font-medium" size="sm">{statusLabel}</Text> - {watcherStatus !== undefined && watcherStatus.activeWatchers.length > 0 ? ( - <Text className="text-muted-foreground" size="sm"> - — {watcherStatus.activeWatchers.join(', ')} - </Text> - ) : null} - </div> - )} - <div className="flex gap-3"> - <Button - disabled={isWatching || startWatching.isPending} - size="sm" - type="button" - variant="primary" - onClick={() => { startWatching.mutate(); }} - > - {startWatching.isPending ? <Spinner size="sm" /> : null} - Start Watching - </Button> - <Button - disabled={!isWatching || stopWatching.isPending} - size="sm" - type="button" - variant="outline" - onClick={() => { stopWatching.mutate(); }} - > - {stopWatching.isPending ? <Spinner size="sm" /> : null} - Stop Watching - </Button> - </div> - </CardContent> - </Card> - - {/* Config form */} - <Card> - <CardHeader> - <CardTitle>Configuration</CardTitle> - </CardHeader> - <CardContent className="flex flex-col gap-3"> - <div> - <Text className="mb-2 font-medium" size="sm">Sources</Text> - <div className="flex flex-col gap-2"> - <div className="flex items-center gap-2"> - <Checkbox - checked={slackEnabled} - id="notifications-slack" - onCheckedChange={(checked) => { - handleSourceToggle('slack', checked === true); - }} - /> - <Label htmlFor="notifications-slack">Slack</Label> - </div> - <div className="flex items-center gap-2"> - <Checkbox - checked={githubEnabled} - id="notifications-github" - onCheckedChange={(checked) => { - handleSourceToggle('github', checked === true); - }} - /> - <Label htmlFor="notifications-github">GitHub</Label> - </div> - </div> - </div> - <div className="flex flex-col gap-1"> - <Label htmlFor="notifications-interval">Refresh Interval (seconds)</Label> - <Input - id="notifications-interval" - placeholder="60" - type="number" - value={refreshInterval} - onChange={(e) => { setRefreshInterval(e.target.value); }} - /> - </div> - <Button - className="self-start" - disabled={updateConfig.isPending} - size="sm" - type="button" - onClick={handleSaveConfig} - > - {updateConfig.isPending ? <Spinner size="sm" /> : null} - Save Config - </Button> - </CardContent> - </Card> - - {/* Notifications list */} - <Card> - <CardHeader> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - <CardTitle>Notifications</CardTitle> - {unreadCount > 0 ? ( - <Badge variant="secondary">{unreadCount}</Badge> - ) : null} - </div> - <Button - disabled={markAllRead.isPending || unreadCount === 0} - size="sm" - type="button" - variant="outline" - onClick={() => { markAllRead.mutate({}); }} - > - Mark All Read - </Button> - </div> - </CardHeader> - <CardContent> - {renderNotifications()} - </CardContent> - </Card> - </div> - ); -} diff --git a/src/renderer/features/integrations/components/NotificationsPanel/NotificationsPanel.tsx b/src/renderer/features/integrations/components/NotificationsPanel/NotificationsPanel.tsx new file mode 100644 index 00000000..7a8d781c --- /dev/null +++ b/src/renderer/features/integrations/components/NotificationsPanel/NotificationsPanel.tsx @@ -0,0 +1,231 @@ +/** + * NotificationsPanel — Watcher status, config, and notification list management + */ + +import { Bell } from 'lucide-react'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Checkbox, + Input, + Label, + Spinner, + Text, +} from '@ui'; + +import { useNotificationsPanel } from './useNotificationsPanel'; + +export function NotificationsPanel() { + const { + watcherStatus, + statusLoading, + notifications, + notificationsLoading, + startWatching, + stopWatching, + markRead, + markAllRead, + updateConfig, + refreshInterval, + setRefreshInterval, + slackEnabled, + githubEnabled, + isWatching, + statusLabel, + statusDotClass, + unreadCount, + handleSaveConfig, + handleSourceToggle, + } = useNotificationsPanel(); + + function renderNotifications() { + if (notificationsLoading) { + return ( + <div className="flex justify-center py-4"> + <Spinner size="sm" /> + </div> + ); + } + + const items = notifications ?? []; + if (items.length === 0) { + return ( + <div className="flex flex-col items-center gap-2 py-8"> + <Bell className="text-muted-foreground h-8 w-8" /> + <Text className="text-muted-foreground" size="sm">No notifications</Text> + </div> + ); + } + + return ( + <div className="flex flex-col gap-2"> + {items.map((notification) => ( + <div + key={notification.id} + className={cn( + 'border-border flex items-start justify-between rounded-md border p-3', + notification.read ? 'opacity-60' : '', + )} + > + <div className="flex flex-col gap-1"> + <Text className="font-medium" size="sm"> + {notification.title} + </Text> + <Text className="text-muted-foreground" size="sm"> + {notification.body} + </Text> + <Text className="text-muted-foreground text-xs"> + {notification.timestamp} + </Text> + </div> + {notification.read ? null : ( + <Button + size="sm" + type="button" + variant="ghost" + onClick={() => { markRead.mutate({ id: notification.id }); }} + > + Mark Read + </Button> + )} + </div> + ))} + </div> + ); + } + + return ( + <div className="flex flex-col gap-4"> + {/* Watcher status */} + <Card> + <CardHeader> + <CardTitle>Watcher Status</CardTitle> + </CardHeader> + <CardContent className="flex flex-col gap-3"> + {statusLoading ? ( + <Spinner size="sm" /> + ) : ( + <div className="flex items-center gap-2"> + <span className={cn('h-2.5 w-2.5 shrink-0 rounded-full', statusDotClass)} /> + <Text className="font-medium" size="sm">{statusLabel}</Text> + {watcherStatus !== undefined && watcherStatus.activeWatchers.length > 0 ? ( + <Text className="text-muted-foreground" size="sm"> + — {watcherStatus.activeWatchers.join(', ')} + </Text> + ) : null} + </div> + )} + <div className="flex gap-3"> + <Button + disabled={isWatching || startWatching.isPending} + size="sm" + type="button" + variant="primary" + onClick={() => { startWatching.mutate(); }} + > + {startWatching.isPending ? <Spinner size="sm" /> : null} + Start Watching + </Button> + <Button + disabled={!isWatching || stopWatching.isPending} + size="sm" + type="button" + variant="outline" + onClick={() => { stopWatching.mutate(); }} + > + {stopWatching.isPending ? <Spinner size="sm" /> : null} + Stop Watching + </Button> + </div> + </CardContent> + </Card> + + {/* Config form */} + <Card> + <CardHeader> + <CardTitle>Configuration</CardTitle> + </CardHeader> + <CardContent className="flex flex-col gap-3"> + <div> + <Text className="mb-2 font-medium" size="sm">Sources</Text> + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <Checkbox + checked={slackEnabled} + id="notifications-slack" + onCheckedChange={(checked) => { + handleSourceToggle('slack', checked === true); + }} + /> + <Label htmlFor="notifications-slack">Slack</Label> + </div> + <div className="flex items-center gap-2"> + <Checkbox + checked={githubEnabled} + id="notifications-github" + onCheckedChange={(checked) => { + handleSourceToggle('github', checked === true); + }} + /> + <Label htmlFor="notifications-github">GitHub</Label> + </div> + </div> + </div> + <div className="flex flex-col gap-1"> + <Label htmlFor="notifications-interval">Refresh Interval (seconds)</Label> + <Input + id="notifications-interval" + placeholder="60" + type="number" + value={refreshInterval} + onChange={(e) => { setRefreshInterval(e.target.value); }} + /> + </div> + <Button + className="self-start" + disabled={updateConfig.isPending} + size="sm" + type="button" + onClick={handleSaveConfig} + > + {updateConfig.isPending ? <Spinner size="sm" /> : null} + Save Config + </Button> + </CardContent> + </Card> + + {/* Notifications list */} + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <CardTitle>Notifications</CardTitle> + {unreadCount > 0 ? ( + <Badge variant="secondary">{unreadCount}</Badge> + ) : null} + </div> + <Button + disabled={markAllRead.isPending || unreadCount === 0} + size="sm" + type="button" + variant="outline" + onClick={() => { markAllRead.mutate({}); }} + > + Mark All Read + </Button> + </div> + </CardHeader> + <CardContent> + {renderNotifications()} + </CardContent> + </Card> + </div> + ); +} diff --git a/src/renderer/features/integrations/components/NotificationsPanel/index.ts b/src/renderer/features/integrations/components/NotificationsPanel/index.ts new file mode 100644 index 00000000..0fbbe8b4 --- /dev/null +++ b/src/renderer/features/integrations/components/NotificationsPanel/index.ts @@ -0,0 +1 @@ +export { NotificationsPanel } from './NotificationsPanel'; diff --git a/src/renderer/features/integrations/components/NotificationsPanel/useNotificationsPanel.ts b/src/renderer/features/integrations/components/NotificationsPanel/useNotificationsPanel.ts new file mode 100644 index 00000000..8de25fba --- /dev/null +++ b/src/renderer/features/integrations/components/NotificationsPanel/useNotificationsPanel.ts @@ -0,0 +1,99 @@ +/** + * useNotificationsPanel — Logic hook for NotificationsPanel + */ + +import { useState } from 'react'; + +import type { NotificationSource } from '@shared/types/notifications'; + +import { + useAllNotifications, + useMarkAllRead, + useMarkNotificationRead, + useNotificationsConfig, + useStartWatching, + useStopWatching, + useUpdateNotificationsConfig, + useWatcherStatus, +} from '../../api/useNotifications'; + +type WatcherStatusVariant = 'success' | 'error' | 'neutral'; + +interface WatcherDisplay { + variant: WatcherStatusVariant; + label: string; + dotClass: string; +} + +export function getWatcherDisplay(isWatching: boolean, hasErrors: boolean): WatcherDisplay { + if (hasErrors) return { variant: 'error', label: 'Error', dotClass: 'bg-red-500' }; + if (isWatching) return { variant: 'success', label: 'Watching', dotClass: 'bg-green-500' }; + return { variant: 'neutral', label: 'Stopped', dotClass: 'bg-muted-foreground' }; +} + +export function relativeTime(timestamp: string): string { + const diffMin = Math.floor((Date.now() - new Date(timestamp).getTime()) / 60_000); + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + return `${Math.floor(diffHr / 24)}d ago`; +} + +export function useNotificationsPanel() { + const { data: watcherStatus, isLoading: statusLoading } = useWatcherStatus(); + const { data: config } = useNotificationsConfig(); + const { data: notifications, isLoading: notificationsLoading } = useAllNotifications(); + const startWatching = useStartWatching(); + const stopWatching = useStopWatching(); + const markRead = useMarkNotificationRead(); + const markAllRead = useMarkAllRead(); + const updateConfig = useUpdateNotificationsConfig(); + + const [refreshInterval, setRefreshInterval] = useState( + config === undefined ? '60' : String(config.slack.pollIntervalSeconds), + ); + const [slackEnabled, setSlackEnabled] = useState(config === undefined ? false : config.slack.enabled); + const [githubEnabled, setGithubEnabled] = useState(config === undefined ? false : config.github.enabled); + + const isWatching = watcherStatus === undefined ? false : watcherStatus.isWatching; + const hasErrors = Object.keys(watcherStatus?.errors ?? {}).length > 0; + const { label: statusLabel, dotClass: statusDotClass } = getWatcherDisplay(isWatching, hasErrors); + const unreadCount = (notifications ?? []).filter((n) => !n.read).length; + + function handleSaveConfig(): void { + const interval = parseInt(refreshInterval, 10); + const ms = isNaN(interval) ? 60 : interval; + updateConfig.mutate({ + slack: { enabled: slackEnabled, pollIntervalSeconds: ms }, + github: { enabled: githubEnabled, pollIntervalSeconds: ms }, + }); + } + + function handleSourceToggle(source: NotificationSource, checked: boolean): void { + if (source === 'slack') setSlackEnabled(checked); + else setGithubEnabled(checked); + } + + return { + watcherStatus, + statusLoading, + notifications, + notificationsLoading, + startWatching, + stopWatching, + markRead, + markAllRead, + updateConfig, + refreshInterval, + setRefreshInterval, + slackEnabled, + githubEnabled, + isWatching, + statusLabel, + statusDotClass, + unreadCount, + handleSaveConfig, + handleSourceToggle, + }; +} diff --git a/src/renderer/features/integrations/components/PrDetailModal.tsx b/src/renderer/features/integrations/components/PrDetailModal.tsx index 3bb97af6..120deca3 100644 --- a/src/renderer/features/integrations/components/PrDetailModal.tsx +++ b/src/renderer/features/integrations/components/PrDetailModal.tsx @@ -4,7 +4,7 @@ import { GitMerge, GitPullRequest } from 'lucide-react'; -import { Badge, Dialog, DialogContent, DialogHeader, DialogTitle, ScrollArea, Text } from '@ui'; +import { Badge, Dialog, DialogContent, DialogHeader, DialogTitle, Heading, ScrollArea, Text } from '@ui'; import { useGitHubPrDetail } from '../api/useGitHub'; @@ -59,7 +59,7 @@ function PrDetailContent({ pr }: { pr: GitHubPr }) { <div> {/* Title and status */} <div className="mb-4 flex items-start gap-3"> - <h2 className="flex-1 text-lg font-semibold">{pr.title}</h2> + <Heading as="h2" className="flex-1 text-lg">{pr.title}</Heading> <Badge variant={getPrStatusVariant(pr)}>{getPrStatusLabel(pr)}</Badge> </div> diff --git a/src/renderer/features/integrations/components/PrDiffView.tsx b/src/renderer/features/integrations/components/PrDiffView.tsx index bdb12bb3..3d908427 100644 --- a/src/renderer/features/integrations/components/PrDiffView.tsx +++ b/src/renderer/features/integrations/components/PrDiffView.tsx @@ -11,6 +11,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger, + Separator, Skeleton, Spinner, Text, @@ -113,7 +114,8 @@ function DiffFileItem({ file }: { file: PrDiffFile }) { </CollapsibleTrigger> <CollapsibleContent> - <div className="border-border border-t"> + <Separator /> + <div> {hasPatch ? ( <pre className="overflow-x-auto p-0 font-mono text-xs leading-5"> {lines.map(({ lineKey, content }) => { diff --git a/src/renderer/features/integrations/components/SlackActionModal.tsx b/src/renderer/features/integrations/components/SlackActionModal.tsx deleted file mode 100644 index d869db56..00000000 --- a/src/renderer/features/integrations/components/SlackActionModal.tsx +++ /dev/null @@ -1,292 +0,0 @@ -/** - * SlackActionModal — Modal for Slack action input - */ - -import type { ChangeEvent } from 'react'; -import { useState } from 'react'; - -import * as Dialog from '@radix-ui/react-dialog'; -import { X } from 'lucide-react'; - -import { Button, Input, Label, Textarea } from '@ui'; - -import { useMcpToolCall } from '../api/useMcpTool'; - -export type SlackActionType = 'send_message' | 'read_channel' | 'search' | 'set_status'; - -interface SlackActionModalProps { - actionType: SlackActionType | null; - onClose: () => void; -} - -interface FormState { - channel: string; - text: string; - query: string; - statusText: string; - statusEmoji: string; -} - -const ACTION_LABELS: Record<SlackActionType, string> = { - send_message: 'Send Message', - read_channel: 'Read Channel', - search: 'Search Workspace', - set_status: 'Set Status', -}; - -const ACTION_DESCRIPTIONS: Record<SlackActionType, string> = { - send_message: 'Send a message to a Slack channel or DM', - read_channel: 'View recent messages from a channel', - search: 'Search messages across your workspace', - set_status: 'Update your Slack status', -}; - -export function SlackActionModal({ actionType, onClose }: SlackActionModalProps) { - const [form, setForm] = useState<FormState>({ - channel: '', - text: '', - query: '', - statusText: '', - statusEmoji: ':house:', - }); - const [result, setResult] = useState<string | null>(null); - const [error, setError] = useState<string | null>(null); - - const mcpCall = useMcpToolCall(); - - function handleInputChange( - field: keyof FormState, - e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, - ): void { - setForm((prev) => ({ ...prev, [field]: e.target.value })); - setError(null); - setResult(null); - } - - async function handleSubmit(): Promise<void> { - if (!actionType) return; - - setError(null); - setResult(null); - - try { - let toolName: string; - let args: Record<string, unknown>; - - switch (actionType) { - case 'send_message': - toolName = 'slack_send_message'; - args = { channel: form.channel, text: form.text }; - break; - case 'read_channel': - toolName = 'slack_read_channel'; - args = { channel: form.channel, limit: 20 }; - break; - case 'search': - toolName = 'slack_search'; - args = { query: form.query, count: 20 }; - break; - case 'set_status': - toolName = 'slack_set_status'; - args = { text: form.statusText, emoji: form.statusEmoji }; - break; - default: - return; - } - - const response = await mcpCall.mutateAsync({ - server: 'slack', - tool: toolName, - args, - }); - - if (response.isError) { - const errorText = response.content[0]?.text ?? 'Unknown error'; - setError(errorText); - } else { - const resultText = response.content[0]?.text ?? 'Success'; - setResult(resultText); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setError(message); - } - } - - function renderForm(): React.ReactNode { - if (!actionType) return null; - - switch (actionType) { - case 'send_message': - return ( - <> - <div> - <Label htmlFor="slack-channel">Channel</Label> - <Input - className="mt-1" - id="slack-channel" - placeholder="#general or C1234567890" - type="text" - value={form.channel} - onChange={(e: ChangeEvent<HTMLInputElement>) => { - handleInputChange('channel', e); - }} - /> - </div> - <div> - <Label htmlFor="slack-text">Message</Label> - <Textarea - className="mt-1" - id="slack-text" - placeholder="Your message..." - rows={3} - value={form.text} - onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { - handleInputChange('text', e); - }} - /> - </div> - </> - ); - - case 'read_channel': - return ( - <div> - <Label htmlFor="slack-channel">Channel</Label> - <Input - className="mt-1" - id="slack-channel" - placeholder="#general or C1234567890" - type="text" - value={form.channel} - onChange={(e: ChangeEvent<HTMLInputElement>) => { - handleInputChange('channel', e); - }} - /> - </div> - ); - - case 'search': - return ( - <div> - <Label htmlFor="slack-query">Search Query</Label> - <Input - className="mt-1" - id="slack-query" - placeholder="Search for messages..." - type="text" - value={form.query} - onChange={(e: ChangeEvent<HTMLInputElement>) => { - handleInputChange('query', e); - }} - /> - </div> - ); - - case 'set_status': - return ( - <> - <div> - <Label htmlFor="slack-status-text">Status Text</Label> - <Input - className="mt-1" - id="slack-status-text" - placeholder="Working from home" - type="text" - value={form.statusText} - onChange={(e: ChangeEvent<HTMLInputElement>) => { - handleInputChange('statusText', e); - }} - /> - </div> - <div> - <Label htmlFor="slack-status-emoji">Status Emoji</Label> - <Input - className="mt-1" - id="slack-status-emoji" - placeholder=":house:" - type="text" - value={form.statusEmoji} - onChange={(e: ChangeEvent<HTMLInputElement>) => { - handleInputChange('statusEmoji', e); - }} - /> - </div> - </> - ); - - default: - return null; - } - } - - return ( - <Dialog.Root open={actionType !== null} onOpenChange={(isOpen) => !isOpen && onClose()}> - <Dialog.Portal> - <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" /> - <Dialog.Content - className="bg-background border-border fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 max-h-[80vh] overflow-y-auto rounded-lg border shadow-2xl" - > - {/* Header */} - <div className="border-border flex items-center justify-between border-b p-4"> - <div> - <Dialog.Title className="text-lg font-semibold"> - {actionType ? ACTION_LABELS[actionType] : ''} - </Dialog.Title> - <Dialog.Description className="text-muted-foreground text-sm"> - {actionType ? ACTION_DESCRIPTIONS[actionType] : ''} - </Dialog.Description> - </div> - <Dialog.Close asChild> - <Button - aria-label="Close" - size="icon" - type="button" - variant="ghost" - > - <X className="h-4 w-4" /> - </Button> - </Dialog.Close> - </div> - - {/* Form */} - <div className="space-y-4 p-4">{renderForm()}</div> - - {/* Error */} - {error ? ( - <div className="bg-destructive/10 text-destructive mx-4 rounded-md p-3 text-sm"> - {error} - </div> - ) : null} - - {/* Result */} - {result ? ( - <div className="bg-muted mx-4 max-h-40 overflow-y-auto rounded-md p-3"> - <pre className="text-muted-foreground text-xs whitespace-pre-wrap">{result}</pre> - </div> - ) : null} - - {/* Actions */} - <div className="border-border flex justify-end gap-2 border-t p-4"> - <Button - type="button" - variant="outline" - onClick={onClose} - > - Close - </Button> - <Button - disabled={mcpCall.isPending} - type="button" - onClick={() => { - void handleSubmit(); - }} - > - {mcpCall.isPending ? 'Processing...' : 'Execute'} - </Button> - </div> - </Dialog.Content> - </Dialog.Portal> - </Dialog.Root> - ); -} diff --git a/src/renderer/features/integrations/components/SlackActionModal/SlackActionModal.tsx b/src/renderer/features/integrations/components/SlackActionModal/SlackActionModal.tsx new file mode 100644 index 00000000..12220b7e --- /dev/null +++ b/src/renderer/features/integrations/components/SlackActionModal/SlackActionModal.tsx @@ -0,0 +1,220 @@ +/** + * SlackActionModal — Modal for Slack action input + */ + +import type { ChangeEvent } from 'react'; + +import * as Dialog from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import { Button, Input, Label, Separator, Textarea } from '@ui'; + +import { useSlackActionModal } from './useSlackActionModal'; + +import type { SlackActionType } from './useSlackActionModal'; + + +export type { SlackActionType }; + +interface SlackActionModalProps { + actionType: SlackActionType | null; + onClose: () => void; +} + +const ACTION_LABELS: Record<SlackActionType, string> = { + send_message: 'Send Message', + read_channel: 'Read Channel', + search: 'Search Workspace', + set_status: 'Set Status', +}; + +const ACTION_DESCRIPTIONS: Record<SlackActionType, string> = { + send_message: 'Send a message to a Slack channel or DM', + read_channel: 'View recent messages from a channel', + search: 'Search messages across your workspace', + set_status: 'Update your Slack status', +}; + +export function SlackActionModal({ actionType, onClose }: SlackActionModalProps) { + const { form, result, error, mcpCall, handleInputChange, handleSubmit } = + useSlackActionModal(actionType); + + function renderForm(): React.ReactNode { + if (!actionType) return null; + + switch (actionType) { + case 'send_message': + return ( + <> + <div> + <Label htmlFor="slack-channel">Channel</Label> + <Input + className="mt-1" + id="slack-channel" + placeholder="#general or C1234567890" + type="text" + value={form.channel} + onChange={(e: ChangeEvent<HTMLInputElement>) => { + handleInputChange('channel', e); + }} + /> + </div> + <div> + <Label htmlFor="slack-text">Message</Label> + <Textarea + className="mt-1" + id="slack-text" + placeholder="Your message..." + rows={3} + value={form.text} + onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { + handleInputChange('text', e); + }} + /> + </div> + </> + ); + + case 'read_channel': + return ( + <div> + <Label htmlFor="slack-channel">Channel</Label> + <Input + className="mt-1" + id="slack-channel" + placeholder="#general or C1234567890" + type="text" + value={form.channel} + onChange={(e: ChangeEvent<HTMLInputElement>) => { + handleInputChange('channel', e); + }} + /> + </div> + ); + + case 'search': + return ( + <div> + <Label htmlFor="slack-query">Search Query</Label> + <Input + className="mt-1" + id="slack-query" + placeholder="Search for messages..." + type="text" + value={form.query} + onChange={(e: ChangeEvent<HTMLInputElement>) => { + handleInputChange('query', e); + }} + /> + </div> + ); + + case 'set_status': + return ( + <> + <div> + <Label htmlFor="slack-status-text">Status Text</Label> + <Input + className="mt-1" + id="slack-status-text" + placeholder="Working from home" + type="text" + value={form.statusText} + onChange={(e: ChangeEvent<HTMLInputElement>) => { + handleInputChange('statusText', e); + }} + /> + </div> + <div> + <Label htmlFor="slack-status-emoji">Status Emoji</Label> + <Input + className="mt-1" + id="slack-status-emoji" + placeholder=":house:" + type="text" + value={form.statusEmoji} + onChange={(e: ChangeEvent<HTMLInputElement>) => { + handleInputChange('statusEmoji', e); + }} + /> + </div> + </> + ); + + default: + return null; + } + } + + return ( + <Dialog.Root open={actionType !== null} onOpenChange={(isOpen) => !isOpen && onClose()}> + <Dialog.Portal> + <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" /> + <Dialog.Content + className="bg-background border-border fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 max-h-[80vh] overflow-y-auto rounded-lg border shadow-2xl" + > + {/* Header */} + <div className="flex items-center justify-between p-4"> + <div> + <Dialog.Title className="text-lg font-semibold"> + {actionType ? ACTION_LABELS[actionType] : ''} + </Dialog.Title> + <Dialog.Description className="text-muted-foreground text-sm"> + {actionType ? ACTION_DESCRIPTIONS[actionType] : ''} + </Dialog.Description> + </div> + <Dialog.Close asChild> + <Button + aria-label="Close" + size="icon" + type="button" + variant="ghost" + > + <X className="h-4 w-4" /> + </Button> + </Dialog.Close> + </div> + <Separator /> + + {/* Form */} + <div className="space-y-4 p-4">{renderForm()}</div> + + {/* Error */} + {error ? ( + <div className="bg-destructive/10 text-destructive mx-4 rounded-md p-3 text-sm"> + {error} + </div> + ) : null} + + {/* Result */} + {result ? ( + <div className="bg-muted mx-4 max-h-40 overflow-y-auto rounded-md p-3"> + <pre className="text-muted-foreground text-xs whitespace-pre-wrap">{result}</pre> + </div> + ) : null} + + {/* Actions */} + <Separator /> + <div className="flex justify-end gap-2 p-4"> + <Button + type="button" + variant="outline" + onClick={onClose} + > + Close + </Button> + <Button + disabled={mcpCall.isPending} + type="button" + onClick={() => { + void handleSubmit(); + }} + > + {mcpCall.isPending ? 'Processing...' : 'Execute'} + </Button> + </div> + </Dialog.Content> + </Dialog.Portal> + </Dialog.Root> + ); +} diff --git a/src/renderer/features/integrations/components/SlackActionModal/index.ts b/src/renderer/features/integrations/components/SlackActionModal/index.ts new file mode 100644 index 00000000..85689322 --- /dev/null +++ b/src/renderer/features/integrations/components/SlackActionModal/index.ts @@ -0,0 +1,2 @@ +export { SlackActionModal } from './SlackActionModal'; +export type { SlackActionType } from './useSlackActionModal'; diff --git a/src/renderer/features/integrations/components/SlackActionModal/useSlackActionModal.ts b/src/renderer/features/integrations/components/SlackActionModal/useSlackActionModal.ts new file mode 100644 index 00000000..fb494342 --- /dev/null +++ b/src/renderer/features/integrations/components/SlackActionModal/useSlackActionModal.ts @@ -0,0 +1,60 @@ +/** + * useSlackActionModal — Logic hook for SlackActionModal + */ + +import { useModalToolAction } from '../../hooks/useModalToolAction'; + +export type SlackActionType = 'send_message' | 'read_channel' | 'search' | 'set_status'; + +export interface SlackFormState { + channel: string; + text: string; + query: string; + statusText: string; + statusEmoji: string; +} + +export function useSlackActionModal(actionType: SlackActionType | null) { + const { + form, + result, + error, + mcpCall, + handleInputChange, + handleSubmit: submitTool, + } = useModalToolAction<SlackFormState>({ + channel: '', + text: '', + query: '', + statusText: '', + statusEmoji: ':house:', + }); + + async function handleSubmit(): Promise<void> { + if (!actionType) return; + + await submitTool((f) => { + switch (actionType) { + case 'send_message': + return { server: 'slack', tool: 'slack_send_message', args: { channel: f.channel, text: f.text } }; + case 'read_channel': + return { server: 'slack', tool: 'slack_read_channel', args: { channel: f.channel, limit: 20 } }; + case 'search': + return { server: 'slack', tool: 'slack_search', args: { query: f.query, count: 20 } }; + case 'set_status': + return { server: 'slack', tool: 'slack_set_status', args: { text: f.statusText, emoji: f.statusEmoji } }; + default: + return null; + } + }); + } + + return { + form, + result, + error, + mcpCall, + handleInputChange, + handleSubmit, + }; +} diff --git a/src/renderer/features/integrations/components/SlackPanel.tsx b/src/renderer/features/integrations/components/SlackPanel/SlackPanel.tsx similarity index 80% rename from src/renderer/features/integrations/components/SlackPanel.tsx rename to src/renderer/features/integrations/components/SlackPanel/SlackPanel.tsx index 9d9df39c..6d3549ab 100644 --- a/src/renderer/features/integrations/components/SlackPanel.tsx +++ b/src/renderer/features/integrations/components/SlackPanel/SlackPanel.tsx @@ -2,20 +2,18 @@ * SlackPanel — Quick Slack actions and status display */ -import { useState } from 'react'; - -import { useNavigate } from '@tanstack/react-router'; import { Hash, MessageSquare, Search, Settings, UserCircle } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; import { Button, StatusIndicator } from '@ui'; -import { useIntegrationsStore } from '../store'; -import { SlackActionModal } from './SlackActionModal'; +import { SlackActionModal } from '../SlackActionModal'; + +import { useSlackPanel } from './useSlackPanel'; -import type { SlackActionType } from './SlackActionModal'; +import type { SlackActionType } from '../SlackActionModal'; type ServiceStatus = 'connected' | 'disconnected' | 'error'; @@ -60,25 +58,8 @@ const slackActions: QuickAction[] = [ ]; export function SlackPanel() { - const { slackStatus } = useIntegrationsStore(); - const navigate = useNavigate(); - const [activeAction, setActiveAction] = useState<SlackActionType | null>(null); - - function handleAction(actionType: SlackActionType): void { - if (slackStatus !== 'connected') { - void navigate({ to: '/settings' }); - return; - } - setActiveAction(actionType); - } - - function handleConnect(): void { - void navigate({ to: '/settings' }); - } - - function handleCloseModal(): void { - setActiveAction(null); - } + const { slackStatus, activeAction, handleAction, handleConnect, handleCloseModal } = + useSlackPanel(); return ( <> diff --git a/src/renderer/features/integrations/components/SlackPanel/index.ts b/src/renderer/features/integrations/components/SlackPanel/index.ts new file mode 100644 index 00000000..ff14d54d --- /dev/null +++ b/src/renderer/features/integrations/components/SlackPanel/index.ts @@ -0,0 +1 @@ +export { SlackPanel } from './SlackPanel'; diff --git a/src/renderer/features/integrations/components/SlackPanel/useSlackPanel.ts b/src/renderer/features/integrations/components/SlackPanel/useSlackPanel.ts new file mode 100644 index 00000000..92a1207d --- /dev/null +++ b/src/renderer/features/integrations/components/SlackPanel/useSlackPanel.ts @@ -0,0 +1,26 @@ +/** + * useSlackPanel — Logic hook for SlackPanel + */ + +import { useConnectedServicePanel } from '../../hooks/useConnectedServicePanel'; +import { useIntegrationsStore } from '../../store'; + +import type { SlackActionType } from '../SlackActionModal'; + +export function useSlackPanel() { + const { slackStatus } = useIntegrationsStore(); + + const { status, activeAction, handleAction, handleConnect, handleCloseModal } = + useConnectedServicePanel<typeof slackStatus, SlackActionType>({ + status: slackStatus, + connectedValue: 'connected', + }); + + return { + slackStatus: status, + activeAction, + handleAction, + handleConnect, + handleCloseModal, + }; +} diff --git a/src/renderer/features/integrations/hooks/useConnectedServicePanel.ts b/src/renderer/features/integrations/hooks/useConnectedServicePanel.ts new file mode 100644 index 00000000..1da61f32 --- /dev/null +++ b/src/renderer/features/integrations/hooks/useConnectedServicePanel.ts @@ -0,0 +1,51 @@ +/** + * useConnectedServicePanel — shared connection check + modal state + navigation. + * + * Extracts the identical pattern from DiscordPanel and SlackPanel: + * check connection status, gate actions behind connected state, navigate to settings. + */ + +import { useState } from 'react'; + +import { useNavigate } from '@tanstack/react-router'; + +import { ROUTES } from '@shared/constants'; + +interface UseConnectedServicePanelOptions<TStatus extends string> { + /** Current connection status from the integrations store */ + status: TStatus; + /** The status value that indicates a connected state */ + connectedValue: TStatus; +} + +export function useConnectedServicePanel< + TStatus extends string, + TAction extends string, +>({ status, connectedValue }: UseConnectedServicePanelOptions<TStatus>) { + const navigate = useNavigate(); + const [activeAction, setActiveAction] = useState<TAction | null>(null); + + function handleAction(actionType: TAction): void { + if (status !== connectedValue) { + void navigate({ to: ROUTES.SETTINGS }); + return; + } + setActiveAction(actionType); + } + + function handleConnect(): void { + void navigate({ to: ROUTES.SETTINGS }); + } + + function handleCloseModal(): void { + setActiveAction(null); + } + + return { + status, + activeAction, + handleAction, + handleConnect, + handleCloseModal, + }; +} diff --git a/src/renderer/features/integrations/hooks/useIntegrationsEvents.ts b/src/renderer/features/integrations/hooks/useIntegrationsEvents.ts deleted file mode 100644 index 178bdd20..00000000 --- a/src/renderer/features/integrations/hooks/useIntegrationsEvents.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Integrations event listeners - * - * Subscribes to Hub connection state changes and updates - * the integrations store with current service statuses. - */ - -import { HUB_EVENTS } from '@shared/ipc/hub/channels'; - -import { useIpcEvent } from '@renderer/shared/hooks'; - -import { useIntegrationsStore } from '../store'; - -export function useIntegrationsEvents() { - const setSlackStatus = useIntegrationsStore((s) => s.setSlackStatus); - const setDiscordStatus = useIntegrationsStore((s) => s.setDiscordStatus); - - useIpcEvent(HUB_EVENTS.CONNECTION.CHANGED, ({ status }) => { - if (status === 'connected') { - setSlackStatus('connected'); - setDiscordStatus('connected'); - } else if (status === 'disconnected' || status === 'error') { - setSlackStatus('disconnected'); - setDiscordStatus('disconnected'); - } - }); -} diff --git a/src/renderer/features/integrations/hooks/useModalToolAction.ts b/src/renderer/features/integrations/hooks/useModalToolAction.ts new file mode 100644 index 00000000..c248bd89 --- /dev/null +++ b/src/renderer/features/integrations/hooks/useModalToolAction.ts @@ -0,0 +1,78 @@ +/** + * useModalToolAction — shared MCP tool execution pattern for action modals. + * + * Extracts the form state, result/error tracking, input change handler, and + * submit-via-mcpCall logic duplicated in DiscordActionModal and SlackActionModal. + */ + +import type { ChangeEvent } from 'react'; +import { useState } from 'react'; + +import { useMcpToolCall } from '../api/useMcpTool'; + +interface ToolCall { + server: string; + tool: string; + args: Record<string, unknown>; +} + +export function useModalToolAction<TForm extends object>(initialForm: TForm) { + const [form, setForm] = useState<TForm>(initialForm); + const [result, setResult] = useState<string | null>(null); + const [error, setError] = useState<string | null>(null); + + const mcpCall = useMcpToolCall(); + + function handleInputChange( + field: keyof TForm, + e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, + ): void { + setForm((prev) => ({ ...prev, [field]: e.target.value })); + setError(null); + setResult(null); + } + + function updateField<K extends keyof TForm>(field: K, value: TForm[K]): void { + setForm((prev) => ({ ...prev, [field]: value })); + setError(null); + setResult(null); + } + + async function handleSubmit(buildCall: (form: TForm) => ToolCall | null): Promise<void> { + const call = buildCall(form); + if (!call) return; + + setError(null); + setResult(null); + + try { + const response = await mcpCall.mutateAsync({ + server: call.server, + tool: call.tool, + args: call.args, + }); + + if (response.isError) { + const errorText = response.content[0]?.text ?? 'Unknown error'; + setError(errorText); + } else { + const resultText = response.content[0]?.text ?? 'Success'; + setResult(resultText); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + } + } + + return { + form, + setForm, + result, + error, + mcpCall, + handleInputChange, + updateField, + handleSubmit, + }; +} diff --git a/src/renderer/features/integrations/index.ts b/src/renderer/features/integrations/index.ts index ec0b6e5e..d1b945b1 100644 --- a/src/renderer/features/integrations/index.ts +++ b/src/renderer/features/integrations/index.ts @@ -57,6 +57,5 @@ export { } from './api/useNotifications'; // Events / Hooks -export { useIntegrationsEvents } from './hooks/useIntegrationsEvents'; export { useGitHubEvents } from './hooks/useGitHubEvents'; export { useGitHubProjectSync } from './hooks/useGitHubProjectSync'; diff --git a/src/renderer/features/merge/api/useMerge.ts b/src/renderer/features/merge/api/useMerge.ts index 8f970a07..83e29687 100644 --- a/src/renderer/features/merge/api/useMerge.ts +++ b/src/renderer/features/merge/api/useMerge.ts @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { MERGE } from '@shared/ipc/misc/merge.channels'; +import { MERGE } from '@shared/ipc/merge'; import { ipc } from '@renderer/shared/lib/ipc'; @@ -12,7 +12,7 @@ import { gitKeys } from '@features/projects'; import { mergeKeys } from './queryKeys'; -interface MergeBranchParams { +export interface MergeBranchParams { repoPath: string; sourceBranch: string; targetBranch: string; diff --git a/src/renderer/features/merge/components/ConflictResolver.tsx b/src/renderer/features/merge/components/ConflictResolver/ConflictResolver.tsx similarity index 77% rename from src/renderer/features/merge/components/ConflictResolver.tsx rename to src/renderer/features/merge/components/ConflictResolver/ConflictResolver.tsx index 876cf918..bc8a6ac5 100644 --- a/src/renderer/features/merge/components/ConflictResolver.tsx +++ b/src/renderer/features/merge/components/ConflictResolver/ConflictResolver.tsx @@ -6,8 +6,6 @@ * for future IPC channel support. */ -import { useMemo, useState } from 'react'; - import { DiffModeEnum } from '@git-diff-view/react'; import { AlertTriangle, @@ -20,13 +18,15 @@ import { } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; -import { useThemeStore } from '@renderer/shared/stores/theme-store'; -import { Button, ScrollArea } from '@ui'; +import { Button, ScrollArea, Separator } from '@ui'; + +import { useFileDiff } from '../../api/useMerge'; +import { FileDiffViewer } from '../FileDiffViewer'; -import { useFileDiff, useMergeConflicts } from '../api/useMerge'; +import { getFileLang, useConflictResolver } from './useConflictResolver'; -import { FileDiffViewer } from './FileDiffViewer'; +import type { ConflictFileStatus } from './useConflictResolver'; interface ConflictResolverProps { repoPath: string; @@ -35,29 +35,6 @@ interface ConflictResolverProps { onOpenTerminal?: (file: string) => void; } -interface ConflictFileStatus { - resolved: boolean; -} - -/** Extract a language hint from a file path for syntax highlighting */ -function getFileLang(filePath: string): string { - const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; - const langMap: Record<string, string> = { - ts: 'typescript', - tsx: 'tsx', - js: 'javascript', - jsx: 'jsx', - json: 'json', - css: 'css', - html: 'xml', - md: 'markdown', - py: 'python', - rs: 'rust', - go: 'go', - }; - return langMap[ext] ?? 'plaintext'; -} - function ConflictFileDiff({ filePath, isDark, @@ -112,48 +89,19 @@ export function ConflictResolver({ onOpenTerminal, }: ConflictResolverProps) { const { - data: conflicts, + conflicts, isLoading, error, - } = useMergeConflicts(repoPath, sourceBranch, targetBranch); - const [fileStatuses, setFileStatuses] = useState<Record<string, ConflictFileStatus>>({}); - const [expandedFile, setExpandedFile] = useState<string | null>(null); - - const themeMode = useThemeStore((s) => s.mode); - const isDark = useMemo(() => { - if (themeMode === 'system') { - return window.matchMedia('(prefers-color-scheme: dark)').matches; - } - return themeMode === 'dark'; - }, [themeMode]); - - function handleMarkResolved(file: string) { - setFileStatuses((prev) => ({ - ...prev, - [file]: { resolved: true }, - })); - } - - function handleOpenInEditor(file: string) { - if (onOpenTerminal) { - onOpenTerminal(file); - } - } - - function handleToggleExpand(file: string) { - setExpandedFile((prev) => (prev === file ? null : file)); - } - - function handleAcceptOurs(_file: string) { - // Placeholder: would call a new IPC channel in the future - // For now, just show intent via mark resolved - handleMarkResolved(_file); - } - - function handleAcceptTheirs(_file: string) { - // Placeholder: would call a new IPC channel in the future - handleMarkResolved(_file); - } + fileStatuses, + expandedFile, + isDark, + resolvedCount, + allResolved, + handleMarkResolved, + handleToggleExpand, + handleAcceptOurs, + handleAcceptTheirs, + } = useConflictResolver({ repoPath, sourceBranch, targetBranch }); if (isLoading) { return ( @@ -181,9 +129,6 @@ export function ConflictResolver({ ); } - const resolvedCount = Object.values(fileStatuses).filter((s) => s.resolved).length; - const allResolved = resolvedCount === conflicts.length; - return ( <div className="space-y-4"> {/* Header with conflict count */} @@ -271,7 +216,11 @@ export function ConflictResolver({ className="h-auto gap-1 px-2 py-0.5 text-xs" size="sm" variant="ghost" - onClick={() => handleOpenInEditor(file)} + onClick={() => { + if (onOpenTerminal) { + onOpenTerminal(file); + } + }} > <ExternalLink className="h-3 w-3" /> Open @@ -292,7 +241,8 @@ export function ConflictResolver({ {/* Expanded diff view */} {isExpanded ? ( - <div className="border-border border-t"> + <div> + <Separator /> <ConflictFileDiff filePath={file} isDark={isDark} diff --git a/src/renderer/features/merge/components/ConflictResolver/index.ts b/src/renderer/features/merge/components/ConflictResolver/index.ts new file mode 100644 index 00000000..1a9e48d0 --- /dev/null +++ b/src/renderer/features/merge/components/ConflictResolver/index.ts @@ -0,0 +1 @@ +export { ConflictResolver } from './ConflictResolver'; diff --git a/src/renderer/features/merge/components/ConflictResolver/useConflictResolver.ts b/src/renderer/features/merge/components/ConflictResolver/useConflictResolver.ts new file mode 100644 index 00000000..c66605bb --- /dev/null +++ b/src/renderer/features/merge/components/ConflictResolver/useConflictResolver.ts @@ -0,0 +1,71 @@ +/** + * useConflictResolver — Logic hook for ConflictResolver + */ + +import { useState } from 'react'; + +import { useDarkMode } from '@renderer/shared/hooks/useDarkMode'; +import { useFileLang } from '@renderer/shared/hooks/useFileLangMap'; + +import { useMergeConflicts } from '../../api/useMerge'; + +export { useFileLang as getFileLang }; + +export interface ConflictFileStatus { + resolved: boolean; +} + +interface UseConflictResolverParams { + repoPath: string; + sourceBranch: string; + targetBranch: string; +} + +export function useConflictResolver({ repoPath, sourceBranch, targetBranch }: UseConflictResolverParams) { + const { + data: conflicts, + isLoading, + error, + } = useMergeConflicts(repoPath, sourceBranch, targetBranch); + const [fileStatuses, setFileStatuses] = useState<Record<string, ConflictFileStatus>>({}); + const [expandedFile, setExpandedFile] = useState<string | null>(null); + + const isDark = useDarkMode(); + + function handleMarkResolved(file: string): void { + setFileStatuses((prev) => ({ + ...prev, + [file]: { resolved: true }, + })); + } + + function handleToggleExpand(file: string): void { + setExpandedFile((prev) => (prev === file ? null : file)); + } + + function handleAcceptOurs(file: string): void { + handleMarkResolved(file); + } + + function handleAcceptTheirs(file: string): void { + handleMarkResolved(file); + } + + const resolvedCount = Object.values(fileStatuses).filter((s) => s.resolved).length; + const allResolved = conflicts !== undefined && conflicts.length > 0 && resolvedCount === conflicts.length; + + return { + conflicts, + isLoading, + error, + fileStatuses, + expandedFile, + isDark, + resolvedCount, + allResolved, + handleMarkResolved, + handleToggleExpand, + handleAcceptOurs, + handleAcceptTheirs, + }; +} diff --git a/src/renderer/features/merge/components/MergeConfirmModal.tsx b/src/renderer/features/merge/components/MergeConfirmModal/MergeConfirmModal.tsx similarity index 75% rename from src/renderer/features/merge/components/MergeConfirmModal.tsx rename to src/renderer/features/merge/components/MergeConfirmModal/MergeConfirmModal.tsx index 30a4df02..d6fc5d7c 100644 --- a/src/renderer/features/merge/components/MergeConfirmModal.tsx +++ b/src/renderer/features/merge/components/MergeConfirmModal/MergeConfirmModal.tsx @@ -5,8 +5,6 @@ * The Changes tab houses MergePreviewPanel with the full diff viewer. */ -import { useState } from 'react'; - import { AlertTriangle, ArrowRight, GitMerge, Loader2 } from 'lucide-react'; import { @@ -16,16 +14,17 @@ import { DialogHeader, DialogTitle, ScrollArea, + Separator, Tabs, TabsContent, TabsList, TabsTrigger, } from '@ui'; -import { useMergeBranch, useMergeConflicts, useMergeDiff } from '../api/useMerge'; +import { ConflictResolver } from '../ConflictResolver'; +import { MergePreviewPanel } from '../MergePreviewPanel'; -import { ConflictResolver } from './ConflictResolver'; -import { MergePreviewPanel } from './MergePreviewPanel'; +import { useMergeConfirmModal } from './useMergeConfirmModal'; interface MergeConfirmModalProps { repoPath: string; @@ -46,23 +45,18 @@ export function MergeConfirmModal({ onSuccess, onOpenTerminal, }: MergeConfirmModalProps) { - const [mergeError, setMergeError] = useState<string | null>(null); - - const mergeBranch = useMergeBranch(); - const { data: conflicts, isLoading: isConflictsLoading } = useMergeConflicts( - isOpen ? repoPath : null, - isOpen ? sourceBranch : null, - isOpen ? targetBranch : null, - ); - const { data: diff, isLoading: isDiffLoading } = useMergeDiff( - isOpen ? repoPath : null, - isOpen ? sourceBranch : null, - isOpen ? targetBranch : null, - ); - - const hasConflicts = (conflicts?.length ?? 0) > 0; - const hasChanges = (diff?.changedFiles ?? 0) > 0; - const isDataLoading = isConflictsLoading || isDiffLoading; + const { + mergeError, + mergeBranch, + diff, + hasConflicts, + hasChanges, + isDataLoading, + conflictsBadge, + changesBadge, + handleMerge, + handleOpenChange, + } = useMergeConfirmModal({ repoPath, sourceBranch, targetBranch, isOpen, onClose, onSuccess }); function getFooterMessage(): React.ReactNode { if (isDataLoading) { @@ -87,36 +81,6 @@ export function MergeConfirmModal({ return <span>No changes to merge</span>; } - function handleMerge() { - setMergeError(null); - mergeBranch.mutate( - { repoPath, sourceBranch, targetBranch }, - { - onSuccess: (result) => { - if (result.success) { - onSuccess?.(); - onClose(); - } else { - setMergeError(result.message); - } - }, - onError: (err) => { - setMergeError(err instanceof Error ? err.message : 'Merge failed'); - }, - }, - ); - } - - function handleOpenChange(open: boolean) { - if (!open) { - setMergeError(null); - onClose(); - } - } - - const conflictsBadge = (conflicts?.length ?? 0) > 0 ? conflicts?.length : undefined; - const changesBadge = (diff?.changedFiles ?? 0) > 0 ? diff?.changedFiles : undefined; - return ( <Dialog open={isOpen} onOpenChange={handleOpenChange}> <DialogContent className="flex h-[85vh] w-[90vw] max-w-7xl flex-col p-0"> @@ -187,7 +151,8 @@ export function MergeConfirmModal({ )} {/* Footer */} - <div className="border-border flex shrink-0 items-center justify-between border-t px-6 py-4"> + <Separator /> + <div className="flex shrink-0 items-center justify-between px-6 py-4"> <div className="text-muted-foreground text-xs">{getFooterMessage()}</div> <div className="flex gap-2"> <Button variant="ghost" onClick={() => handleOpenChange(false)}> diff --git a/src/renderer/features/merge/components/MergeConfirmModal/index.ts b/src/renderer/features/merge/components/MergeConfirmModal/index.ts new file mode 100644 index 00000000..0c16f03d --- /dev/null +++ b/src/renderer/features/merge/components/MergeConfirmModal/index.ts @@ -0,0 +1 @@ +export { MergeConfirmModal } from './MergeConfirmModal'; diff --git a/src/renderer/features/merge/components/MergeConfirmModal/useMergeConfirmModal.ts b/src/renderer/features/merge/components/MergeConfirmModal/useMergeConfirmModal.ts new file mode 100644 index 00000000..a2a42a94 --- /dev/null +++ b/src/renderer/features/merge/components/MergeConfirmModal/useMergeConfirmModal.ts @@ -0,0 +1,87 @@ +/** + * useMergeConfirmModal — Logic hook for MergeConfirmModal + */ + +import { useState } from 'react'; + +import { useMergeBranch, useMergeConflicts, useMergeDiff } from '../../api/useMerge'; + +interface UseMergeConfirmModalParams { + repoPath: string; + sourceBranch: string; + targetBranch: string; + isOpen: boolean; + onClose: () => void; + onSuccess?: () => void; +} + +export function useMergeConfirmModal({ + repoPath, + sourceBranch, + targetBranch, + isOpen, + onClose, + onSuccess, +}: UseMergeConfirmModalParams) { + const [mergeError, setMergeError] = useState<string | null>(null); + + const mergeBranch = useMergeBranch(); + const { data: conflicts, isLoading: isConflictsLoading } = useMergeConflicts( + isOpen ? repoPath : null, + isOpen ? sourceBranch : null, + isOpen ? targetBranch : null, + ); + const { data: diff, isLoading: isDiffLoading } = useMergeDiff( + isOpen ? repoPath : null, + isOpen ? sourceBranch : null, + isOpen ? targetBranch : null, + ); + + const hasConflicts = (conflicts?.length ?? 0) > 0; + const hasChanges = (diff?.changedFiles ?? 0) > 0; + const isDataLoading = isConflictsLoading || isDiffLoading; + + const conflictsBadge = (conflicts?.length ?? 0) > 0 ? conflicts?.length : undefined; + const changesBadge = (diff?.changedFiles ?? 0) > 0 ? diff?.changedFiles : undefined; + + function handleMerge(): void { + setMergeError(null); + mergeBranch.mutate( + { repoPath, sourceBranch, targetBranch }, + { + onSuccess: (result) => { + if (result.success) { + onSuccess?.(); + onClose(); + } else { + setMergeError(result.message); + } + }, + onError: (err) => { + setMergeError(err instanceof Error ? err.message : 'Merge failed'); + }, + }, + ); + } + + function handleOpenChange(open: boolean): void { + if (!open) { + setMergeError(null); + onClose(); + } + } + + return { + mergeError, + mergeBranch, + conflicts, + diff, + hasConflicts, + hasChanges, + isDataLoading, + conflictsBadge, + changesBadge, + handleMerge, + handleOpenChange, + }; +} diff --git a/src/renderer/features/merge/components/MergePreviewPanel.tsx b/src/renderer/features/merge/components/MergePreviewPanel/MergePreviewPanel.tsx similarity index 75% rename from src/renderer/features/merge/components/MergePreviewPanel.tsx rename to src/renderer/features/merge/components/MergePreviewPanel/MergePreviewPanel.tsx index ae2302de..0dc3bbaf 100644 --- a/src/renderer/features/merge/components/MergePreviewPanel.tsx +++ b/src/renderer/features/merge/components/MergePreviewPanel/MergePreviewPanel.tsx @@ -7,8 +7,6 @@ * - Top toolbar: view mode toggle + summary bar */ -import { useMemo, useState } from 'react'; - import { DiffModeEnum } from '@git-diff-view/react'; import { Columns2, @@ -21,13 +19,17 @@ import { } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; -import { useThemeStore } from '@renderer/shared/stores/theme-store'; import { Button, ScrollArea } from '@ui'; -import { useFileDiff, useMergeDiff } from '../api/useMerge'; +import { FileDiffViewer } from '../FileDiffViewer'; -import { FileDiffViewer } from './FileDiffViewer'; +import { + getFileDir, + getFileLang, + getFileName, + useMergePreviewPanel, +} from './useMergePreviewPanel'; interface MergePreviewPanelProps { repoPath: string; @@ -35,64 +37,6 @@ interface MergePreviewPanelProps { targetBranch: string; } -/** Extract a language hint from a file path for syntax highlighting */ -function getFileLang(filePath: string): string { - const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; - const langMap: Record<string, string> = { - ts: 'typescript', - tsx: 'tsx', - js: 'javascript', - jsx: 'jsx', - json: 'json', - css: 'css', - scss: 'scss', - less: 'less', - html: 'xml', - xml: 'xml', - md: 'markdown', - py: 'python', - rs: 'rust', - go: 'go', - java: 'java', - rb: 'ruby', - sh: 'bash', - bash: 'bash', - zsh: 'bash', - yml: 'yaml', - yaml: 'yaml', - sql: 'sql', - graphql: 'graphql', - swift: 'swift', - kt: 'kotlin', - c: 'c', - cpp: 'cpp', - h: 'c', - hpp: 'cpp', - cs: 'csharp', - php: 'php', - lua: 'lua', - r: 'r', - toml: 'ini', - ini: 'ini', - dockerfile: 'dockerfile', - makefile: 'makefile', - }; - return langMap[ext] ?? 'plaintext'; -} - -/** Extract just the filename from a full path */ -function getFileName(filePath: string): string { - const parts = filePath.split('/'); - return parts.at(-1) ?? filePath; -} - -/** Get the directory portion of a file path */ -function getFileDir(filePath: string): string { - const parts = filePath.split('/'); - if (parts.length <= 1) return ''; - return parts.slice(0, -1).join('/'); -} - interface DiffViewerAreaProps { selectedFile: string | null; isFileDiffLoading: boolean; @@ -155,29 +99,20 @@ export function MergePreviewPanel({ sourceBranch, targetBranch, }: MergePreviewPanelProps) { - const { data: diff, isLoading, error } = useMergeDiff(repoPath, sourceBranch, targetBranch); - const [selectedFile, setSelectedFile] = useState<string | null>(null); - const [viewMode, setViewMode] = useState<DiffModeEnum>(DiffModeEnum.SplitGitHub); - - const themeMode = useThemeStore((s) => s.mode); - const isDark = useMemo(() => { - if (themeMode === 'system') { - return window.matchMedia('(prefers-color-scheme: dark)').matches; - } - return themeMode === 'dark'; - }, [themeMode]); - - // Fetch the individual file diff const { - data: fileDiffData, - isLoading: isFileDiffLoading, - error: fileDiffError, - } = useFileDiff( - repoPath, - sourceBranch, - targetBranch, - selectedFile ?? '', - ); + diff, + isLoading, + error, + selectedFile, + setSelectedFile, + viewMode, + setViewMode, + isDark, + fileDiffData, + isFileDiffLoading, + fileDiffError, + isSplit, + } = useMergePreviewPanel({ repoPath, sourceBranch, targetBranch }); if (isLoading) { return ( @@ -202,8 +137,6 @@ export function MergePreviewPanel({ ); } - const isSplit = (viewMode & DiffModeEnum.Split) !== 0 || viewMode === DiffModeEnum.SplitGitHub; - return ( <div className="flex h-full flex-col"> {/* Summary bar + view mode toggle */} diff --git a/src/renderer/features/merge/components/MergePreviewPanel/index.ts b/src/renderer/features/merge/components/MergePreviewPanel/index.ts new file mode 100644 index 00000000..502d91fc --- /dev/null +++ b/src/renderer/features/merge/components/MergePreviewPanel/index.ts @@ -0,0 +1 @@ +export { MergePreviewPanel } from './MergePreviewPanel'; diff --git a/src/renderer/features/merge/components/MergePreviewPanel/useMergePreviewPanel.ts b/src/renderer/features/merge/components/MergePreviewPanel/useMergePreviewPanel.ts new file mode 100644 index 00000000..d2eccca6 --- /dev/null +++ b/src/renderer/features/merge/components/MergePreviewPanel/useMergePreviewPanel.ts @@ -0,0 +1,62 @@ +/** + * useMergePreviewPanel — Logic hook for MergePreviewPanel + */ + +import { useState } from 'react'; + +import { DiffModeEnum } from '@git-diff-view/react'; + +import { useDarkMode } from '@renderer/shared/hooks/useDarkMode'; +import { useFileLang } from '@renderer/shared/hooks/useFileLangMap'; + +import { useFileDiff, useMergeDiff } from '../../api/useMerge'; + +export { useFileLang as getFileLang }; + +export function getFileName(filePath: string): string { + const parts = filePath.split('/'); + return parts.at(-1) ?? filePath; +} + +export function getFileDir(filePath: string): string { + const parts = filePath.split('/'); + if (parts.length <= 1) return ''; + return parts.slice(0, -1).join('/'); +} + +interface UseMergePreviewPanelParams { + repoPath: string; + sourceBranch: string; + targetBranch: string; +} + +export function useMergePreviewPanel({ repoPath, sourceBranch, targetBranch }: UseMergePreviewPanelParams) { + const { data: diff, isLoading, error } = useMergeDiff(repoPath, sourceBranch, targetBranch); + const [selectedFile, setSelectedFile] = useState<string | null>(null); + const [viewMode, setViewMode] = useState<DiffModeEnum>(DiffModeEnum.SplitGitHub); + + const isDark = useDarkMode(); + + const { + data: fileDiffData, + isLoading: isFileDiffLoading, + error: fileDiffError, + } = useFileDiff(repoPath, sourceBranch, targetBranch, selectedFile ?? ''); + + const isSplit = (viewMode & DiffModeEnum.Split) !== 0 || viewMode === DiffModeEnum.SplitGitHub; + + return { + diff, + isLoading, + error, + selectedFile, + setSelectedFile, + viewMode, + setViewMode, + isDark, + fileDiffData, + isFileDiffLoading, + fileDiffError, + isSplit, + }; +} diff --git a/src/renderer/features/my-work/components/MyWorkPage.tsx b/src/renderer/features/my-work/components/MyWorkPage.tsx deleted file mode 100644 index dd4ca5e3..00000000 --- a/src/renderer/features/my-work/components/MyWorkPage.tsx +++ /dev/null @@ -1,567 +0,0 @@ -/** - * MyWorkPage -- Cross-project task view - * - * Displays all progress tasks from SQLite, optionally grouped by team name. - * Includes status filter, search input, sort dropdown, priority/Jira/PR badges, - * and clickable rows that navigate to the project tasks view. - */ - -import { useMemo, useState } from 'react'; - -import { useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; -import { Briefcase, ExternalLink, Filter, RefreshCw, Users } from 'lucide-react'; - -import { ROUTE_PATTERNS } from '@shared/constants'; -import { PROGRESS_EVENTS } from '@shared/ipc/progress/channels'; -import type { ProgressPriority, ProgressStatus, ProgressTask } from '@shared/types/progress'; - -import { useIpcEvent } from '@renderer/shared/hooks'; -import { useDebounce } from '@renderer/shared/hooks/useDebounce'; -import { useLayoutStore } from '@renderer/shared/stores/layout-store'; - -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - EmptyState, - PageContent, - PageHeader, - PageLayout, - SearchInput, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Separator, - StatusBadge, - Text, -} from '@ui'; - -import { myWorkKeys } from '../api/queryKeys'; -import { useAllTasks } from '../api/useMyWork'; - -import type { StatusBadgeProps } from '@ui'; - -/* ------------------------------------------------------------------ */ -/* Constants */ -/* ------------------------------------------------------------------ */ - -const EXTERNAL_LINK_FEATURES = 'noopener,noreferrer'; - -/* ------------------------------------------------------------------ */ -/* Status filter */ -/* ------------------------------------------------------------------ */ - -type StatusFilter = 'all' | ProgressStatus; - -const STATUS_OPTIONS: Array<{ value: StatusFilter; label: string }> = [ - { value: 'all', label: 'All Tasks' }, - { value: 'backlog', label: 'Backlog' }, - { value: 'researching', label: 'Researching' }, - { value: 'research_done', label: 'Research Done' }, - { value: 'planning', label: 'Planning' }, - { value: 'plan_ready', label: 'Plan Ready' }, - { value: 'executing', label: 'Executing' }, - { value: 'review', label: 'Review' }, - { value: 'done', label: 'Done' }, - { value: 'archived', label: 'Archived' }, - { value: 'error', label: 'Error' }, -]; - -/* ------------------------------------------------------------------ */ -/* Sort options */ -/* ------------------------------------------------------------------ */ - -type SortField = 'priority' | 'updatedAt' | 'status'; - -const SORT_OPTIONS: Array<{ value: SortField; label: string }> = [ - { value: 'priority', label: 'Priority' }, - { value: 'updatedAt', label: 'Updated At' }, - { value: 'status', label: 'Status' }, -]; - -const PRIORITY_ORDER: Record<ProgressPriority, number> = { - urgent: 0, - high: 1, - normal: 2, - low: 3, -}; - -const STATUS_ORDER: Record<ProgressStatus, number> = { - error: 0, - executing: 1, - review: 2, - planning: 3, - plan_ready: 4, - researching: 5, - research_done: 6, - backlog: 7, - done: 8, - archived: 9, -}; - -/* ------------------------------------------------------------------ */ -/* Priority badge config */ -/* ------------------------------------------------------------------ */ - -type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'warning' | 'info' | 'success' | 'error'; - -const PRIORITY_BADGE_VARIANTS: Record<ProgressPriority, BadgeVariant> = { - low: 'outline', - normal: 'secondary', - high: 'info', - urgent: 'destructive', -}; - -const PRIORITY_LABELS: Record<ProgressPriority, string> = { - low: 'Low', - normal: 'Normal', - high: 'High', - urgent: 'Urgent', -}; - -/* ------------------------------------------------------------------ */ -/* ProgressStatus badge config */ -/* ------------------------------------------------------------------ */ - -const ACTIVE_STATUSES = new Set<ProgressStatus>(['researching', 'executing', 'planning']); - -const progressStatusConfig: Record< - ProgressStatus, - { label: string; tone: StatusBadgeProps['tone'] } -> = { - backlog: { label: 'Backlog', tone: 'muted' }, - researching: { label: 'Researching', tone: 'info' }, - research_done: { label: 'Research Done', tone: 'info' }, - planning: { label: 'Planning', tone: 'info' }, - plan_ready: { label: 'Plan Ready', tone: 'purple' }, - executing: { label: 'Executing', tone: 'primary' }, - review: { label: 'Review', tone: 'amber' }, - done: { label: 'Done', tone: 'success' }, - archived: { label: 'Archived', tone: 'muted' }, - error: { label: 'Error', tone: 'destructive' }, -}; - -function ProgressStatusBadge({ - status, - className, -}: { - status: ProgressStatus; - className?: string; -}) { - const config = progressStatusConfig[status]; - return ( - <StatusBadge - className={className} - pulsing={ACTIVE_STATUSES.has(status)} - tone={config.tone} - > - {config.label} - </StatusBadge> - ); -} - -/* ------------------------------------------------------------------ */ -/* Filtering, sorting, grouping */ -/* ------------------------------------------------------------------ */ - -interface TasksByTeam { - teamName: string; - tasks: ProgressTask[]; -} - -function groupTasksByTeam(tasks: ProgressTask[]): TasksByTeam[] { - const grouped = new Map<string, ProgressTask[]>(); - - for (const task of tasks) { - const team = task.teamName ?? 'Ungrouped'; - const existing = grouped.get(team) ?? []; - existing.push(task); - grouped.set(team, existing); - } - - const result: TasksByTeam[] = []; - for (const [teamName, teamTasks] of grouped.entries()) { - result.push({ teamName, tasks: teamTasks }); - } - - result.sort((a, b) => a.teamName.localeCompare(b.teamName)); - return result; -} - -function filterByStatus(tasks: ProgressTask[], status: StatusFilter): ProgressTask[] { - if (status === 'all') return tasks; - return tasks.filter((t) => t.status === status); -} - -function filterBySearch(tasks: ProgressTask[], query: string): ProgressTask[] { - if (query.trim().length === 0) return tasks; - const lower = query.toLowerCase(); - return tasks.filter( - (t) => - t.title.toLowerCase().includes(lower) || - t.description.toLowerCase().includes(lower), - ); -} - -function sortTasks(tasks: ProgressTask[], field: SortField): ProgressTask[] { - const sorted = [...tasks]; - if (field === 'priority') { - sorted.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]); - } else if (field === 'updatedAt') { - sorted.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - } else { - sorted.sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]); - } - return sorted; -} - -function getTaskCountLabel(count: number): string { - return count === 1 ? 'task' : 'tasks'; -} - -/* ------------------------------------------------------------------ */ -/* Sub-components */ -/* ------------------------------------------------------------------ */ - -function MyWorkEmptyState({ hasFilter }: { hasFilter: boolean }) { - const title = hasFilter ? 'No tasks match filter' : 'No tasks yet'; - const description = hasFilter - ? 'Try selecting a different status filter or changing your search to see more tasks.' - : 'Tasks will appear here once you create them. Add a project and create tasks to get started.'; - - return ( - <EmptyState - description={description} - icon={Briefcase} - size="lg" - title={title} - /> - ); -} - -interface TaskRowProps { - task: ProgressTask; - onNavigate: (task: ProgressTask) => void; -} - -function TaskRow({ task, onNavigate }: TaskRowProps) { - const hasJira = task.jiraTicket !== undefined && task.jiraUrl !== undefined; - const hasPr = task.prNumber !== undefined && task.prUrl !== undefined; - - function handleClick() { - onNavigate(task); - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onNavigate(task); - } - } - - function handleJiraClick(e: React.MouseEvent) { - e.stopPropagation(); - const url = task.jiraUrl; - if (url !== undefined) { - window.open(url, '_blank', EXTERNAL_LINK_FEATURES); - } - } - - function handlePrClick(e: React.MouseEvent) { - e.stopPropagation(); - const url = task.prUrl; - if (url !== undefined) { - window.open(url, '_blank', EXTERNAL_LINK_FEATURES); - } - } - - function handleJiraKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - const url = task.jiraUrl; - if (url !== undefined) { - window.open(url, '_blank', EXTERNAL_LINK_FEATURES); - } - } - } - - function handlePrKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - const url = task.prUrl; - if (url !== undefined) { - window.open(url, '_blank', EXTERNAL_LINK_FEATURES); - } - } - } - - return ( - <div - className="hover:bg-accent/50 cursor-pointer px-4 py-3 transition-colors" - role="button" - tabIndex={0} - onClick={handleClick} - onKeyDown={handleKeyDown} - > - <div className="mb-1 flex items-start justify-between gap-2"> - <div className="flex min-w-0 flex-1 items-center gap-2"> - <Text className="truncate font-medium" size="sm"> - {task.title} - </Text> - <Badge size="sm" variant={PRIORITY_BADGE_VARIANTS[task.priority]}> - {PRIORITY_LABELS[task.priority]} - </Badge> - {hasJira ? ( - <div - aria-label={`Open Jira ticket ${task.jiraTicket ?? ''}`} - className="flex cursor-pointer items-center gap-1" - role="button" - tabIndex={0} - onClick={handleJiraClick} - onKeyDown={handleJiraKeyDown} - > - <Badge size="sm" variant="info"> - {task.jiraTicket} - </Badge> - <ExternalLink className="text-muted-foreground h-3 w-3 shrink-0" /> - </div> - ) : null} - {hasPr ? ( - <div - aria-label={`Open PR #${task.prNumber ?? ''}`} - className="flex cursor-pointer items-center gap-1" - role="button" - tabIndex={0} - onClick={handlePrClick} - onKeyDown={handlePrKeyDown} - > - <Badge size="sm" variant="secondary"> - PR #{task.prNumber} - </Badge> - <ExternalLink className="text-muted-foreground h-3 w-3 shrink-0" /> - </div> - ) : null} - </div> - <ProgressStatusBadge status={task.status} /> - </div> - {task.description.length > 0 ? ( - <Text className="line-clamp-2 text-xs" variant="muted"> - {task.description} - </Text> - ) : null} - </div> - ); -} - -function TeamGroup({ - group, - onNavigate, -}: { - group: TasksByTeam; - onNavigate: (task: ProgressTask) => void; -}) { - return ( - <Card> - <CardHeader className="flex flex-row items-center gap-2 py-3"> - <Users className="text-muted-foreground h-4 w-4" /> - <span className="text-foreground text-sm font-semibold">{group.teamName}</span> - <span className="text-muted-foreground text-xs">({group.tasks.length})</span> - </CardHeader> - <Separator /> - <CardContent className="p-0"> - <div className="divide-border divide-y"> - {group.tasks.map((task) => ( - <TaskRow - key={task.slug} - task={task} - onNavigate={onNavigate} - /> - ))} - </div> - </CardContent> - </Card> - ); -} - -function ErrorState({ onRetry }: { onRetry: () => void }) { - return ( - <EmptyState - description="Unable to load tasks. The progress database may be unavailable." - icon={RefreshCw} - size="lg" - title="Failed to load tasks" - action={ - <Button onClick={onRetry}> - <RefreshCw className="h-4 w-4" /> - Retry - </Button> - } - /> - ); -} - -function TaskListContent({ - isLoading, - isError, - taskGroups, - hasFilter, - onRetry, - onNavigate, -}: { - isLoading: boolean; - isError: boolean; - taskGroups: TasksByTeam[]; - hasFilter: boolean; - onRetry: () => void; - onNavigate: (task: ProgressTask) => void; -}) { - if (isError) { - return <ErrorState onRetry={onRetry} />; - } - - if (isLoading) { - return ( - <div className="text-muted-foreground flex items-center justify-center py-12"> - Loading tasks... - </div> - ); - } - - if (taskGroups.length === 0) { - return <MyWorkEmptyState hasFilter={hasFilter} />; - } - - return ( - <div className="space-y-4"> - {taskGroups.map((group) => ( - <TeamGroup - key={group.teamName} - group={group} - onNavigate={onNavigate} - /> - ))} - </div> - ); -} - -/* ------------------------------------------------------------------ */ -/* Page */ -/* ------------------------------------------------------------------ */ - -export function MyWorkPage() { - const queryClient = useQueryClient(); - const navigate = useNavigate(); - const activeProjectId = useLayoutStore((s) => s.activeProjectId); - - const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); - const [searchQuery, setSearchQuery] = useState(''); - const [sortField, setSortField] = useState<SortField>('priority'); - - const debouncedSearch = useDebounce(searchQuery, 250); - - const { data: tasks, isLoading: tasksLoading, isError: tasksError } = useAllTasks(); - - // Invalidate task list on progress events - useIpcEvent(PROGRESS_EVENTS.TASK.CREATED, () => { - void queryClient.invalidateQueries({ queryKey: myWorkKeys.tasks() }); - }); - useIpcEvent(PROGRESS_EVENTS.TASK.UPDATED, () => { - void queryClient.invalidateQueries({ queryKey: myWorkKeys.tasks() }); - }); - useIpcEvent(PROGRESS_EVENTS.TASK.ARCHIVED, () => { - void queryClient.invalidateQueries({ queryKey: myWorkKeys.tasks() }); - }); - - function handleRetry() { - void queryClient.invalidateQueries({ queryKey: myWorkKeys.tasks() }); - } - - function handleTaskNavigate(task: ProgressTask) { - if (!activeProjectId) return; - void navigate({ - to: ROUTE_PATTERNS.PROJECT_TASKS, - params: { projectId: activeProjectId }, - search: { taskSlug: task.slug }, - }); - } - - // Filter and sort tasks - const processedTasks = useMemo(() => { - const statusFiltered = filterByStatus(tasks ?? [], statusFilter); - const searchFiltered = filterBySearch(statusFiltered, debouncedSearch); - return sortTasks(searchFiltered, sortField); - }, [tasks, statusFilter, debouncedSearch, sortField]); - - const taskGroups = useMemo(() => { - return groupTasksByTeam(processedTasks); - }, [processedTasks]); - - const totalTasks = processedTasks.length; - const hasFilter = statusFilter !== 'all' || debouncedSearch.trim().length > 0; - - return ( - <PageLayout> - <PageHeader> - <PageHeader.Row> - <PageHeader.Title description="All tasks across your projects"> - My Work - </PageHeader.Title> - <PageHeader.Actions> - <SearchInput - className="w-[200px]" - placeholder="Search tasks..." - value={searchQuery} - onChange={(e) => { setSearchQuery(e.target.value); }} - onClear={() => { setSearchQuery(''); }} - /> - <Filter className="text-muted-foreground h-4 w-4" /> - <Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v as StatusFilter); }}> - <SelectTrigger className="w-[140px]"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {STATUS_OPTIONS.map((opt) => ( - <SelectItem key={opt.value} value={opt.value}> - {opt.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <Select value={sortField} onValueChange={(v) => { setSortField(v as SortField); }}> - <SelectTrigger className="w-[130px]"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {SORT_OPTIONS.map((opt) => ( - <SelectItem key={opt.value} value={opt.value}> - {opt.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <Text className="text-xs" variant="muted"> - {totalTasks} {getTaskCountLabel(totalTasks)} - </Text> - </PageHeader.Actions> - </PageHeader.Row> - </PageHeader> - <PageContent> - <TaskListContent - hasFilter={hasFilter} - isError={tasksError} - isLoading={tasksLoading} - taskGroups={taskGroups} - onNavigate={handleTaskNavigate} - onRetry={handleRetry} - /> - </PageContent> - </PageLayout> - ); -} diff --git a/src/renderer/features/my-work/components/MyWorkPage/MyWorkPage.tsx b/src/renderer/features/my-work/components/MyWorkPage/MyWorkPage.tsx new file mode 100644 index 00000000..e1340b52 --- /dev/null +++ b/src/renderer/features/my-work/components/MyWorkPage/MyWorkPage.tsx @@ -0,0 +1,202 @@ +/** + * MyWorkPage -- Cross-project task view + * + * Displays all progress tasks from SQLite, optionally grouped by team name. + * Includes status filter, search input, sort dropdown, priority/Jira/PR badges, + * and clickable rows that navigate to the project tasks view. + */ + +import { Briefcase, Filter, RefreshCw } from 'lucide-react'; + +import type { ProgressTask } from '@shared/types/progress'; + +import { + Button, + EmptyState, + PageContent, + PageHeader, + PageLayout, + SearchInput, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Text, +} from '@ui'; + +import { TeamGroup } from '../TeamGroup'; + +import { + SORT_OPTIONS, + STATUS_OPTIONS, + getTaskCountLabel, + useMyWorkPage, +} from './useMyWorkPage'; + +import type { TasksByTeam } from '../TeamGroup'; + + +/* ------------------------------------------------------------------ */ +/* Sub-components */ +/* ------------------------------------------------------------------ */ + +function MyWorkEmptyState({ hasFilter }: { hasFilter: boolean }) { + const title = hasFilter ? 'No tasks match filter' : 'No tasks yet'; + const description = hasFilter + ? 'Try selecting a different status filter or changing your search to see more tasks.' + : 'Tasks will appear here once you create them. Add a project and create tasks to get started.'; + + return ( + <EmptyState + description={description} + icon={Briefcase} + size="lg" + title={title} + /> + ); +} + +function ErrorState({ onRetry }: { onRetry: () => void }) { + return ( + <EmptyState + description="Unable to load tasks. The progress database may be unavailable." + icon={RefreshCw} + size="lg" + title="Failed to load tasks" + action={ + <Button onClick={onRetry}> + <RefreshCw className="h-4 w-4" /> + Retry + </Button> + } + /> + ); +} + +interface TaskListContentProps { + isLoading: boolean; + isError: boolean; + taskGroups: TasksByTeam[]; + hasFilter: boolean; + onRetry: () => void; + onNavigate: (task: ProgressTask) => void; +} + +function TaskListContent({ + isLoading, + isError, + taskGroups, + hasFilter, + onRetry, + onNavigate, +}: TaskListContentProps) { + if (isError) { + return <ErrorState onRetry={onRetry} />; + } + + if (isLoading) { + return ( + <div className="text-muted-foreground flex items-center justify-center py-12"> + Loading tasks... + </div> + ); + } + + if (taskGroups.length === 0) { + return <MyWorkEmptyState hasFilter={hasFilter} />; + } + + return ( + <div className="space-y-4"> + {taskGroups.map((group) => ( + <TeamGroup + key={group.teamName} + group={group} + onNavigate={onNavigate} + /> + ))} + </div> + ); +} + +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + +export function MyWorkPage() { + const { + statusFilter, + setStatusFilter, + searchQuery, + setSearchQuery, + sortField, + setSortField, + tasksLoading, + tasksError, + taskGroups, + totalTasks, + hasFilter, + handleRetry, + handleTaskNavigate, + } = useMyWorkPage(); + + return ( + <PageLayout> + <PageHeader> + <PageHeader.Row> + <PageHeader.Title description="All tasks across your projects"> + My Work + </PageHeader.Title> + <PageHeader.Actions> + <SearchInput + className="w-[200px]" + placeholder="Search tasks..." + value={searchQuery} + onChange={(e) => { setSearchQuery(e.target.value); }} + onClear={() => { setSearchQuery(''); }} + /> + <Filter className="text-muted-foreground h-4 w-4" /> + <Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v as typeof statusFilter); }}> + <SelectTrigger className="w-[140px]"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {STATUS_OPTIONS.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <Select value={sortField} onValueChange={(v) => { setSortField(v as typeof sortField); }}> + <SelectTrigger className="w-[130px]"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {SORT_OPTIONS.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <Text className="text-xs" variant="muted"> + {totalTasks} {getTaskCountLabel(totalTasks)} + </Text> + </PageHeader.Actions> + </PageHeader.Row> + </PageHeader> + <PageContent> + <TaskListContent + hasFilter={hasFilter} + isError={tasksError} + isLoading={tasksLoading} + taskGroups={taskGroups} + onNavigate={handleTaskNavigate} + onRetry={handleRetry} + /> + </PageContent> + </PageLayout> + ); +} diff --git a/src/renderer/features/my-work/components/MyWorkPage/index.ts b/src/renderer/features/my-work/components/MyWorkPage/index.ts new file mode 100644 index 00000000..d494a6b8 --- /dev/null +++ b/src/renderer/features/my-work/components/MyWorkPage/index.ts @@ -0,0 +1 @@ +export { MyWorkPage } from './MyWorkPage'; diff --git a/src/renderer/features/my-work/components/MyWorkPage/useMyWorkPage.ts b/src/renderer/features/my-work/components/MyWorkPage/useMyWorkPage.ts new file mode 100644 index 00000000..3df31697 --- /dev/null +++ b/src/renderer/features/my-work/components/MyWorkPage/useMyWorkPage.ts @@ -0,0 +1,218 @@ +/** + * useMyWorkPage — Logic hook for MyWorkPage + */ + +import { useMemo, useState } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; + +import { ROUTE_PATTERNS } from '@shared/constants'; +import { PROGRESS_EVENTS } from '@shared/ipc/progress/channels'; +import type { ProgressPriority, ProgressStatus, ProgressTask } from '@shared/types/progress'; + +import { useIpcEvent } from '@renderer/shared/hooks'; +import { useFilteredList } from '@renderer/shared/hooks/useFilteredList'; +import { useLayoutStore } from '@renderer/shared/stores/layout-store'; + +import { myWorkKeys } from '../../api/queryKeys'; +import { useAllTasks } from '../../api/useMyWork'; + +import type { TasksByTeam } from '../TeamGroup'; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type StatusFilter = 'all' | ProgressStatus; +export type SortField = 'priority' | 'updatedAt' | 'status'; + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +export const STATUS_OPTIONS: Array<{ value: StatusFilter; label: string }> = [ + { value: 'all', label: 'All Tasks' }, + { value: 'backlog', label: 'Backlog' }, + { value: 'researching', label: 'Researching' }, + { value: 'research_done', label: 'Research Done' }, + { value: 'planning', label: 'Planning' }, + { value: 'plan_ready', label: 'Plan Ready' }, + { value: 'executing', label: 'Executing' }, + { value: 'review', label: 'Review' }, + { value: 'done', label: 'Done' }, + { value: 'archived', label: 'Archived' }, + { value: 'error', label: 'Error' }, +]; + +export const SORT_OPTIONS: Array<{ value: SortField; label: string }> = [ + { value: 'priority', label: 'Priority' }, + { value: 'updatedAt', label: 'Updated At' }, + { value: 'status', label: 'Status' }, +]; + +const PRIORITY_ORDER: Record<ProgressPriority, number> = { + urgent: 0, + high: 1, + normal: 2, + low: 3, +}; + +const STATUS_ORDER: Record<ProgressStatus, number> = { + error: 0, + executing: 1, + review: 2, + planning: 3, + plan_ready: 4, + researching: 5, + research_done: 6, + backlog: 7, + done: 8, + archived: 9, +}; + +/* ------------------------------------------------------------------ */ +/* Pure helpers */ +/* ------------------------------------------------------------------ */ + +export function groupTasksByTeam(tasks: ProgressTask[]): TasksByTeam[] { + const grouped = new Map<string, ProgressTask[]>(); + + for (const task of tasks) { + const team = task.teamName ?? 'Ungrouped'; + const existing = grouped.get(team) ?? []; + existing.push(task); + grouped.set(team, existing); + } + + const result: TasksByTeam[] = []; + for (const [teamName, teamTasks] of grouped.entries()) { + result.push({ teamName, tasks: teamTasks }); + } + + result.sort((a, b) => a.teamName.localeCompare(b.teamName)); + return result; +} + +export function filterByStatus(tasks: ProgressTask[], status: StatusFilter): ProgressTask[] { + if (status === 'all') return tasks; + return tasks.filter((t) => t.status === status); +} + +export function filterBySearch(tasks: ProgressTask[], query: string): ProgressTask[] { + if (query.trim().length === 0) return tasks; + const lower = query.toLowerCase(); + return tasks.filter( + (t) => + t.title.toLowerCase().includes(lower) || + t.description.toLowerCase().includes(lower), + ); +} + +export function sortTasks(tasks: ProgressTask[], field: SortField): ProgressTask[] { + const sorted = [...tasks]; + if (field === 'priority') { + sorted.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]); + } else if (field === 'updatedAt') { + sorted.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } else { + sorted.sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]); + } + return sorted; +} + +export function getTaskCountLabel(count: number): string { + return count === 1 ? 'task' : 'tasks'; +} + +/* ------------------------------------------------------------------ */ +/* Hook */ +/* ------------------------------------------------------------------ */ + +export function useMyWorkPage() { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const activeProjectId = useLayoutStore((s) => s.activeProjectId); + + const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); + const [sortField, setSortField] = useState<SortField>('priority'); + + const { data: tasks, isLoading: tasksLoading, isError: tasksError } = useAllTasks(); + + useIpcEvent(PROGRESS_EVENTS.TASK.CREATED, () => { + void queryClient.invalidateQueries({ queryKey: myWorkKeys.tasks() }); + }); + useIpcEvent(PROGRESS_EVENTS.TASK.UPDATED, () => { + void queryClient.invalidateQueries({ queryKey: myWorkKeys.tasks() }); + }); + useIpcEvent(PROGRESS_EVENTS.TASK.ARCHIVED, () => { + void queryClient.invalidateQueries({ queryKey: myWorkKeys.tasks() }); + }); + + function handleRetry(): void { + void queryClient.invalidateQueries({ queryKey: myWorkKeys.tasks() }); + } + + function handleTaskNavigate(task: ProgressTask): void { + if (!activeProjectId) return; + void navigate({ + to: ROUTE_PATTERNS.PROJECT_TASKS, + params: { projectId: activeProjectId }, + search: { taskSlug: task.slug }, + }); + } + + // Status-filtered input for useFilteredList + const statusFiltered = useMemo( + () => filterByStatus(tasks ?? [], statusFilter), + [tasks, statusFilter], + ); + + const filterConfig = useMemo( + () => ({ + searchFn: (t: ProgressTask, query: string) => { + const lower = query.toLowerCase(); + return t.title.toLowerCase().includes(lower) || t.description.toLowerCase().includes(lower); + }, + sortFn: (a: ProgressTask, b: ProgressTask) => { + if (sortField === 'priority') { + return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]; + } + if (sortField === 'updatedAt') { + return b.updatedAt.localeCompare(a.updatedAt); + } + return STATUS_ORDER[a.status] - STATUS_ORDER[b.status]; + }, + }), + [sortField], + ); + + const { + searchQuery, + setSearchQuery, + filtered: processedTasks, + } = useFilteredList(statusFiltered, filterConfig); + + const taskGroups = useMemo(() => { + return groupTasksByTeam(processedTasks); + }, [processedTasks]); + + const totalTasks = processedTasks.length; + const hasFilter = statusFilter !== 'all' || searchQuery.trim().length > 0; + + return { + statusFilter, + setStatusFilter, + searchQuery, + setSearchQuery, + sortField, + setSortField, + tasksLoading, + tasksError, + taskGroups, + totalTasks, + hasFilter, + handleRetry, + handleTaskNavigate, + }; +} diff --git a/src/renderer/features/my-work/components/TaskRow/TaskRow.tsx b/src/renderer/features/my-work/components/TaskRow/TaskRow.tsx new file mode 100644 index 00000000..aff84574 --- /dev/null +++ b/src/renderer/features/my-work/components/TaskRow/TaskRow.tsx @@ -0,0 +1,190 @@ +/** + * TaskRow — Single task row in the My Work list + */ + +import { ExternalLink } from 'lucide-react'; + +import type { ProgressPriority, ProgressStatus, ProgressTask } from '@shared/types/progress'; + +import { Badge, StatusBadge, Text } from '@ui'; + +import type { StatusBadgeProps } from '@ui'; + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +const EXTERNAL_LINK_FEATURES = 'noopener,noreferrer'; + +type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'warning' | 'info' | 'success' | 'error'; + +const PRIORITY_BADGE_VARIANTS: Record<ProgressPriority, BadgeVariant> = { + low: 'outline', + normal: 'secondary', + high: 'info', + urgent: 'destructive', +}; + +const PRIORITY_LABELS: Record<ProgressPriority, string> = { + low: 'Low', + normal: 'Normal', + high: 'High', + urgent: 'Urgent', +}; + +const ACTIVE_STATUSES = new Set<ProgressStatus>(['researching', 'executing', 'planning']); + +const progressStatusConfig: Record< + ProgressStatus, + { label: string; tone: StatusBadgeProps['tone'] } +> = { + backlog: { label: 'Backlog', tone: 'muted' }, + researching: { label: 'Researching', tone: 'info' }, + research_done: { label: 'Research Done', tone: 'info' }, + planning: { label: 'Planning', tone: 'info' }, + plan_ready: { label: 'Plan Ready', tone: 'purple' }, + executing: { label: 'Executing', tone: 'primary' }, + review: { label: 'Review', tone: 'amber' }, + done: { label: 'Done', tone: 'success' }, + archived: { label: 'Archived', tone: 'muted' }, + error: { label: 'Error', tone: 'destructive' }, +}; + +function ProgressStatusBadge({ + status, + className, +}: { + status: ProgressStatus; + className?: string; +}) { + const config = progressStatusConfig[status]; + return ( + <StatusBadge + className={className} + pulsing={ACTIVE_STATUSES.has(status)} + tone={config.tone} + > + {config.label} + </StatusBadge> + ); +} + +/* ------------------------------------------------------------------ */ +/* TaskRow */ +/* ------------------------------------------------------------------ */ + +export interface TaskRowProps { + task: ProgressTask; + onNavigate: (task: ProgressTask) => void; +} + +export function TaskRow({ task, onNavigate }: TaskRowProps) { + const hasJira = task.jiraTicket !== undefined && task.jiraUrl !== undefined; + const hasPr = task.prNumber !== undefined && task.prUrl !== undefined; + + function handleClick(): void { + onNavigate(task); + } + + function handleKeyDown(e: React.KeyboardEvent): void { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onNavigate(task); + } + } + + function handleJiraClick(e: React.MouseEvent): void { + e.stopPropagation(); + const url = task.jiraUrl; + if (url !== undefined) { + window.open(url, '_blank', EXTERNAL_LINK_FEATURES); + } + } + + function handlePrClick(e: React.MouseEvent): void { + e.stopPropagation(); + const url = task.prUrl; + if (url !== undefined) { + window.open(url, '_blank', EXTERNAL_LINK_FEATURES); + } + } + + function handleJiraKeyDown(e: React.KeyboardEvent): void { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + const url = task.jiraUrl; + if (url !== undefined) { + window.open(url, '_blank', EXTERNAL_LINK_FEATURES); + } + } + } + + function handlePrKeyDown(e: React.KeyboardEvent): void { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + const url = task.prUrl; + if (url !== undefined) { + window.open(url, '_blank', EXTERNAL_LINK_FEATURES); + } + } + } + + return ( + <div + className="hover:bg-accent/50 cursor-pointer px-4 py-3 transition-colors" + role="button" + tabIndex={0} + onClick={handleClick} + onKeyDown={handleKeyDown} + > + <div className="mb-1 flex items-start justify-between gap-2"> + <div className="flex min-w-0 flex-1 items-center gap-2"> + <Text className="truncate font-medium" size="sm"> + {task.title} + </Text> + <Badge size="sm" variant={PRIORITY_BADGE_VARIANTS[task.priority]}> + {PRIORITY_LABELS[task.priority]} + </Badge> + {hasJira ? ( + <div + aria-label={`Open Jira ticket ${task.jiraTicket ?? ''}`} + className="flex cursor-pointer items-center gap-1" + role="button" + tabIndex={0} + onClick={handleJiraClick} + onKeyDown={handleJiraKeyDown} + > + <Badge size="sm" variant="info"> + {task.jiraTicket} + </Badge> + <ExternalLink className="text-muted-foreground h-3 w-3 shrink-0" /> + </div> + ) : null} + {hasPr ? ( + <div + aria-label={`Open PR #${task.prNumber ?? ''}`} + className="flex cursor-pointer items-center gap-1" + role="button" + tabIndex={0} + onClick={handlePrClick} + onKeyDown={handlePrKeyDown} + > + <Badge size="sm" variant="secondary"> + PR #{task.prNumber} + </Badge> + <ExternalLink className="text-muted-foreground h-3 w-3 shrink-0" /> + </div> + ) : null} + </div> + <ProgressStatusBadge status={task.status} /> + </div> + {task.description.length > 0 ? ( + <Text className="line-clamp-2 text-xs" variant="muted"> + {task.description} + </Text> + ) : null} + </div> + ); +} diff --git a/src/renderer/features/my-work/components/TaskRow/index.ts b/src/renderer/features/my-work/components/TaskRow/index.ts new file mode 100644 index 00000000..fb62b34a --- /dev/null +++ b/src/renderer/features/my-work/components/TaskRow/index.ts @@ -0,0 +1,2 @@ +export { TaskRow } from './TaskRow'; +export type { TaskRowProps } from './TaskRow'; diff --git a/src/renderer/features/my-work/components/TeamGroup/TeamGroup.tsx b/src/renderer/features/my-work/components/TeamGroup/TeamGroup.tsx new file mode 100644 index 00000000..c815ac3b --- /dev/null +++ b/src/renderer/features/my-work/components/TeamGroup/TeamGroup.tsx @@ -0,0 +1,50 @@ +/** + * TeamGroup — Card grouping tasks by team name + */ + +import { Users } from 'lucide-react'; + +import type { ProgressTask } from '@shared/types/progress'; + +import { + Card, + CardContent, + CardHeader, + Separator, +} from '@ui'; + +import { TaskRow } from '../TaskRow'; + +export interface TasksByTeam { + teamName: string; + tasks: ProgressTask[]; +} + +export interface TeamGroupProps { + group: TasksByTeam; + onNavigate: (task: ProgressTask) => void; +} + +export function TeamGroup({ group, onNavigate }: TeamGroupProps) { + return ( + <Card> + <CardHeader className="flex flex-row items-center gap-2 py-3"> + <Users className="text-muted-foreground h-4 w-4" /> + <span className="text-foreground text-sm font-semibold">{group.teamName}</span> + <span className="text-muted-foreground text-xs">({group.tasks.length})</span> + </CardHeader> + <Separator /> + <CardContent className="p-0"> + <div className="divide-border divide-y"> + {group.tasks.map((task) => ( + <TaskRow + key={task.slug} + task={task} + onNavigate={onNavigate} + /> + ))} + </div> + </CardContent> + </Card> + ); +} diff --git a/src/renderer/features/my-work/components/TeamGroup/index.ts b/src/renderer/features/my-work/components/TeamGroup/index.ts new file mode 100644 index 00000000..a3588900 --- /dev/null +++ b/src/renderer/features/my-work/components/TeamGroup/index.ts @@ -0,0 +1,2 @@ +export { TeamGroup } from './TeamGroup'; +export type { TasksByTeam, TeamGroupProps } from './TeamGroup'; diff --git a/src/renderer/features/personal/notes/api/queryKeys.ts b/src/renderer/features/notes/api/queryKeys.ts similarity index 100% rename from src/renderer/features/personal/notes/api/queryKeys.ts rename to src/renderer/features/notes/api/queryKeys.ts diff --git a/src/renderer/features/personal/notes/api/useNotes.ts b/src/renderer/features/notes/api/useNotes.ts similarity index 97% rename from src/renderer/features/personal/notes/api/useNotes.ts rename to src/renderer/features/notes/api/useNotes.ts index c180104c..1813bedd 100644 --- a/src/renderer/features/personal/notes/api/useNotes.ts +++ b/src/renderer/features/notes/api/useNotes.ts @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { NOTES } from '@shared/ipc/misc/notes.channels'; +import { NOTES } from '@shared/ipc/notes'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/notes/components/NoteEditor/NoteEditor.tsx b/src/renderer/features/notes/components/NoteEditor/NoteEditor.tsx new file mode 100644 index 00000000..c22e8c2a --- /dev/null +++ b/src/renderer/features/notes/components/NoteEditor/NoteEditor.tsx @@ -0,0 +1,121 @@ +/** + * NoteEditor — Title + content textarea for editing a note + */ + +import { Pin, PinOff, Save, Trash2, X } from 'lucide-react'; + +import type { Note } from '@shared/types'; + +import { Button, Input, Textarea } from '@ui'; + +import { useNoteEditor } from './useNoteEditor'; + +// ── Types ──────────────────────────────────────────────────── + +interface NoteEditorProps { + note: Note; +} + +// ── Component ──────────────────────────────────────────────── + +export function NoteEditor({ note }: NoteEditorProps) { + const { + title, + content, + tagsInput, + hasChanges, + setTitle, + setContent, + setTagsInput, + handleSave, + handleDelete, + handleTogglePin, + handleClose, + } = useNoteEditor(note); + + return ( + <div className="flex h-full flex-col"> + {/* Toolbar */} + <div className="border-border flex items-center justify-between border-b px-4 py-2"> + <div className="flex items-center gap-2"> + <Button + aria-label={note.pinned ? 'Unpin note' : 'Pin note'} + className={note.pinned ? 'text-primary' : 'text-muted-foreground'} + size="icon" + variant="ghost" + onClick={handleTogglePin} + > + {note.pinned ? <Pin className="h-4 w-4" /> : <PinOff className="h-4 w-4" />} + </Button> + <Button + aria-label="Save note" + className={hasChanges ? 'text-primary' : 'text-muted-foreground'} + disabled={!hasChanges} + size="icon" + variant="ghost" + onClick={handleSave} + > + <Save className="h-4 w-4" /> + </Button> + </div> + <div className="flex items-center gap-2"> + <Button + aria-label="Delete note" + className="text-muted-foreground hover:text-destructive" + size="icon" + variant="ghost" + onClick={handleDelete} + > + <Trash2 className="h-4 w-4" /> + </Button> + <Button + aria-label="Close editor" + size="icon" + variant="ghost" + onClick={handleClose} + > + <X className="h-4 w-4" /> + </Button> + </div> + </div> + + {/* Title */} + <div className="border-border border-b px-4 py-3"> + <Input + aria-label="Note title" + className="border-none bg-transparent text-lg font-semibold shadow-none focus-visible:ring-0" + placeholder="Note title..." + value={title} + onChange={(e) => setTitle(e.target.value)} + /> + </div> + + {/* Tags */} + <div className="border-border border-b px-4 py-2"> + <Input + aria-label="Tags (comma separated)" + className="border-none bg-transparent text-muted-foreground shadow-none focus-visible:ring-0" + placeholder="Tags (comma separated)..." + value={tagsInput} + onChange={(e) => setTagsInput(e.target.value)} + /> + </div> + + {/* Content */} + <div className="flex-1 p-4"> + <Textarea + aria-label="Note content" + className="h-full resize-none border-none bg-transparent shadow-none focus-visible:ring-0" + placeholder="Write your note..." + value={content} + onChange={(e) => setContent(e.target.value)} + /> + </div> + + {/* Footer */} + <div className="border-border text-muted-foreground border-t px-4 py-2 text-xs"> + Updated {new Date(note.updatedAt).toLocaleString()} + </div> + </div> + ); +} diff --git a/src/renderer/features/notes/components/NoteEditor/index.ts b/src/renderer/features/notes/components/NoteEditor/index.ts new file mode 100644 index 00000000..1dfd0df3 --- /dev/null +++ b/src/renderer/features/notes/components/NoteEditor/index.ts @@ -0,0 +1 @@ +export { NoteEditor } from './NoteEditor'; diff --git a/src/renderer/features/notes/components/NoteEditor/useNoteEditor.ts b/src/renderer/features/notes/components/NoteEditor/useNoteEditor.ts new file mode 100644 index 00000000..f0fbb029 --- /dev/null +++ b/src/renderer/features/notes/components/NoteEditor/useNoteEditor.ts @@ -0,0 +1,71 @@ +/** + * useNoteEditor — logic for NoteEditor + */ + +import { useCallback, useEffect, useState } from 'react'; + +import type { Note } from '@shared/types'; + +import { useDeleteNote, useUpdateNote } from '../../api/useNotes'; +import { useNotesUI } from '../../store'; + +export function useNoteEditor(note: Note) { + const updateNote = useUpdateNote(); + const deleteNote = useDeleteNote(); + const { selectNote } = useNotesUI(); + + const [title, setTitle] = useState(note.title); + const [content, setContent] = useState(note.content); + const [tagsInput, setTagsInput] = useState(note.tags.join(', ')); + + // Sync local state when the selected note changes + useEffect(() => { + setTitle(note.title); + setContent(note.content); + setTagsInput(note.tags.join(', ')); + }, [note.id, note.title, note.content, note.tags]); + + const handleSave = useCallback(() => { + const tags = tagsInput + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); + + updateNote.mutate({ + id: note.id, + title, + content, + tags, + }); + }, [note.id, title, content, tagsInput, updateNote]); + + function handleDelete() { + deleteNote.mutate(note.id); + selectNote(null); + } + + function handleTogglePin() { + updateNote.mutate({ id: note.id, pinned: !note.pinned }); + } + + function handleClose() { + selectNote(null); + } + + const hasChanges = + title !== note.title || content !== note.content || tagsInput !== note.tags.join(', '); + + return { + title, + content, + tagsInput, + hasChanges, + setTitle, + setContent, + setTagsInput, + handleSave, + handleDelete, + handleTogglePin, + handleClose, + }; +} diff --git a/src/renderer/features/personal/notes/components/NotesList.tsx b/src/renderer/features/notes/components/NotesList/NotesList.tsx similarity index 81% rename from src/renderer/features/personal/notes/components/NotesList.tsx rename to src/renderer/features/notes/components/NotesList/NotesList.tsx index acd00ba1..c1a8601b 100644 --- a/src/renderer/features/personal/notes/components/NotesList.tsx +++ b/src/renderer/features/notes/components/NotesList/NotesList.tsx @@ -11,40 +11,22 @@ import { cn } from '@renderer/shared/lib/utils'; import { Button, ScrollArea, SearchInput } from '@ui'; -import { useCreateNote, useNotes, useSearchNotes } from '../api/useNotes'; -import { useNotesUI } from '../store'; +import { useNotesList } from './useNotesList'; // ── Component ──────────────────────────────────────────────── export function NotesList() { - const { selectedNoteId, searchQuery, selectedTag, selectNote, setSearchQuery, setSelectedTag } = - useNotesUI(); - - const createNote = useCreateNote(); - - const { data: allNotes } = useNotes(undefined, selectedTag ?? undefined); - const { data: searchResults } = useSearchNotes(searchQuery); - - const notes = searchQuery.length > 0 ? searchResults : allNotes; - const displayNotes = notes ?? []; - - // Collect all unique tags across notes - const allTags = [...new Set((allNotes ?? []).flatMap((n) => n.tags))].sort(); - - function handleCreateNote() { - createNote.mutate( - { title: 'Untitled Note', content: '' }, - { - onSuccess: (newNote) => { - selectNote(newNote.id); - }, - }, - ); - } - - function handleTagClick(tag: string) { - setSelectedTag(selectedTag === tag ? null : tag); - } + const { + selectedNoteId, + searchQuery, + selectedTag, + displayNotes, + allTags, + selectNote, + setSearchQuery, + handleCreateNote, + handleTagClick, + } = useNotesList(); return ( <div className="flex h-full flex-col"> diff --git a/src/renderer/features/notes/components/NotesList/index.ts b/src/renderer/features/notes/components/NotesList/index.ts new file mode 100644 index 00000000..c46f3a28 --- /dev/null +++ b/src/renderer/features/notes/components/NotesList/index.ts @@ -0,0 +1 @@ +export { NotesList } from './NotesList'; diff --git a/src/renderer/features/notes/components/NotesList/useNotesList.ts b/src/renderer/features/notes/components/NotesList/useNotesList.ts new file mode 100644 index 00000000..ea1691d1 --- /dev/null +++ b/src/renderer/features/notes/components/NotesList/useNotesList.ts @@ -0,0 +1,48 @@ +/** + * useNotesList — logic for NotesList + */ + +import { useCreateNote, useNotes, useSearchNotes } from '../../api/useNotes'; +import { useNotesUI } from '../../store'; + +export function useNotesList() { + const { selectedNoteId, searchQuery, selectedTag, selectNote, setSearchQuery, setSelectedTag } = + useNotesUI(); + + const createNote = useCreateNote(); + + const { data: allNotes } = useNotes(undefined, selectedTag ?? undefined); + const { data: searchResults } = useSearchNotes(searchQuery); + + const notes = searchQuery.length > 0 ? searchResults : allNotes; + const displayNotes = notes ?? []; + + const allTags = [...new Set((allNotes ?? []).flatMap((n) => n.tags))].sort(); + + function handleCreateNote() { + createNote.mutate( + { title: 'Untitled Note', content: '' }, + { + onSuccess: (newNote) => { + selectNote(newNote.id); + }, + }, + ); + } + + function handleTagClick(tag: string) { + setSelectedTag(selectedTag === tag ? null : tag); + } + + return { + selectedNoteId, + searchQuery, + selectedTag, + displayNotes, + allTags, + selectNote, + setSearchQuery, + handleCreateNote, + handleTagClick, + }; +} diff --git a/src/renderer/features/personal/notes/components/NotesPage.tsx b/src/renderer/features/notes/components/NotesPage.tsx similarity index 100% rename from src/renderer/features/personal/notes/components/NotesPage.tsx rename to src/renderer/features/notes/components/NotesPage.tsx diff --git a/src/renderer/features/notes/components/NotesPage/NotesPage.tsx b/src/renderer/features/notes/components/NotesPage/NotesPage.tsx new file mode 100644 index 00000000..4fe1bc98 --- /dev/null +++ b/src/renderer/features/notes/components/NotesPage/NotesPage.tsx @@ -0,0 +1,49 @@ +/** + * NotesPage — Split view: list + editor + */ + +import { Loader2, StickyNote } from 'lucide-react'; + +import { PageLayout } from '@ui'; + +import { NoteEditor } from '../NoteEditor'; +import { NotesList } from '../NotesList'; + +import { useNotesPage } from './useNotesPage'; + +// ── Component ──────────────────────────────────────────────── + +export function NotesPage() { + const { isLoading, selectedNote } = useNotesPage(); + + if (isLoading) { + return ( + <div className="flex h-full items-center justify-center"> + <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" /> + </div> + ); + } + + return ( + <PageLayout> + <div className="flex flex-1 overflow-hidden"> + {/* Left panel — notes list */} + <div className="border-border w-72 shrink-0 border-r"> + <NotesList /> + </div> + + {/* Right panel — editor or empty state */} + <div className="flex-1"> + {selectedNote ? ( + <NoteEditor note={selectedNote} /> + ) : ( + <div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3"> + <StickyNote className="h-12 w-12 opacity-30" /> + <p className="text-sm">Select a note or create a new one</p> + </div> + )} + </div> + </div> + </PageLayout> + ); +} diff --git a/src/renderer/features/notes/components/NotesPage/index.ts b/src/renderer/features/notes/components/NotesPage/index.ts new file mode 100644 index 00000000..5bf7471c --- /dev/null +++ b/src/renderer/features/notes/components/NotesPage/index.ts @@ -0,0 +1 @@ +export { NotesPage } from './NotesPage'; diff --git a/src/renderer/features/notes/components/NotesPage/useNotesPage.ts b/src/renderer/features/notes/components/NotesPage/useNotesPage.ts new file mode 100644 index 00000000..7574f240 --- /dev/null +++ b/src/renderer/features/notes/components/NotesPage/useNotesPage.ts @@ -0,0 +1,22 @@ +/** + * useNotesPage — logic for NotesPage + */ + +import { useNotes } from '../../api/useNotes'; +import { useNoteEvents } from '../../hooks/useNoteEvents'; +import { useNotesUI } from '../../store'; + +export function useNotesPage() { + const { selectedNoteId } = useNotesUI(); + const { data: notes, isLoading } = useNotes(); + + // Subscribe to real-time note events + useNoteEvents(); + + const selectedNote = (notes ?? []).find((n) => n.id === selectedNoteId); + + return { + isLoading, + selectedNote, + }; +} diff --git a/src/renderer/features/personal/notes/components/QuickNote.tsx b/src/renderer/features/notes/components/QuickNote.tsx similarity index 100% rename from src/renderer/features/personal/notes/components/QuickNote.tsx rename to src/renderer/features/notes/components/QuickNote.tsx diff --git a/src/renderer/features/notes/components/QuickNote/QuickNote.tsx b/src/renderer/features/notes/components/QuickNote/QuickNote.tsx new file mode 100644 index 00000000..15737c9f --- /dev/null +++ b/src/renderer/features/notes/components/QuickNote/QuickNote.tsx @@ -0,0 +1,78 @@ +/** + * QuickNote — Floating quick-add note widget + */ + +import { Plus, Send, X } from 'lucide-react'; + +import { Button, Card, CardContent, CardHeader, Input, Textarea } from '@ui'; + +import { useQuickNote } from './useQuickNote'; + +// ── Component ──────────────────────────────────────────────── + +export function QuickNote() { + const { + isOpen, + title, + content, + setIsOpen, + setTitle, + setContent, + handleSubmit, + handleKeyDown, + } = useQuickNote(); + + if (!isOpen) { + return ( + <Button + aria-label="Quick add note" + className="fixed right-6 bottom-6 z-50 rounded-full p-3 shadow-lg" + size="icon" + onClick={() => setIsOpen(true)} + > + <Plus className="h-5 w-5" /> + </Button> + ); + } + + return ( + <Card className="fixed right-6 bottom-6 z-50 w-80 shadow-xl"> + <CardHeader className="flex flex-row items-center justify-between py-2"> + <span className="text-foreground text-sm font-medium">Quick Note</span> + <Button + aria-label="Close quick note" + size="icon" + variant="ghost" + onClick={() => setIsOpen(false)} + > + <X className="h-4 w-4" /> + </Button> + </CardHeader> + <CardContent className="space-y-2 pb-3"> + <Input + aria-label="Note title" + placeholder="Title..." + value={title} + onChange={(e) => setTitle(e.target.value)} + onKeyDown={handleKeyDown} + /> + <Textarea + aria-label="Note content" + className="h-24 resize-none" + placeholder="Write a note... (Ctrl+Enter to save)" + value={content} + onChange={(e) => setContent(e.target.value)} + onKeyDown={handleKeyDown} + /> + <Button + className="w-full" + disabled={title.trim().length === 0} + onClick={handleSubmit} + > + <Send className="h-4 w-4" /> + Save Note + </Button> + </CardContent> + </Card> + ); +} diff --git a/src/renderer/features/notes/components/QuickNote/index.ts b/src/renderer/features/notes/components/QuickNote/index.ts new file mode 100644 index 00000000..9174c383 --- /dev/null +++ b/src/renderer/features/notes/components/QuickNote/index.ts @@ -0,0 +1 @@ +export { QuickNote } from './QuickNote'; diff --git a/src/renderer/features/notes/components/QuickNote/useQuickNote.ts b/src/renderer/features/notes/components/QuickNote/useQuickNote.ts new file mode 100644 index 00000000..55734422 --- /dev/null +++ b/src/renderer/features/notes/components/QuickNote/useQuickNote.ts @@ -0,0 +1,58 @@ +/** + * useQuickNote — logic for QuickNote + */ + +import { useState } from 'react'; + +import { useDialogWithMutation } from '@renderer/shared/hooks/useDialogWithMutation'; +import { useModalFormState } from '@renderer/shared/hooks/useModalFormState'; + +import { useCreateNote } from '../../api/useNotes'; + +interface QuickNoteFormValues { + title: string; + content: string; +} + +const QUICK_NOTE_DEFAULTS: QuickNoteFormValues = { + title: '', + content: '', +}; + +export function useQuickNote() { + const [isOpen, setIsOpen] = useState(false); + const createNote = useCreateNote(); + + const { values, update, reset } = useModalFormState<QuickNoteFormValues>( + isOpen, + QUICK_NOTE_DEFAULTS, + ); + + const { handleSubmit: submitMutation, isPending } = useDialogWithMutation(createNote, { + onClose: () => setIsOpen(false), + resetForm: reset, + }); + + function handleSubmit() { + if (values.title.trim().length === 0) return; + submitMutation({ title: values.title.trim(), content: values.content.trim() }); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && e.ctrlKey) { + handleSubmit(); + } + } + + return { + isOpen, + title: values.title, + content: values.content, + isPending, + setIsOpen, + setTitle: (v: string) => update('title', v), + setContent: (v: string) => update('content', v), + handleSubmit, + handleKeyDown, + }; +} diff --git a/src/renderer/features/personal/notes/hooks/useNoteEvents.ts b/src/renderer/features/notes/hooks/useNoteEvents.ts similarity index 88% rename from src/renderer/features/personal/notes/hooks/useNoteEvents.ts rename to src/renderer/features/notes/hooks/useNoteEvents.ts index 10726096..0a4c2693 100644 --- a/src/renderer/features/personal/notes/hooks/useNoteEvents.ts +++ b/src/renderer/features/notes/hooks/useNoteEvents.ts @@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query'; -import { NOTES_EVENTS } from '@shared/ipc/misc/notes.channels'; +import { NOTES_EVENTS } from '@shared/ipc/notes'; import { useIpcEvent } from '@renderer/shared/hooks'; diff --git a/src/renderer/features/personal/notes/index.ts b/src/renderer/features/notes/index.ts similarity index 100% rename from src/renderer/features/personal/notes/index.ts rename to src/renderer/features/notes/index.ts diff --git a/src/renderer/features/personal/notes/store.ts b/src/renderer/features/notes/store.ts similarity index 100% rename from src/renderer/features/personal/notes/store.ts rename to src/renderer/features/notes/store.ts diff --git a/src/renderer/features/onboarding/components/ClaudeCliStep.tsx b/src/renderer/features/onboarding/components/ClaudeCliStep.tsx deleted file mode 100644 index a09e0e69..00000000 --- a/src/renderer/features/onboarding/components/ClaudeCliStep.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/** - * ClaudeCliStep — Claude Code authorization step in the onboarding wizard. - * - * Shows an Authorize button immediately. Clicking it runs `claude auth login` - * in the background, which opens the Anthropic OAuth page in the browser. - * Auto-polls for auth completion and updates the UI. - */ - -import { useEffect, useState } from 'react'; - -import { ArrowLeft, ArrowRight, CheckCircle2, Loader2, Terminal } from 'lucide-react'; - -import { APP } from '@shared/ipc/app/channels'; - -import { useClaudeAuth } from '@renderer/shared/hooks'; -import { ipc } from '@renderer/shared/lib/ipc'; - -import { Button, Card, CardContent, CardHeader } from '@ui'; - -// ── Types ─────────────────────────────────────────────────── - -interface ClaudeCliStepProps { - onNext: () => void; - onBack: () => void; -} - -// ── Main Component ─────────────────────────────────────────── - -export function ClaudeCliStep({ onNext, onBack }: ClaudeCliStepProps) { - const { data: auth, refetch } = useClaudeAuth(); - const [authorizing, setAuthorizing] = useState(false); - const [error, setError] = useState<string | null>(null); - - const isAuthenticated = auth?.authenticated ?? false; - - // Poll for auth status while authorizing - useEffect(() => { - if (!authorizing) return; - const interval = setInterval(() => { - void refetch(); - }, 3000); - return () => { - clearInterval(interval); - }; - }, [authorizing, refetch]); - - // Stop polling once authenticated - useEffect(() => { - if (isAuthenticated && authorizing) { - setAuthorizing(false); - } - }, [isAuthenticated, authorizing]); - - async function handleAuthorize() { - setError(null); - setAuthorizing(true); - const result = await ipc(APP.LAUNCH['CLAUDE-AUTH'], {}); - if (!result.success && !isAuthenticated) { - setError('Authorization failed. Make sure Claude Code is installed (npm install -g @anthropic-ai/claude-code).'); - setAuthorizing(false); - } - } - - return ( - <div className="flex flex-col items-center"> - <h2 className="mb-2 text-2xl font-bold text-foreground">Claude Code Setup</h2> - <p className="mb-6 max-w-md text-center text-sm text-muted-foreground"> - ADC uses Claude Code to run autonomous coding agents. Authorize to connect your Anthropic - account. - </p> - - {/* Card */} - <Card className="mb-5 w-full max-w-lg overflow-hidden"> - <CardHeader className="flex flex-row items-center gap-2 border-b bg-muted/50 px-4 py-2.5"> - <Terminal className="size-4 text-muted-foreground" /> - <span className="text-xs font-medium text-muted-foreground">Claude Authorization</span> - </CardHeader> - - <CardContent className="space-y-4 p-5"> - {isAuthenticated ? ( - <div className="space-y-2"> - <div className="flex items-center gap-2"> - <CheckCircle2 className="size-5 text-success" /> - <span className="text-sm font-bold text-card-foreground">Authorized</span> - </div> - <p className="text-xs text-muted-foreground"> - Claude Code is authorized. ADC can now launch and manage autonomous coding agents. - </p> - </div> - ) : ( - <> - <p className="text-sm text-muted-foreground"> - Click <strong>Authorize</strong> to open the Anthropic authorization page in your - browser. Sign in and click Authorize to connect your account. - </p> - - {authorizing ? ( - <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2.5"> - <Loader2 className="size-4 animate-spin text-primary" /> - <span className="text-xs text-primary"> - Browser opened — click Authorize on the Anthropic page... - </span> - </div> - ) : null} - - {error ? ( - <div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"> - {error} - </div> - ) : null} - - <Button - className="w-full justify-center" - disabled={authorizing} - onClick={() => { - void handleAuthorize(); - }} - > - <Terminal className="size-4" /> - {authorizing ? 'Waiting...' : 'Authorize'} - </Button> - </> - )} - </CardContent> - </Card> - - {/* Navigation */} - <div className="flex items-center gap-3"> - <Button variant="outline" onClick={onBack}> - <ArrowLeft className="size-4" /> - Back - </Button> - - <Button onClick={onNext}> - {isAuthenticated ? 'Continue' : 'Skip for Now'} - <ArrowRight className="size-4" /> - </Button> - </div> - </div> - ); -} diff --git a/src/renderer/features/onboarding/components/ClaudeCliStep/ClaudeCliStep.tsx b/src/renderer/features/onboarding/components/ClaudeCliStep/ClaudeCliStep.tsx new file mode 100644 index 00000000..4e7ad846 --- /dev/null +++ b/src/renderer/features/onboarding/components/ClaudeCliStep/ClaudeCliStep.tsx @@ -0,0 +1,104 @@ +/** + * ClaudeCliStep — Claude Code authorization step in the onboarding wizard. + * + * Shows an Authorize button immediately. Clicking it runs `claude auth login` + * in the background, which opens the Anthropic OAuth page in the browser. + * Auto-polls for auth completion and updates the UI. + */ + +import { ArrowLeft, ArrowRight, CheckCircle2, Loader2, Terminal } from 'lucide-react'; + +import { Button, Card, CardContent, CardHeader, Heading } from '@ui'; + +import { useClaudeCliStep } from './useClaudeCliStep'; + +// ── Types ─────────────────────────────────────────────────── + +interface ClaudeCliStepProps { + onNext: () => void; + onBack: () => void; +} + +// ── Main Component ─────────────────────────────────────────── + +export function ClaudeCliStep({ onNext, onBack }: ClaudeCliStepProps) { + const { isAuthenticated, authorizing, error, handleAuthorize } = useClaudeCliStep(); + + return ( + <div className="flex flex-col items-center"> + <Heading as="h2" className="mb-2 text-2xl font-bold">Claude Code Setup</Heading> + <p className="mb-6 max-w-md text-center text-sm text-muted-foreground"> + ADC uses Claude Code to run autonomous coding agents. Authorize to connect your Anthropic + account. + </p> + + {/* Card */} + <Card className="mb-5 w-full max-w-lg overflow-hidden"> + <CardHeader className="flex flex-row items-center gap-2 border-b bg-muted/50 px-4 py-2.5"> + <Terminal className="size-4 text-muted-foreground" /> + <span className="text-xs font-medium text-muted-foreground">Claude Authorization</span> + </CardHeader> + + <CardContent className="space-y-4 p-5"> + {isAuthenticated ? ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <CheckCircle2 className="size-5 text-success" /> + <span className="text-sm font-bold text-card-foreground">Authorized</span> + </div> + <p className="text-xs text-muted-foreground"> + Claude Code is authorized. ADC can now launch and manage autonomous coding agents. + </p> + </div> + ) : ( + <> + <p className="text-sm text-muted-foreground"> + Click <strong>Authorize</strong> to open the Anthropic authorization page in your + browser. Sign in and click Authorize to connect your account. + </p> + + {authorizing ? ( + <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2.5"> + <Loader2 className="size-4 animate-spin text-primary" /> + <span className="text-xs text-primary"> + Browser opened — click Authorize on the Anthropic page... + </span> + </div> + ) : null} + + {error ? ( + <div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"> + {error} + </div> + ) : null} + + <Button + className="w-full justify-center" + disabled={authorizing} + onClick={() => { + void handleAuthorize(); + }} + > + <Terminal className="size-4" /> + {authorizing ? 'Waiting...' : 'Authorize'} + </Button> + </> + )} + </CardContent> + </Card> + + {/* Navigation */} + <div className="flex items-center gap-3"> + <Button variant="outline" onClick={onBack}> + <ArrowLeft className="size-4" /> + Back + </Button> + + <Button onClick={onNext}> + {isAuthenticated ? 'Continue' : 'Skip for Now'} + <ArrowRight className="size-4" /> + </Button> + </div> + </div> + ); +} diff --git a/src/renderer/features/onboarding/components/ClaudeCliStep/index.ts b/src/renderer/features/onboarding/components/ClaudeCliStep/index.ts new file mode 100644 index 00000000..a5326830 --- /dev/null +++ b/src/renderer/features/onboarding/components/ClaudeCliStep/index.ts @@ -0,0 +1 @@ +export { ClaudeCliStep } from './ClaudeCliStep'; diff --git a/src/renderer/features/onboarding/components/ClaudeCliStep/useClaudeCliStep.ts b/src/renderer/features/onboarding/components/ClaudeCliStep/useClaudeCliStep.ts new file mode 100644 index 00000000..c0597c96 --- /dev/null +++ b/src/renderer/features/onboarding/components/ClaudeCliStep/useClaudeCliStep.ts @@ -0,0 +1,40 @@ +/** + * useClaudeCliStep — logic for ClaudeCliStep + */ + +import { useState } from 'react'; + +import { APP } from '@shared/ipc/app/channels'; + +import { useClaudeAuth } from '@renderer/shared/hooks'; +import { useAuthPolling } from '@renderer/shared/hooks/useAuthPolling'; +import { ipc } from '@renderer/shared/lib/ipc'; + +export function useClaudeCliStep() { + const { data: auth, refetch } = useClaudeAuth(); + const [error, setError] = useState<string | null>(null); + + const isAuthenticated = auth?.authenticated ?? false; + + const { authorizing, setAuthorizing, handleAuthorize: startPolling } = useAuthPolling( + refetch, + isAuthenticated, + ); + + async function handleAuthorize() { + setError(null); + startPolling(); + const result = await ipc(APP.LAUNCH['CLAUDE-AUTH'], {}); + if (!result.success && !isAuthenticated) { + setError('Authorization failed. Make sure Claude Code is installed (npm install -g @anthropic-ai/claude-code).'); + setAuthorizing(false); + } + } + + return { + isAuthenticated, + authorizing, + error, + handleAuthorize, + }; +} diff --git a/src/renderer/features/onboarding/components/CompleteStep.tsx b/src/renderer/features/onboarding/components/CompleteStep/CompleteStep.tsx similarity index 79% rename from src/renderer/features/onboarding/components/CompleteStep.tsx rename to src/renderer/features/onboarding/components/CompleteStep/CompleteStep.tsx index ee4e53e8..a8ed15db 100644 --- a/src/renderer/features/onboarding/components/CompleteStep.tsx +++ b/src/renderer/features/onboarding/components/CompleteStep/CompleteStep.tsx @@ -6,9 +6,9 @@ import { Check, Loader2, Rocket } from 'lucide-react'; -import { Button, Card, CardContent } from '@ui'; +import { Button, Card, CardContent, Heading } from '@ui'; -import { useUpdateSettings } from '@features/settings'; +import { useCompleteStep } from './useCompleteStep'; // ── Types ─────────────────────────────────────────────────── @@ -19,18 +19,7 @@ interface CompleteStepProps { // ── Component ─────────────────────────────────────────────── export function CompleteStep({ onComplete }: CompleteStepProps) { - const updateSettings = useUpdateSettings(); - - function handleFinish() { - updateSettings.mutate( - { onboardingCompleted: true }, - { - onSuccess() { - onComplete(); - }, - }, - ); - } + const { isPending, handleFinish } = useCompleteStep(onComplete); return ( <div className="flex flex-col items-center text-center"> @@ -39,7 +28,7 @@ export function CompleteStep({ onComplete }: CompleteStepProps) { <Check className="text-success h-10 w-10" /> </div> - <h2 className="text-foreground mb-3 text-3xl font-bold">You're All Set!</h2> + <Heading as="h2" className="mb-3 text-3xl font-bold">You're All Set!</Heading> <p className="text-muted-foreground mb-8 max-w-md text-lg"> ADC is ready to help you manage your coding projects with AI-powered agents. @@ -48,7 +37,7 @@ export function CompleteStep({ onComplete }: CompleteStepProps) { {/* Quick tips */} <Card className="mb-8 w-full max-w-md text-left"> <CardContent className="p-6"> - <h3 className="text-foreground mb-4 font-semibold">Quick Tips</h3> + <Heading as="h3" className="mb-4">Quick Tips</Heading> <ul className="text-muted-foreground space-y-3 text-sm"> <li className="flex gap-3"> <Rocket className="text-primary mt-0.5 h-4 w-4 shrink-0" /> @@ -68,11 +57,11 @@ export function CompleteStep({ onComplete }: CompleteStepProps) { {/* CTA */} <Button - disabled={updateSettings.isPending} + disabled={isPending} size="lg" onClick={handleFinish} > - {updateSettings.isPending ? ( + {isPending ? ( <Loader2 className="h-4 w-4 animate-spin" /> ) : ( <> diff --git a/src/renderer/features/onboarding/components/CompleteStep/index.ts b/src/renderer/features/onboarding/components/CompleteStep/index.ts new file mode 100644 index 00000000..982125b2 --- /dev/null +++ b/src/renderer/features/onboarding/components/CompleteStep/index.ts @@ -0,0 +1 @@ +export { CompleteStep } from './CompleteStep'; diff --git a/src/renderer/features/onboarding/components/CompleteStep/useCompleteStep.ts b/src/renderer/features/onboarding/components/CompleteStep/useCompleteStep.ts new file mode 100644 index 00000000..a87a0683 --- /dev/null +++ b/src/renderer/features/onboarding/components/CompleteStep/useCompleteStep.ts @@ -0,0 +1,25 @@ +/** + * useCompleteStep — logic for CompleteStep + */ + +import { useUpdateSettings } from '@features/settings'; + +export function useCompleteStep(onComplete: () => void) { + const updateSettings = useUpdateSettings(); + + function handleFinish() { + updateSettings.mutate( + { onboardingCompleted: true }, + { + onSuccess() { + onComplete(); + }, + }, + ); + } + + return { + isPending: updateSettings.isPending, + handleFinish, + }; +} diff --git a/src/renderer/features/onboarding/components/IntegrationsStep.tsx b/src/renderer/features/onboarding/components/IntegrationsStep.tsx index 3c0dadf5..1b5b9e1d 100644 --- a/src/renderer/features/onboarding/components/IntegrationsStep.tsx +++ b/src/renderer/features/onboarding/components/IntegrationsStep.tsx @@ -14,7 +14,7 @@ import { APP } from '@shared/ipc/app/channels'; import { ipc } from '@renderer/shared/lib/ipc'; -import { Button, Card, CardContent, CardHeader } from '@ui'; +import { Button, Card, CardContent, CardHeader, Heading } from '@ui'; // ── Icons ─────────────────────────────────────────────────── @@ -144,7 +144,7 @@ export function IntegrationsStep({ onBack, onNext, onSkip }: IntegrationsStepPro return ( <div className="flex flex-col items-center"> - <h2 className="mb-2 text-2xl font-bold text-foreground">Integrations</h2> + <Heading as="h2" className="mb-2 text-2xl font-bold">Integrations</Heading> <p className="mb-6 max-w-md text-center text-sm text-muted-foreground"> Connect your accounts to unlock additional features. You can always configure these later in Settings. diff --git a/src/renderer/features/onboarding/components/IntegrationsStep/IntegrationsStep.tsx b/src/renderer/features/onboarding/components/IntegrationsStep/IntegrationsStep.tsx new file mode 100644 index 00000000..2eaa0e74 --- /dev/null +++ b/src/renderer/features/onboarding/components/IntegrationsStep/IntegrationsStep.tsx @@ -0,0 +1,132 @@ +/** + * IntegrationsStep — One-click GitHub authorization via `gh auth login`. + * + * Shows a Connect button for GitHub that launches the device flow auth + * (opens browser). Polls for auth completion. Google/Slack deferred to Settings. + */ + +import { ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from 'lucide-react'; + +import { Button, Card, CardContent, CardHeader, Heading } from '@ui'; + +import { useIntegrationsStep } from './useIntegrationsStep'; + +// ── Icons ─────────────────────────────────────────────────── + +/** GitHub mark (Simple Icons) — lucide deprecated their Github icon. */ +function GitHubIcon({ className }: { className?: string }) { + return ( + <svg className={className} fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /> + </svg> + ); +} + +// ── Types ─────────────────────────────────────────────────── + +interface IntegrationsStepProps { + onBack: () => void; + onNext: () => void; + onSkip: () => void; +} + +// ── Component ─────────────────────────────────────────────── + +export function IntegrationsStep({ onBack, onNext, onSkip }: IntegrationsStepProps) { + const { ghAuth, isAuthenticated, isInstalled, authorizing, error, handleConnect } = + useIntegrationsStep(); + + function renderGitHubCard() { + if (isAuthenticated) { + return ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <CheckCircle2 className="size-5 text-success" /> + <span className="text-sm font-bold text-card-foreground">Connected</span> + {ghAuth?.username ? ( + <code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"> + {ghAuth.username} + </code> + ) : null} + </div> + <p className="text-xs text-muted-foreground"> + GitHub is connected. ADC can access your repositories, pull requests, and issues. + </p> + </div> + ); + } + + return ( + <> + <p className="text-sm text-muted-foreground"> + {isInstalled + ? 'Click Connect to open the GitHub authorization page in your browser.' + : 'Install the GitHub CLI first, then click Connect to authorize.'} + </p> + + {authorizing ? ( + <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2.5"> + <Loader2 className="size-4 animate-spin text-primary" /> + <span className="text-xs text-primary"> + Browser opened — authorize on the GitHub page... + </span> + </div> + ) : null} + + {error === null ? null : ( + <div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"> + {error} + </div> + )} + + <Button + className="w-full justify-center bg-foreground text-background hover:bg-foreground/90" + disabled={authorizing} + onClick={() => { + void handleConnect(); + }} + > + <GitHubIcon className="size-4" /> + {authorizing ? 'Waiting...' : 'Connect GitHub'} + </Button> + </> + ); + } + + return ( + <div className="flex flex-col items-center"> + <Heading as="h2" className="mb-2 text-2xl font-bold">Integrations</Heading> + <p className="mb-6 max-w-md text-center text-sm text-muted-foreground"> + Connect your accounts to unlock additional features. You can always configure these later in + Settings. + </p> + + {/* GitHub Card */} + <Card className="mb-5 w-full max-w-lg overflow-hidden"> + <CardHeader className="flex flex-row items-center gap-2 border-b bg-muted/50 px-4 py-2.5"> + <GitHubIcon className="size-4 text-muted-foreground" /> + <span className="text-xs font-medium text-muted-foreground">GitHub</span> + </CardHeader> + <CardContent className="space-y-4 p-5">{renderGitHubCard()}</CardContent> + </Card> + + {/* Additional integrations note */} + <p className="mb-5 max-w-md text-center text-xs text-muted-foreground"> + Google and Slack integrations can be configured in Settings. + </p> + + {/* Navigation */} + <div className="flex items-center gap-3"> + <Button variant="outline" onClick={onBack}> + <ArrowLeft className="size-4" /> + Back + </Button> + + <Button onClick={isAuthenticated ? onNext : onSkip}> + {isAuthenticated ? 'Continue' : 'Skip for Now'} + <ArrowRight className="size-4" /> + </Button> + </div> + </div> + ); +} diff --git a/src/renderer/features/onboarding/components/IntegrationsStep/index.ts b/src/renderer/features/onboarding/components/IntegrationsStep/index.ts new file mode 100644 index 00000000..c49e9be6 --- /dev/null +++ b/src/renderer/features/onboarding/components/IntegrationsStep/index.ts @@ -0,0 +1 @@ +export { IntegrationsStep } from './IntegrationsStep'; diff --git a/src/renderer/features/onboarding/components/IntegrationsStep/useIntegrationsStep.ts b/src/renderer/features/onboarding/components/IntegrationsStep/useIntegrationsStep.ts new file mode 100644 index 00000000..8e3f7da3 --- /dev/null +++ b/src/renderer/features/onboarding/components/IntegrationsStep/useIntegrationsStep.ts @@ -0,0 +1,58 @@ +/** + * useIntegrationsStep — logic for IntegrationsStep (includes GitHub auth) + */ + +import { useState } from 'react'; + +import { useQuery } from '@tanstack/react-query'; + +import { APP } from '@shared/ipc/app/channels'; + +import { useAuthPolling } from '@renderer/shared/hooks/useAuthPolling'; +import { ipc } from '@renderer/shared/lib/ipc'; + +// ── GitHub Auth Hook ───────────────────────────────────────── + +export function useGitHubAuth() { + return useQuery({ + queryKey: ['app', 'githubAuth'], + queryFn: () => ipc(APP.CHECK['GITHUB-AUTH'], {}), + staleTime: 30_000, + }); +} + +// ── Main Hook ───────────────────────────────────────────────── + +export function useIntegrationsStep() { + const { data: ghAuth, refetch } = useGitHubAuth(); + const [error, setError] = useState<string | null>(null); + + const isAuthenticated = ghAuth?.authenticated ?? false; + const isInstalled = ghAuth?.installed ?? false; + + const { authorizing, setAuthorizing, handleAuthorize: startPolling } = useAuthPolling( + refetch, + isAuthenticated, + ); + + async function handleConnect() { + setError(null); + startPolling(); + const result = await ipc(APP.LAUNCH['GITHUB-AUTH'], {}); + if (!result.success && !isAuthenticated) { + setError( + 'GitHub authorization failed. Make sure the GitHub CLI is installed (https://cli.github.com).', + ); + setAuthorizing(false); + } + } + + return { + ghAuth, + isAuthenticated, + isInstalled, + authorizing, + error, + handleConnect, + }; +} diff --git a/src/renderer/features/onboarding/components/OnboardingWizard.tsx b/src/renderer/features/onboarding/components/OnboardingWizard/OnboardingWizard.tsx similarity index 80% rename from src/renderer/features/onboarding/components/OnboardingWizard.tsx rename to src/renderer/features/onboarding/components/OnboardingWizard/OnboardingWizard.tsx index 36b04f77..20899b12 100644 --- a/src/renderer/features/onboarding/components/OnboardingWizard.tsx +++ b/src/renderer/features/onboarding/components/OnboardingWizard/OnboardingWizard.tsx @@ -9,14 +9,13 @@ import { cn } from '@renderer/shared/lib/utils'; import { Button } from '@ui'; -import { useUpdateSettings } from '@features/settings'; +import { getStepIndex, getTotalSteps } from '../../store'; +import { ClaudeCliStep } from '../ClaudeCliStep'; +import { CompleteStep } from '../CompleteStep'; +import { IntegrationsStep } from '../IntegrationsStep'; +import { WelcomeStep } from '../WelcomeStep'; -import { getStepIndex, getTotalSteps, useOnboardingStore } from '../store'; - -import { ClaudeCliStep } from './ClaudeCliStep'; -import { CompleteStep } from './CompleteStep'; -import { IntegrationsStep } from './IntegrationsStep'; -import { WelcomeStep } from './WelcomeStep'; +import { useOnboardingWizard } from './useOnboardingWizard'; // ── Types ─────────────────────────────────────────────────── @@ -36,9 +35,8 @@ function getProgressDotClasses(index: number, currentIndex: number): string { return 'bg-muted w-2'; } -function ProgressIndicator() { - const { currentStep } = useOnboardingStore(); - const currentIndex = getStepIndex(currentStep); +function ProgressIndicator({ currentStep }: { currentStep: string }) { + const currentIndex = getStepIndex(currentStep as Parameters<typeof getStepIndex>[0]); const totalSteps = getTotalSteps(); return ( @@ -61,15 +59,14 @@ function ProgressIndicator() { // ── Component ─────────────────────────────────────────────── export function OnboardingWizard({ onComplete }: OnboardingWizardProps) { - const { currentStep, nextStep, previousStep, skipIntegrations } = useOnboardingStore(); - const updateSettings = useUpdateSettings(); - - function handleSkipOnboarding() { - updateSettings.mutate( - { onboardingCompleted: true }, - { onSuccess() { onComplete(); } }, - ); - } + const { + currentStep, + nextStep, + previousStep, + skipIntegrations, + updateSettings, + handleSkipOnboarding, + } = useOnboardingWizard(onComplete); function renderStep() { switch (currentStep) { @@ -94,7 +91,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) { <header className="border-border flex items-center justify-between border-b px-6 py-4"> <span className="text-foreground text-lg font-semibold">Setup</span> <div className="flex items-center gap-4"> - <ProgressIndicator /> + <ProgressIndicator currentStep={currentStep} /> <Button disabled={updateSettings.isPending} size="sm" diff --git a/src/renderer/features/onboarding/components/OnboardingWizard/index.ts b/src/renderer/features/onboarding/components/OnboardingWizard/index.ts new file mode 100644 index 00000000..6893da15 --- /dev/null +++ b/src/renderer/features/onboarding/components/OnboardingWizard/index.ts @@ -0,0 +1 @@ +export { OnboardingWizard } from './OnboardingWizard'; diff --git a/src/renderer/features/onboarding/components/OnboardingWizard/useOnboardingWizard.ts b/src/renderer/features/onboarding/components/OnboardingWizard/useOnboardingWizard.ts new file mode 100644 index 00000000..e71531b4 --- /dev/null +++ b/src/renderer/features/onboarding/components/OnboardingWizard/useOnboardingWizard.ts @@ -0,0 +1,28 @@ +/** + * useOnboardingWizard — logic for OnboardingWizard + */ + +import { useUpdateSettings } from '@features/settings'; + +import { useOnboardingStore } from '../../store'; + +export function useOnboardingWizard(onComplete: () => void) { + const { currentStep, nextStep, previousStep, skipIntegrations } = useOnboardingStore(); + const updateSettings = useUpdateSettings(); + + function handleSkipOnboarding() { + updateSettings.mutate( + { onboardingCompleted: true }, + { onSuccess() { onComplete(); } }, + ); + } + + return { + currentStep, + nextStep, + previousStep, + skipIntegrations, + updateSettings, + handleSkipOnboarding, + }; +} diff --git a/src/renderer/features/onboarding/components/WelcomeStep.tsx b/src/renderer/features/onboarding/components/WelcomeStep.tsx index c1162d1b..a354fa4c 100644 --- a/src/renderer/features/onboarding/components/WelcomeStep.tsx +++ b/src/renderer/features/onboarding/components/WelcomeStep.tsx @@ -6,7 +6,7 @@ import { ArrowRight, Sparkles, Zap } from 'lucide-react'; -import { Button, Card, CardContent } from '@ui'; +import { Button, Card, CardContent, Heading } from '@ui'; // ── Constants ─────────────────────────────────────────────── @@ -39,7 +39,7 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) { <Sparkles className="text-primary h-10 w-10" /> </div> - <h1 className="text-foreground mb-3 text-3xl font-bold">Welcome to ADC</h1> + <Heading as="h1" className="mb-3 text-3xl font-bold">Welcome to ADC</Heading> <p className="text-muted-foreground mb-8 max-w-md text-lg"> Your desktop companion for managing Claude autonomous coding agents. @@ -54,7 +54,7 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) { <feature.icon className="text-primary h-5 w-5" /> </div> <div> - <h3 className="text-foreground font-medium">{feature.title}</h3> + <Heading as="h3" className="font-medium">{feature.title}</Heading> <p className="text-muted-foreground mt-1 text-sm">{feature.description}</p> </div> </CardContent> diff --git a/src/renderer/features/peers/api/queryKeys.ts b/src/renderer/features/peers/api/queryKeys.ts new file mode 100644 index 00000000..362dcf08 --- /dev/null +++ b/src/renderer/features/peers/api/queryKeys.ts @@ -0,0 +1,9 @@ +/** + * Peers query key factory — re-exported from @shared/ipc/peers. + * + * The canonical definition now lives in `src/shared/ipc/peers/queryKeys.ts` + * so cross-feature consumers (e.g. EventBridge) can import it without + * crossing the renderer's `boundaries/dependencies` rule. This re-export + * preserves the existing import path for renderer code. Audit 05/T13. + */ +export { peerKeys } from '@shared/ipc/peers'; diff --git a/src/renderer/features/peers/api/usePeerEvents.ts b/src/renderer/features/peers/api/usePeerEvents.ts new file mode 100644 index 00000000..d10411f1 --- /dev/null +++ b/src/renderer/features/peers/api/usePeerEvents.ts @@ -0,0 +1,22 @@ +import { useCallback, useState } from 'react'; + +import { PEERS_EVENTS } from '@shared/ipc/peers'; +import type { PinIssuedEvent } from '@shared/ipc/peers'; + +import { useIpcEvent } from '@renderer/shared/hooks/useIpcEvent'; + +/** + * Subscribes to PEERS_EVENTS.PIN.ISSUED and exposes the latest unseen PIN. + * Caller calls `dismiss()` when the user closes the dialog. Multiple PINs + * in quick succession overwrite the previous (Phase 3b accepts this). + */ +export function useIncomingPin(): { pin: PinIssuedEvent | null; dismiss: () => void } { + const [pin, setPin] = useState<PinIssuedEvent | null>(null); + useIpcEvent(PEERS_EVENTS.PIN.ISSUED, (payload) => { + setPin(payload); + }); + const dismiss = useCallback(() => { + setPin(null); + }, []); + return { pin, dismiss }; +} diff --git a/src/renderer/features/peers/api/usePeers.ts b/src/renderer/features/peers/api/usePeers.ts new file mode 100644 index 00000000..fba02146 --- /dev/null +++ b/src/renderer/features/peers/api/usePeers.ts @@ -0,0 +1,76 @@ +/** + * React Query hooks for peers + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { PEERS } from '@shared/ipc/peers'; +import type { PairConfirmInput, PairInitInput } from '@shared/ipc/peers'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { peerKeys } from './queryKeys'; + +/** Fetch the list of paired peers */ +export function usePairedPeers() { + return useQuery({ + queryKey: peerKeys.paired(), + queryFn: () => ipc(PEERS.LIST.PAIRED, {}), + staleTime: 30_000, + }); +} + +/** Fetch the list of mDNS-discovered peers on the local network */ +export function useDiscoveredPeers() { + return useQuery({ + queryKey: peerKeys.discovered(), + queryFn: () => ipc(PEERS.LIST.DISCOVERED, {}), + staleTime: 5_000, + }); +} + +/** Fetch this device's own peer identity */ +export function useSelfIdentity() { + return useQuery({ + queryKey: peerKeys.identity(), + queryFn: () => ipc(PEERS.IDENTITY.GET, {}), + staleTime: Infinity, + }); +} + +/** + * Initiate a pairing session. Returns `{ sessionId, challenge }`. + * Caller holds the session+challenge and prompts the user for the PIN + * before calling `usePairConfirm`. No cache invalidation here — pairing + * only completes on the confirm step. + */ +export function usePairInit() { + return useMutation({ + mutationFn: (input: PairInitInput) => ipc(PEERS.PAIR.INIT, input), + }); +} + +/** + * Complete a pairing session by submitting the PIN. On success, invalidates + * the paired-peers list so the new peer appears. + */ +export function usePairConfirm() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: PairConfirmInput) => ipc(PEERS.PAIR.CONFIRM, input), + onSuccess() { + void queryClient.invalidateQueries({ queryKey: peerKeys.paired() }); + }, + }); +} + +/** Revoke trust in a paired peer. Invalidates the paired-peers list. */ +export function useRevokePeer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (peerId: string) => ipc(PEERS.REVOKE.PEER, { peerId }), + onSuccess() { + void queryClient.invalidateQueries({ queryKey: peerKeys.paired() }); + }, + }); +} diff --git a/src/renderer/features/peers/components/IncomingPinDialog.tsx b/src/renderer/features/peers/components/IncomingPinDialog.tsx new file mode 100644 index 00000000..d40f52f1 --- /dev/null +++ b/src/renderer/features/peers/components/IncomingPinDialog.tsx @@ -0,0 +1,52 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Stack, + Text, +} from '@ui'; + +import { useIncomingPin } from '../api/usePeerEvents'; +import { peerLabel } from '../lib/format'; + +/** + * Receiver-side modal showing the PIN issued to a remote initiator. + * Mounted once at the app shell — driven entirely by useIncomingPin. + */ +export function IncomingPinDialog() { + const { pin, dismiss } = useIncomingPin(); + if (pin === null) return null; + const initiatorLabel = peerLabel({ + displayName: pin.initiatorDisplayName, + peerId: pin.initiatorPeerId, + }); + return ( + <Dialog + open + onOpenChange={(open) => { + if (!open) dismiss(); + }} + > + <DialogContent> + <DialogHeader> + <DialogTitle>Pairing request</DialogTitle> + <DialogDescription> + A device ({initiatorLabel}) is requesting to pair. Read this PIN to that device. + </DialogDescription> + </DialogHeader> + <Stack align="center" gap="md"> + <Text className="text-3xl font-mono tracking-widest" size="lg"> + {pin.pin} + </Text> + </Stack> + <DialogFooter> + <Button onClick={dismiss}>Done</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/peers/components/OutgoingPairDialog.tsx b/src/renderer/features/peers/components/OutgoingPairDialog.tsx new file mode 100644 index 00000000..6ddbc1f0 --- /dev/null +++ b/src/renderer/features/peers/components/OutgoingPairDialog.tsx @@ -0,0 +1,110 @@ +import type { DiscoveredPeer } from '@shared/ipc/peers'; + +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Stack, + Text, +} from '@ui'; + +import { useOutgoingPair } from '../hooks/useOutgoingPair'; + +interface OutgoingPairDialogProps { + target: DiscoveredPeer; + onClose: () => void; +} + +/** + * Initiator-side three-step flow: send invite → enter PIN → confirm. + * Render-only — all state and mutations live in `useOutgoingPair`. + */ +export function OutgoingPairDialog({ target, onClose }: OutgoingPairDialogProps) { + const vm = useOutgoingPair(target, onClose); + + return ( + <Dialog + open + onOpenChange={(open) => { + if (!open) vm.close(); + }} + > + <DialogContent> + <DialogHeader> + <DialogTitle>Pair with {vm.targetLabel}</DialogTitle> + <DialogDescription> + {target.host}:{target.port} + </DialogDescription> + </DialogHeader> + + {vm.stage === 'idle' && ( + <Stack gap="md"> + <Text variant="muted"> + Click Send invite. The receiving device will display a 6-digit PIN that you enter + here to complete pairing. + </Text> + {vm.initError === null ? null : ( + <Text role="alert" variant="error"> + Failed to send invite: {vm.initError.message} + </Text> + )} + <DialogFooter> + <Button variant="secondary" onClick={vm.close}> + Cancel + </Button> + <Button disabled={vm.isInitPending} onClick={vm.sendInvite}> + {vm.isInitPending ? 'Sending…' : 'Send invite'} + </Button> + </DialogFooter> + </Stack> + )} + + {vm.stage === 'awaiting-pin' && ( + <Stack gap="md"> + <Stack gap="sm"> + <Label htmlFor="pair-pin-input"> + Enter the 6-digit PIN displayed on {vm.targetLabel} + </Label> + <Input + id="pair-pin-input" + inputMode="numeric" + maxLength={6} + placeholder="000000" + value={vm.pin} + onChange={(e) => vm.setPin(e.target.value)} + /> + </Stack> + {vm.confirmError === null ? null : ( + <Text role="alert" variant="error"> + Pairing failed: {vm.confirmError.message} + </Text> + )} + <DialogFooter> + <Button variant="secondary" onClick={vm.close}> + Cancel + </Button> + <Button disabled={!vm.canConfirm} onClick={vm.confirm}> + {vm.isConfirmPending ? 'Confirming…' : 'Confirm'} + </Button> + </DialogFooter> + </Stack> + )} + + {vm.stage === 'done' && ( + <Stack gap="md"> + <Text variant="success">Pairing complete. {vm.targetLabel} is now trusted.</Text> + <DialogFooter> + <Button onClick={vm.close}>Close</Button> + </DialogFooter> + </Stack> + )} + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/peers/components/PeerListPanel.tsx b/src/renderer/features/peers/components/PeerListPanel.tsx new file mode 100644 index 00000000..1672b066 --- /dev/null +++ b/src/renderer/features/peers/components/PeerListPanel.tsx @@ -0,0 +1,224 @@ +import type { DiscoveredPeer, PairedPeer, SelfIdentity } from '@shared/ipc/peers'; + +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Code, + EmptyState, + Flex, + Spinner, + Stack, + Text, +} from '@ui'; + +import { usePeerListPanel } from '../hooks/usePeerListPanel'; +import { peerLabel, truncate } from '../lib/format'; + +import { OutgoingPairDialog } from './OutgoingPairDialog'; + +// ─── Sub-components ───────────────────────────────────── + +interface SelfBodyProps { + isPending: boolean; + data: SelfIdentity | undefined; +} + +function SelfBody({ isPending, data }: SelfBodyProps) { + if (isPending) return <Spinner />; + if (data === undefined) return <Text variant="muted">Identity unavailable.</Text>; + return ( + <Stack gap="sm"> + <Flex align="center" gap="sm"> + <Text size="sm" variant="muted"> + Peer ID + </Text> + <Code>{truncate(data.peerId)}</Code> + </Flex> + <Flex align="center" gap="sm"> + <Text size="sm" variant="muted"> + Fingerprint + </Text> + <Code>{truncate(data.fingerprint)}</Code> + </Flex> + </Stack> + ); +} + +interface PairedRowProps { + peer: PairedPeer; + onRevoke: (peerId: string) => void; + revokePending: boolean; +} + +function PairedRow({ peer, onRevoke, revokePending }: PairedRowProps) { + const isRevoked = peer.revokedAt !== null; + return ( + <Flex align="center" gap="md" justify="between"> + <Stack gap="none"> + <Text>{peerLabel(peer)}</Text> + <Code>{truncate(peer.peerId)}</Code> + </Stack> + <Flex align="center" gap="sm"> + <Badge variant={isRevoked ? 'destructive' : 'success'}> + {isRevoked ? 'Revoked' : 'Trusted'} + </Badge> + {isRevoked ? null : ( + <Button + disabled={revokePending} + size="sm" + variant="destructive" + onClick={() => onRevoke(peer.peerId)} + > + Revoke + </Button> + )} + </Flex> + </Flex> + ); +} + +interface PairedListProps { + isPending: boolean; + data: readonly PairedPeer[] | undefined; + onRevoke: (peerId: string) => void; + revokePending: boolean; +} + +function PairedList({ isPending, data, onRevoke, revokePending }: PairedListProps) { + if (isPending) return <Spinner />; + if (data === undefined || data.length === 0) { + return ( + <EmptyState + description="Pair with a nearby device to enable sync." + title="No paired peers" + /> + ); + } + return ( + <Stack gap="sm"> + {data.map((peer) => ( + <PairedRow + key={peer.peerId} + peer={peer} + revokePending={revokePending} + onRevoke={onRevoke} + /> + ))} + </Stack> + ); +} + +interface DiscoveredRowProps { + peer: DiscoveredPeer; + onInvite: (peer: DiscoveredPeer) => void; +} + +function DiscoveredRow({ peer, onInvite }: DiscoveredRowProps) { + return ( + <Flex align="center" gap="md" justify="between"> + <Stack gap="none"> + <Text>{peerLabel(peer)}</Text> + <Code> + {peer.host}:{peer.port} + </Code> + </Stack> + <Flex align="center" gap="sm"> + {peer.isPaired ? ( + <Badge variant="success">Paired</Badge> + ) : ( + <Button size="sm" onClick={() => onInvite(peer)}> + Invite + </Button> + )} + </Flex> + </Flex> + ); +} + +interface DiscoveredListProps { + isPending: boolean; + data: readonly DiscoveredPeer[] | undefined; + onInvite: (peer: DiscoveredPeer) => void; +} + +function DiscoveredList({ isPending, data, onInvite }: DiscoveredListProps) { + if (isPending) return <Spinner />; + if (data === undefined || data.length === 0) { + return ( + <EmptyState + description="Other ADC devices on this network will appear here." + title="No devices found" + /> + ); + } + return ( + <Stack gap="sm"> + {data.map((peer) => ( + <DiscoveredRow key={peer.peerId} peer={peer} onInvite={onInvite} /> + ))} + </Stack> + ); +} + +// ─── Component ─────────────────────────────────────────── + +/** + * Settings panel listing this device, paired peers, and nearby (mDNS) peers. + * + * Auto-updates via the global EventBridge subscriptions for + * DISCOVERY.CHANGED (cache write) and TRUST.CHANGED (invalidate) — this + * panel only consumes the queries via `usePeerListPanel`. + */ +export function PeerListPanel() { + const vm = usePeerListPanel(); + + return ( + <> + <Stack gap="lg"> + <Card> + <CardHeader> + <CardTitle>This device</CardTitle> + </CardHeader> + <CardContent> + <SelfBody data={vm.self.data} isPending={vm.self.isPending} /> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle>Paired peers</CardTitle> + </CardHeader> + <CardContent> + <PairedList + data={vm.paired.data} + isPending={vm.paired.isPending} + revokePending={vm.revoke.isPending} + onRevoke={vm.revokePeer} + /> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle>Nearby (mDNS)</CardTitle> + </CardHeader> + <CardContent> + <DiscoveredList + data={vm.discovered.data} + isPending={vm.discovered.isPending} + onInvite={vm.openInvite} + /> + </CardContent> + </Card> + </Stack> + + {vm.inviteTarget !== null && ( + <OutgoingPairDialog target={vm.inviteTarget} onClose={vm.closeInvite} /> + )} + </> + ); +} diff --git a/src/renderer/features/peers/hooks/useOutgoingPair.ts b/src/renderer/features/peers/hooks/useOutgoingPair.ts new file mode 100644 index 00000000..14d21143 --- /dev/null +++ b/src/renderer/features/peers/hooks/useOutgoingPair.ts @@ -0,0 +1,122 @@ +/** + * Presentation hook for the initiator-side pairing dialog. + * + * Owns the three-stage state machine (`idle` → `awaiting-pin` → `done`), + * holds the `{ sessionId, challenge }` returned by `usePairInit` between + * mutations, and PIN-sanitizes every keystroke. The component (`OutgoingPairDialog`) + * is then a render-only switch on `stage`. + */ + +import { useCallback, useMemo, useState } from 'react'; + +import type { DiscoveredPeer, PairInitOutput } from '@shared/ipc/peers'; + +import { usePairConfirm, usePairInit } from '../api/usePeers'; +import { peerLabel, sanitizePin } from '../lib/format'; + +export type Stage = 'idle' | 'awaiting-pin' | 'done'; + +export interface UseOutgoingPairResult { + stage: Stage; + pin: string; + setPin: (raw: string) => void; + targetLabel: string; + isInitPending: boolean; + isConfirmPending: boolean; + initError: Error | null; + confirmError: Error | null; + canConfirm: boolean; + sendInvite: () => void; + confirm: () => void; + close: () => void; +} + +/** + * @param target the discovered peer being invited (component renders only when + * non-null; we accept `DiscoveredPeer` directly to keep the + * contract identical to the previous prop shape) + * @param onClose external close handler (parent clears its `inviteTarget`) + */ +export function useOutgoingPair( + target: DiscoveredPeer, + onClose: () => void, +): UseOutgoingPairResult { + const [stage, setStage] = useState<Stage>('idle'); + const [session, setSession] = useState<PairInitOutput | null>(null); + const [pinValue, setPinValue] = useState(''); + + const pairInit = usePairInit(); + const pairConfirm = usePairConfirm(); + + const targetLabel = useMemo(() => peerLabel(target), [target]); + + const setPin = useCallback((raw: string) => { + setPinValue(sanitizePin(raw)); + }, []); + + const sendInvite = useCallback(() => { + pairInit.mutate( + { + host: target.host, + port: target.port, + fingerprint: target.fingerprint, + displayName: target.displayName ?? null, + }, + { + onSuccess(result) { + setSession(result); + setStage('awaiting-pin'); + }, + }, + ); + }, [pairInit, target.host, target.port, target.fingerprint, target.displayName]); + + const confirm = useCallback(() => { + if (session === null || pinValue.length !== 6) return; + pairConfirm.mutate( + { + host: target.host, + port: target.port, + fingerprint: target.fingerprint, + sessionId: session.sessionId, + challenge: session.challenge, + pin: pinValue, + displayName: target.displayName ?? null, + }, + { + onSuccess() { + setStage('done'); + }, + }, + ); + }, [ + pairConfirm, + session, + pinValue, + target.host, + target.port, + target.fingerprint, + target.displayName, + ]); + + const close = useCallback(() => { + pairInit.reset(); + pairConfirm.reset(); + onClose(); + }, [pairInit, pairConfirm, onClose]); + + return { + stage, + pin: pinValue, + setPin, + targetLabel, + isInitPending: pairInit.isPending, + isConfirmPending: pairConfirm.isPending, + initError: pairInit.error, + confirmError: pairConfirm.error, + canConfirm: pinValue.length === 6 && !pairConfirm.isPending, + sendInvite, + confirm, + close, + }; +} diff --git a/src/renderer/features/peers/hooks/usePeerListPanel.ts b/src/renderer/features/peers/hooks/usePeerListPanel.ts new file mode 100644 index 00000000..0c7d9bfe --- /dev/null +++ b/src/renderer/features/peers/hooks/usePeerListPanel.ts @@ -0,0 +1,63 @@ +/** + * Presentation hook for the peers settings panel. + * + * Wraps the four read/mutation hooks consumed by `PeerListPanel` and owns the + * `inviteTarget` dialog state. Components consuming the result are render-only. + */ + +import { useCallback, useState } from 'react'; + +import type { DiscoveredPeer } from '@shared/ipc/peers'; + +import { + useDiscoveredPeers, + usePairedPeers, + useRevokePeer, + useSelfIdentity, +} from '../api/usePeers'; + +export interface UsePeerListPanelResult { + self: ReturnType<typeof useSelfIdentity>; + paired: ReturnType<typeof usePairedPeers>; + discovered: ReturnType<typeof useDiscoveredPeers>; + revoke: ReturnType<typeof useRevokePeer>; + inviteTarget: DiscoveredPeer | null; + openInvite: (peer: DiscoveredPeer) => void; + closeInvite: () => void; + revokePeer: (peerId: string) => void; +} + +export function usePeerListPanel(): UsePeerListPanelResult { + const self = useSelfIdentity(); + const paired = usePairedPeers(); + const discovered = useDiscoveredPeers(); + const revoke = useRevokePeer(); + + const [inviteTarget, setInviteTarget] = useState<DiscoveredPeer | null>(null); + + const openInvite = useCallback((peer: DiscoveredPeer) => { + setInviteTarget(peer); + }, []); + + const closeInvite = useCallback(() => { + setInviteTarget(null); + }, []); + + const revokePeer = useCallback( + (peerId: string) => { + revoke.mutate(peerId); + }, + [revoke], + ); + + return { + self, + paired, + discovered, + revoke, + inviteTarget, + openInvite, + closeInvite, + revokePeer, + }; +} diff --git a/src/renderer/features/peers/index.ts b/src/renderer/features/peers/index.ts new file mode 100644 index 00000000..0a8a04d4 --- /dev/null +++ b/src/renderer/features/peers/index.ts @@ -0,0 +1,14 @@ +export * from './api/queryKeys'; +export * from './api/usePeerEvents'; +export * from './api/usePeers'; + +export { useOutgoingPair } from './hooks/useOutgoingPair'; +export type { Stage as OutgoingPairStage, UseOutgoingPairResult } from './hooks/useOutgoingPair'; +export { usePeerListPanel } from './hooks/usePeerListPanel'; +export type { UsePeerListPanelResult } from './hooks/usePeerListPanel'; + +export { peerLabel, sanitizePin, truncate } from './lib/format'; + +export { IncomingPinDialog } from './components/IncomingPinDialog'; +export { OutgoingPairDialog } from './components/OutgoingPairDialog'; +export { PeerListPanel } from './components/PeerListPanel'; diff --git a/src/renderer/features/peers/lib/format.ts b/src/renderer/features/peers/lib/format.ts new file mode 100644 index 00000000..814c1166 --- /dev/null +++ b/src/renderer/features/peers/lib/format.ts @@ -0,0 +1,36 @@ +/** + * Peer-domain string formatters. + * + * Replaces the older `lib/truncate.ts` which was a single-fn module. All + * helpers are pure and shared across `OutgoingPairDialog`, `PeerListPanel`, + * `IncomingPinDialog`, and the `useOutgoingPair` presentation hook. + */ + +import { PEER_ID_DISPLAY_MAX, PIN_LENGTH } from '@shared/ipc/peers'; + +const ELLIPSIS = '…'; + +/** Truncate a value (e.g. peerId, fingerprint) to N chars + ellipsis. */ +export function truncate(value: string, max = PEER_ID_DISPLAY_MAX): string { + return value.length <= max ? value : `${value.slice(0, max)}${ELLIPSIS}`; +} + +/** + * Render-friendly label for any peer-shaped record: + * `displayName` if set, otherwise a truncated peerId. + */ +export function peerLabel(peer: { + displayName: string | null; + peerId: string; +}): string { + return peer.displayName ?? truncate(peer.peerId); +} + +/** + * Strip every non-digit character and clamp to the canonical PIN length. + * Used in the PIN entry field on the initiator side; defends against pasted + * values like `"1-2-3-4-5-6"` or extra whitespace. + */ +export function sanitizePin(raw: string): string { + return raw.replaceAll(/\D/g, '').slice(0, PIN_LENGTH); +} diff --git a/src/renderer/features/personal/alerts/components/AlertEditDialog.tsx b/src/renderer/features/personal/alerts/components/AlertEditDialog.tsx deleted file mode 100644 index b3c8f77f..00000000 --- a/src/renderer/features/personal/alerts/components/AlertEditDialog.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/** - * AlertEditDialog — Edit alert message, triggerAt, recurring config, and linkedTo - */ - -import { useEffect, useState } from 'react'; - -import { Link } from 'lucide-react'; - -import type { Alert, RecurringConfig } from '@shared/types'; - -import { - Badge, - Button, - Checkbox, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@ui'; - -import { useUpdateAlert } from '../api/useAlertMutations'; - -interface AlertEditDialogProps { - alert: Alert | null; - onClose: () => void; -} - -function toDatetimeLocalValue(isoString: string): string { - try { - const date = new Date(isoString); - if (Number.isNaN(date.getTime())) return ''; - const pad = (n: number) => String(n).padStart(2, '0'); - return `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; - } catch { - return ''; - } -} - -export function AlertEditDialog({ alert, onClose }: AlertEditDialogProps) { - const updateAlert = useUpdateAlert(); - - const [message, setMessage] = useState(''); - const [triggerAt, setTriggerAt] = useState(''); - const [enableRecurring, setEnableRecurring] = useState(false); - const [recurringFrequency, setRecurringFrequency] = useState< - 'daily' | 'weekly' | 'monthly' - >('daily'); - const [recurringTime, setRecurringTime] = useState('09:00'); - - // Populate form when alert changes - useEffect(() => { - if (alert === null) return; - setMessage(alert.message); - setTriggerAt(toDatetimeLocalValue(alert.triggerAt)); - if (alert.recurring === undefined) { - setEnableRecurring(false); - setRecurringFrequency('daily'); - setRecurringTime('09:00'); - } else { - setEnableRecurring(true); - setRecurringFrequency(alert.recurring.frequency); - setRecurringTime(alert.recurring.time); - } - }, [alert]); - - const isOpen = alert !== null; - - function handleSubmit() { - if (alert === null) return; - const trimmed = message.trim(); - if (trimmed.length === 0) return; - - const recurring: RecurringConfig | null = enableRecurring - ? { frequency: recurringFrequency, time: recurringTime } - : null; - - const updatedMessage = trimmed === alert.message ? undefined : trimmed; - const updatedTriggerAt = - triggerAt.length > 0 ? new Date(triggerAt).toISOString() : undefined; - - updateAlert.mutate( - { - id: alert.id, - message: updatedMessage, - triggerAt: updatedTriggerAt, - recurring, - linkedTo: alert.linkedTo, - }, - { - onSuccess: () => { - onClose(); - }, - }, - ); - } - - function handleOpenChange(open: boolean) { - if (open) return; - onClose(); - } - - const linkedTo = isOpen ? alert.linkedTo : undefined; - - return ( - <Dialog open={isOpen} onOpenChange={handleOpenChange}> - <DialogContent className="max-w-md"> - <DialogHeader> - <DialogTitle>Edit Alert</DialogTitle> - </DialogHeader> - - <div className="space-y-4 py-2"> - {/* linkedTo badge (read-only display) */} - {linkedTo === undefined ? null : ( - <div className="flex items-center gap-2"> - <span className="text-muted-foreground text-xs">Linked to:</span> - <Badge variant="secondary"> - {linkedTo.type}: {linkedTo.id} - </Badge> - </div> - )} - - {/* Message */} - <div className="space-y-1.5"> - <Label htmlFor="edit-alert-message">Message</Label> - <Input - id="edit-alert-message" - placeholder="Alert message" - type="text" - value={message} - onChange={(e) => setMessage(e.target.value)} - /> - </div> - - {/* Trigger At */} - <div className="space-y-1.5"> - <Label htmlFor="edit-alert-trigger-at">Trigger At</Label> - <Input - id="edit-alert-trigger-at" - type="datetime-local" - value={triggerAt} - onChange={(e) => setTriggerAt(e.target.value)} - /> - </div> - - {/* Recurring toggle */} - <div className="space-y-3"> - <Label - className="flex cursor-pointer items-center gap-2" - htmlFor="edit-alert-recurring-toggle" - > - <Checkbox - checked={enableRecurring} - id="edit-alert-recurring-toggle" - onCheckedChange={(checked) => setEnableRecurring(checked === true)} - /> - <span>Recurring</span> - </Label> - - {enableRecurring ? ( - <div className="space-y-3 pl-6"> - <div className="space-y-1.5"> - <Label htmlFor="edit-alert-frequency">Frequency</Label> - <Select - value={recurringFrequency} - onValueChange={(value) => - setRecurringFrequency(value as 'daily' | 'weekly' | 'monthly') - } - > - <SelectTrigger id="edit-alert-frequency"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="daily">Daily</SelectItem> - <SelectItem value="weekly">Weekly</SelectItem> - <SelectItem value="monthly">Monthly</SelectItem> - </SelectContent> - </Select> - </div> - - <div className="space-y-1.5"> - <Label htmlFor="edit-alert-time">Time</Label> - <Input - id="edit-alert-time" - type="time" - value={recurringTime} - onChange={(e) => setRecurringTime(e.target.value)} - /> - </div> - </div> - ) : null} - </div> - </div> - - <DialogFooter> - <Button variant="ghost" onClick={onClose}> - Cancel - </Button> - <Button - disabled={message.trim().length === 0 || updateAlert.isPending} - onClick={handleSubmit} - > - {updateAlert.isPending ? 'Saving...' : 'Save Changes'} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} - -/** Inline badge to display linkedTo on alert cards */ -interface LinkedToBadgeProps { - linkedTo: Alert['linkedTo']; -} - -export function LinkedToBadge({ linkedTo }: LinkedToBadgeProps) { - if (linkedTo === undefined) return null; - - return ( - <Badge className="mt-1 text-xs" variant="outline"> - <Link className="mr-1 h-2.5 w-2.5" /> - {linkedTo.type} - </Badge> - ); -} diff --git a/src/renderer/features/personal/alerts/components/CreateAlertModal.tsx b/src/renderer/features/personal/alerts/components/CreateAlertModal.tsx deleted file mode 100644 index f3684b80..00000000 --- a/src/renderer/features/personal/alerts/components/CreateAlertModal.tsx +++ /dev/null @@ -1,228 +0,0 @@ -/** - * CreateAlertModal — Natural language input with parsed preview - */ - -import { useState } from 'react'; - -import { Bell, Calendar, Clock, Repeat, X } from 'lucide-react'; - -import { Button, Checkbox, Input, Label } from '@ui'; - -import { useCreateAlert } from '../api/useAlerts'; -import { useAlertStore } from '../store'; - -interface ParsePreview { - date: string; - isRecurring: boolean; - confidence: number; -} - -export function CreateAlertModal() { - const showCreateModal = useAlertStore((s) => s.showCreateModal); - const closeCreateModal = useAlertStore((s) => s.closeCreateModal); - const createAlert = useCreateAlert(); - - const [message, setMessage] = useState(''); - const [timeInput, setTimeInput] = useState(''); - const [alertType, setAlertType] = useState< - 'reminder' | 'deadline' | 'notification' | 'recurring' - >('reminder'); - const [parsePreview, setParsePreview] = useState<ParsePreview | null>(null); - const [manualDate, setManualDate] = useState(''); - const [useManualDate, setUseManualDate] = useState(false); - - if (!showCreateModal) { - return null; - } - - function handleTimeInputChange(value: string) { - setTimeInput(value); - if (value.trim().length === 0) { - setParsePreview(null); - return; - } - - // Simple client-side preview — actual NLP parsing happens on the backend - try { - const date = new Date(value); - if (!Number.isNaN(date.getTime())) { - setParsePreview({ - date: date.toLocaleString(), - isRecurring: false, - confidence: 0.9, - }); - return; - } - } catch { - // Fall through to keyword check - } - - // Check for recurring keywords - const isRecurring = /^every\s/i.test(value); - setParsePreview({ - date: 'Will be parsed on creation', - isRecurring, - confidence: 0.5, - }); - } - - function handleSubmit() { - if (message.trim().length === 0) return; - - let triggerAt: string; - const type = alertType; - - if (useManualDate && manualDate.length > 0) { - triggerAt = new Date(manualDate).toISOString(); - } else if (timeInput.trim().length > 0) { - // Try parsing as direct date first - const directDate = new Date(timeInput); - triggerAt = Number.isNaN(directDate.getTime()) - ? new Date(Date.now() + 3_600_000).toISOString() - : directDate.toISOString(); - } else { - // Default: 1 hour from now - triggerAt = new Date(Date.now() + 3_600_000).toISOString(); - } - - createAlert.mutate( - { type, message: message.trim(), triggerAt }, - { - onSuccess: () => { - setMessage(''); - setTimeInput(''); - setManualDate(''); - setParsePreview(null); - closeCreateModal(); - }, - }, - ); - } - - const typeOptions = [ - { value: 'reminder' as const, label: 'Reminder', icon: Bell }, - { value: 'deadline' as const, label: 'Deadline', icon: Calendar }, - { value: 'notification' as const, label: 'Notification', icon: Clock }, - { value: 'recurring' as const, label: 'Recurring', icon: Repeat }, - ]; - - return ( - <div className="fixed inset-0 z-50 flex items-center justify-center"> - {/* Backdrop */} - <div - aria-label="Close modal" - className="absolute inset-0 bg-black/50" - role="button" - tabIndex={0} - onClick={closeCreateModal} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') closeCreateModal(); - }} - /> - - {/* Modal */} - <div className="bg-card border-border relative z-10 w-full max-w-md rounded-lg border p-6 shadow-xl"> - <div className="mb-4 flex items-center justify-between"> - <h2 className="text-foreground text-lg font-semibold">Create Alert</h2> - <Button - className="h-7 w-7 text-muted-foreground" - size="icon" - variant="ghost" - onClick={closeCreateModal} - > - <X className="h-5 w-5" /> - </Button> - </div> - - {/* Alert type */} - <div className="mb-4"> - <span className="text-foreground mb-1.5 block text-sm font-medium">Type</span> - <div className="flex gap-2"> - {typeOptions.map((option) => ( - <Button - key={option.value} - className="gap-1.5" - size="sm" - variant={alertType === option.value ? 'primary' : 'ghost'} - onClick={() => setAlertType(option.value)} - > - <option.icon className="h-3.5 w-3.5" /> - {option.label} - </Button> - ))} - </div> - </div> - - {/* Message */} - <div className="mb-4"> - <Label className="mb-1.5 block" htmlFor="alert-message"> - Message - </Label> - <Input - id="alert-message" - placeholder="What should you be reminded about?" - type="text" - value={message} - onChange={(e) => setMessage(e.target.value)} - /> - </div> - - {/* Time input — NLP */} - <div className="mb-4"> - <Label className="mb-1.5 block" htmlFor="alert-time"> - When (natural language) - </Label> - <Input - disabled={useManualDate} - id="alert-time" - placeholder='e.g. "tomorrow at 3pm", "in 2 hours", "every Monday at 9am"' - type="text" - value={timeInput} - onChange={(e) => handleTimeInputChange(e.target.value)} - /> - {parsePreview === null ? null : ( - <div className="bg-muted mt-2 rounded-md p-2 text-xs"> - <span className="text-muted-foreground"> - {parsePreview.isRecurring ? 'Recurring: ' : 'One-time: '} - {parsePreview.date} - </span> - </div> - )} - </div> - - {/* Manual date fallback */} - <div className="mb-4"> - <Label className="text-muted-foreground flex items-center gap-2 text-sm" htmlFor="alert-manual-toggle"> - <Checkbox - checked={useManualDate} - id="alert-manual-toggle" - onCheckedChange={(checked) => setUseManualDate(checked === true)} - /> - Use manual date/time - </Label> - {useManualDate ? ( - <Input - className="mt-2" - type="datetime-local" - value={manualDate} - onChange={(e) => setManualDate(e.target.value)} - /> - ) : null} - </div> - - {/* Actions */} - <div className="flex justify-end gap-2"> - <Button variant="ghost" onClick={closeCreateModal}> - Cancel - </Button> - <Button - disabled={message.trim().length === 0 || createAlert.isPending} - onClick={handleSubmit} - > - {createAlert.isPending ? 'Creating...' : 'Create Alert'} - </Button> - </div> - </div> - </div> - ); -} diff --git a/src/renderer/features/personal/alerts/index.ts b/src/renderer/features/personal/alerts/index.ts deleted file mode 100644 index 3e102234..00000000 --- a/src/renderer/features/personal/alerts/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Alerts feature — public API - */ - -export { useAlerts, useCreateAlert, useDismissAlert, useDeleteAlert } from './api/useAlerts'; -export { alertKeys } from './api/queryKeys'; -export { AlertsPage } from './components/AlertsPage'; -export { AlertNotification } from './components/AlertNotification'; -export { useAlertEvents } from './hooks/useAlertEvents'; -export { useAlertStore } from './store'; diff --git a/src/renderer/features/personal/briefing/components/SuggestionCard.tsx b/src/renderer/features/personal/briefing/components/SuggestionCard.tsx deleted file mode 100644 index 4553dc08..00000000 --- a/src/renderer/features/personal/briefing/components/SuggestionCard.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * SuggestionCard — Display a proactive suggestion with action - */ - -import { useNavigate } from '@tanstack/react-router'; -import { AlertCircle, GitBranch, Layers, Zap } from 'lucide-react'; - -import type { Suggestion, SuggestionType } from '@shared/types'; - -import { Button, Card, CardContent, Heading } from '@ui'; - -const SUGGESTION_ICONS: Record<SuggestionType, React.ElementType> = { - stale_project: GitBranch, - parallel_tasks: Layers, - blocked_task: AlertCircle, -}; - -const SUGGESTION_COLORS: Record<SuggestionType, string> = { - stale_project: 'text-muted-foreground', - parallel_tasks: 'text-info', - blocked_task: 'text-warning', -}; - -interface SuggestionCardProps { - suggestion: Suggestion; -} - -export function SuggestionCard({ suggestion }: SuggestionCardProps) { - const navigate = useNavigate(); - const IconComponent = SUGGESTION_ICONS[suggestion.type]; - const iconColor = SUGGESTION_COLORS[suggestion.type]; - - function handleAction(): void { - if (suggestion.action === undefined) return; - - const { targetId, targetType } = suggestion.action; - if (targetId === undefined) return; - - if (targetType === 'project') { - void navigate({ to: '/projects/$projectId/tasks', params: { projectId: targetId } }); - } else if (targetType === 'task') { - // Navigate to the task — for now, navigate to my-work which shows all tasks - void navigate({ to: '/my-work' }); - } - } - - function handleKeyDown(event: React.KeyboardEvent): void { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleAction(); - } - } - - return ( - <Card> - <CardContent className="p-4"> - <div className="flex items-start gap-3"> - <div className={`mt-0.5 ${iconColor}`}> - <IconComponent className="h-5 w-5" /> - </div> - <div className="flex-1"> - <Heading as="h4" className="text-sm">{suggestion.title}</Heading> - <p className="text-muted-foreground mt-1 text-sm">{suggestion.description}</p> - {suggestion.action === undefined ? null : ( - <Button - className="mt-3 gap-1" - size="sm" - type="button" - variant="outline" - onClick={handleAction} - onKeyDown={handleKeyDown} - > - <Zap className="h-3 w-3" /> - {suggestion.action.label} - </Button> - )} - </div> - </div> - </CardContent> - </Card> - ); -} diff --git a/src/renderer/features/personal/changelog/components/ChangelogPage.tsx b/src/renderer/features/personal/changelog/components/ChangelogPage.tsx deleted file mode 100644 index 66a2b5fd..00000000 --- a/src/renderer/features/personal/changelog/components/ChangelogPage.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useState } from 'react'; - -import { GitBranch, ScrollText, X } from 'lucide-react'; - -import type { ChangeCategory, ChangelogEntry, ChangeType } from '@shared/types'; - -import { Button, Card, CardContent, EmptyState, Heading } from '@ui'; - -import { useAddChangelogEntry, useChangelog, useGenerateChangelog } from '../api/useChangelog'; - -import { EntryPreview } from './EntryPreview'; -import { GenerateForm } from './GenerateForm'; -import { VersionCard } from './VersionCard'; - -export function ChangelogPage() { - const { data: entries, isLoading } = useChangelog(); - const generateChangelog = useGenerateChangelog(); - const addEntry = useAddChangelogEntry(); - const items = entries ?? []; - - // Generate dialog state - const [showGenerateDialog, setShowGenerateDialog] = useState(false); - const [repoPath, setRepoPath] = useState(''); - const [version, setVersion] = useState(''); - const [fromTag, setFromTag] = useState(''); - const [generatedEntry, setGeneratedEntry] = useState<ChangelogEntry | null>(null); - const [isEditing, setIsEditing] = useState(false); - const [editableCategories, setEditableCategories] = useState<ChangeCategory[]>([]); - - function handleGenerate(): void { - if (!repoPath.trim() || !version.trim()) return; - - generateChangelog.mutate( - { - repoPath: repoPath.trim(), - version: version.trim(), - fromTag: fromTag.trim() || undefined, - }, - { - onSuccess: (entry) => { - setGeneratedEntry(entry); - setEditableCategories(entry.categories); - setIsEditing(true); - }, - }, - ); - } - - function handleSaveEntry(): void { - if (!generatedEntry) return; - - addEntry.mutate( - { - version: generatedEntry.version, - date: generatedEntry.date, - categories: editableCategories, - }, - { - onSuccess: () => { - setShowGenerateDialog(false); - setGeneratedEntry(null); - setIsEditing(false); - setEditableCategories([]); - setRepoPath(''); - setVersion(''); - setFromTag(''); - }, - }, - ); - } - - function handleCloseDialog(): void { - setShowGenerateDialog(false); - setGeneratedEntry(null); - setIsEditing(false); - setEditableCategories([]); - } - - function handleRemoveItem(categoryType: ChangeType, itemIndex: number): void { - setEditableCategories((prev) => - prev - .map((cat) => { - if (cat.type !== categoryType) return cat; - const newItems = cat.items.filter((_, idx) => idx !== itemIndex); - return { ...cat, items: newItems }; - }) - .filter((cat) => cat.items.length > 0), - ); - } - - function handleBackToForm(): void { - setIsEditing(false); - setGeneratedEntry(null); - setEditableCategories([]); - } - - const errorMessage = generateChangelog.isError ? generateChangelog.error.message : null; - - return ( - <div className="space-y-6 p-6"> - {/* Actions */} - <div className="flex items-center gap-2"> - <Button onClick={() => setShowGenerateDialog(true)}> - <GitBranch className="h-4 w-4" /> - Generate from Git - </Button> - </div> - {/* Generate Dialog */} - {showGenerateDialog ? ( - <Card className="mb-6"> - <CardContent className="p-6"> - <div className="mb-4 flex items-center justify-between"> - <Heading as="h3">Generate Changelog from Git</Heading> - <Button - aria-label="Close" - size="icon" - type="button" - variant="ghost" - onClick={handleCloseDialog} - > - <X className="h-5 w-5" /> - </Button> - </div> - - {isEditing && generatedEntry ? ( - <EntryPreview - categories={editableCategories} - entry={generatedEntry} - isSaving={addEntry.isPending} - onBack={handleBackToForm} - onRemoveItem={handleRemoveItem} - onSave={handleSaveEntry} - /> - ) : ( - <GenerateForm - errorMessage={errorMessage} - fromTag={fromTag} - isPending={generateChangelog.isPending} - repoPath={repoPath} - version={version} - onFromTagChange={setFromTag} - onGenerate={handleGenerate} - onRepoPathChange={setRepoPath} - onVersionChange={setVersion} - /> - )} - </CardContent> - </Card> - ) : null} - - {/* Timeline */} - {isLoading ? ( - <div className="text-muted-foreground flex items-center justify-center py-12"> - Loading changelog... - </div> - ) : null} - - {!isLoading && items.length > 0 ? ( - <div className="relative space-y-8"> - {/* Timeline line */} - <div className="bg-border absolute top-0 bottom-0 left-[5px] w-px" /> - - {items.map((entry) => ( - <VersionCard key={entry.version} entry={entry} /> - ))} - </div> - ) : null} - - {!isLoading && items.length === 0 ? ( - <EmptyState - className="border-border rounded-lg border border-dashed" - description="Entries will appear here as releases are published" - icon={ScrollText} - size="md" - title="No changelog entries" - /> - ) : null} - </div> - ); -} diff --git a/src/renderer/features/personal/changelog/components/EditEntryDialog.tsx b/src/renderer/features/personal/changelog/components/EditEntryDialog.tsx deleted file mode 100644 index a875d230..00000000 --- a/src/renderer/features/personal/changelog/components/EditEntryDialog.tsx +++ /dev/null @@ -1,277 +0,0 @@ -/** - * EditEntryDialog — Edit an existing changelog entry's date and categories - */ - -import { useState } from 'react'; - -import { Minus, Plus } from 'lucide-react'; - -import type { ChangeCategory, ChangelogEntry, ChangeType } from '@shared/types'; - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Separator, - Text, -} from '@ui'; - -import { useUpdateChangelogEntry } from '../api/useChangelog'; - -const CHANGE_TYPES: ChangeType[] = ['added', 'changed', 'fixed', 'removed', 'security', 'deprecated']; - -interface EditEntryDialogProps { - entry: ChangelogEntry; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function EditEntryDialog({ entry, open, onOpenChange }: EditEntryDialogProps) { - const updateEntry = useUpdateChangelogEntry(); - - const [version, setVersion] = useState(entry.version); - const [date, setDate] = useState(entry.date); - const [categories, setCategories] = useState<ChangeCategory[]>(entry.categories); - const [newItemTexts, setNewItemTexts] = useState<Record<string, string>>({}); - - function handleAddCategory(): void { - const usedTypes = new Set(categories.map((c) => c.type)); - const nextType = CHANGE_TYPES.find((t) => !usedTypes.has(t)); - if (!nextType) return; - setCategories((prev) => [...prev, { type: nextType, items: [] }]); - } - - function handleRemoveCategory(categoryType: ChangeType): void { - setCategories((prev) => prev.filter((c) => c.type !== categoryType)); - } - - function handleCategoryTypeChange(oldType: ChangeType, newType: ChangeType): void { - setCategories((prev) => - prev.map((c) => (c.type === oldType ? { ...c, type: newType } : c)), - ); - setNewItemTexts((prev) => { - const { [oldType]: movedText, ...rest } = prev; - return movedText ? { ...rest, [newType]: movedText } : rest; - }); - } - - function handleAddItem(categoryType: ChangeType): void { - const text = (newItemTexts[categoryType] ?? '').trim(); - if (!text) return; - setCategories((prev) => - prev.map((c) => - c.type === categoryType ? { ...c, items: [...c.items, text] } : c, - ), - ); - setNewItemTexts((prev) => ({ ...prev, [categoryType]: '' })); - } - - function handleRemoveItem(categoryType: ChangeType, itemIndex: number): void { - setCategories((prev) => - prev - .map((c) => { - if (c.type !== categoryType) return c; - return { ...c, items: c.items.filter((_, idx) => idx !== itemIndex) }; - }) - .filter((c) => c.items.length > 0 || c.type === categoryType), - ); - } - - function handleNewItemKeyDown(e: React.KeyboardEvent, categoryType: ChangeType): void { - if (e.key === 'Enter') { - e.preventDefault(); - handleAddItem(categoryType); - } - } - - function handleSave(): void { - const nonEmptyCategories = categories.filter((c) => c.items.length > 0); - updateEntry.mutate( - { - version: entry.version, - updates: { - version: version.trim() || entry.version, - date: date.trim() || entry.date, - categories: nonEmptyCategories, - }, - }, - { - onSuccess: () => { - onOpenChange(false); - }, - }, - ); - } - - function handleOpenChange(nextOpen: boolean): void { - if (!nextOpen) { - // Reset state when dialog closes - setVersion(entry.version); - setDate(entry.date); - setCategories(entry.categories); - setNewItemTexts({}); - } - onOpenChange(nextOpen); - } - - const usedTypes = new Set(categories.map((c) => c.type)); - const hasAvailableTypes = usedTypes.size < CHANGE_TYPES.length; - const isSaveDisabled = !version.trim() || !date.trim() || updateEntry.isPending; - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="max-h-[80vh] max-w-lg overflow-y-auto"> - <DialogHeader> - <DialogTitle>Edit Changelog Entry</DialogTitle> - </DialogHeader> - - <div className="space-y-4 py-2"> - {/* Version */} - <div> - <Label htmlFor="edit-version">Version</Label> - <Input - className="mt-1" - id="edit-version" - placeholder="v1.0.0" - type="text" - value={version} - onChange={(e) => setVersion(e.target.value)} - /> - </div> - - {/* Date */} - <div> - <Label htmlFor="edit-date">Date</Label> - <Input - className="mt-1" - id="edit-date" - placeholder="January 2026" - type="text" - value={date} - onChange={(e) => setDate(e.target.value)} - /> - </div> - - <Separator /> - - {/* Categories */} - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <Label>Categories</Label> - {hasAvailableTypes ? ( - <Button size="sm" type="button" variant="outline" onClick={handleAddCategory}> - <Plus className="h-3 w-3" /> - Add Category - </Button> - ) : null} - </div> - - {categories.map((category) => ( - <div key={category.type} className="border-border rounded-md border p-3 space-y-3"> - {/* Category header */} - <div className="flex items-center gap-2"> - <Select - value={category.type} - onValueChange={(val) => - handleCategoryTypeChange(category.type, val as ChangeType) - } - > - <SelectTrigger className="w-36"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {CHANGE_TYPES.map((t) => ( - <SelectItem - key={t} - disabled={usedTypes.has(t) && t !== category.type} - value={t} - > - {t.charAt(0).toUpperCase() + t.slice(1)} - </SelectItem> - ))} - </SelectContent> - </Select> - <Button - aria-label={`Remove ${category.type} category`} - className="ml-auto" - size="sm" - type="button" - variant="ghost" - onClick={() => handleRemoveCategory(category.type)} - > - <Minus className="h-3 w-3" /> - </Button> - </div> - - {/* Items */} - <ul className="space-y-1"> - {category.items.map((item, idx) => ( - <li key={`${category.type}-${item.slice(0, 30)}`} className="flex items-center gap-2"> - <Text className="flex-1" size="sm"> - {item} - </Text> - <Button - aria-label="Remove item" - className="h-6 w-6 shrink-0" - size="icon" - type="button" - variant="ghost" - onClick={() => handleRemoveItem(category.type, idx)} - > - <Minus className="h-3 w-3" /> - </Button> - </li> - ))} - </ul> - - {/* Add item input */} - <div className="flex items-center gap-2"> - <Input - className="flex-1" - placeholder="Add item..." - type="text" - value={newItemTexts[category.type] ?? ''} - onChange={(e) => - setNewItemTexts((prev) => ({ ...prev, [category.type]: e.target.value })) - } - onKeyDown={(e) => { - handleNewItemKeyDown(e, category.type); - }} - /> - <Button - aria-label="Add item" - size="icon" - type="button" - variant="outline" - onClick={() => handleAddItem(category.type)} - > - <Plus className="h-4 w-4" /> - </Button> - </div> - </div> - ))} - </div> - </div> - - <DialogFooter> - <Button type="button" variant="outline" onClick={() => handleOpenChange(false)}> - Cancel - </Button> - <Button disabled={isSaveDisabled} type="button" onClick={handleSave}> - {updateEntry.isPending ? 'Saving...' : 'Save Changes'} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/personal/components/PersonalPage.tsx b/src/renderer/features/personal/components/PersonalPage.tsx index bc91e6b0..036ffe1d 100644 --- a/src/renderer/features/personal/components/PersonalPage.tsx +++ b/src/renderer/features/personal/components/PersonalPage.tsx @@ -14,22 +14,22 @@ import { usePersonalStore } from '../store'; // ── Lazy Tab Content ───────────────────────────────────────── const NotesPage = lazy(() => - import('../notes/components/NotesPage').then((m) => ({ default: m.NotesPage })), + import('@features/notes/components/NotesPage').then((m) => ({ default: m.NotesPage })), ); const FitnessPage = lazy(() => - import('../fitness/components/FitnessPage').then((m) => ({ default: m.FitnessPage })), + import('@features/fitness/components/FitnessPage').then((m) => ({ default: m.FitnessPage })), ); const PlannerPage = lazy(() => - import('../planner/components/PlannerPage').then((m) => ({ default: m.PlannerPage })), + import('@features/planner/components/PlannerPage').then((m) => ({ default: m.PlannerPage })), ); const BriefingPage = lazy(() => - import('../briefing/components/BriefingPage').then((m) => ({ default: m.BriefingPage })), + import('@features/briefing/components/BriefingPage').then((m) => ({ default: m.BriefingPage })), ); const AlertsPage = lazy(() => - import('../alerts/components/AlertsPage').then((m) => ({ default: m.AlertsPage })), + import('@features/alerts/components/AlertsPage').then((m) => ({ default: m.AlertsPage })), ); const ChangelogPage = lazy(() => - import('../changelog/components/ChangelogPage').then((m) => ({ default: m.ChangelogPage })), + import('@features/changelog/components/ChangelogPage').then((m) => ({ default: m.ChangelogPage })), ); // ── Constants ──────────────────────────────────────────────── diff --git a/src/renderer/features/personal/components/PersonalPage/PersonalPage.tsx b/src/renderer/features/personal/components/PersonalPage/PersonalPage.tsx new file mode 100644 index 00000000..78d8632c --- /dev/null +++ b/src/renderer/features/personal/components/PersonalPage/PersonalPage.tsx @@ -0,0 +1,120 @@ +/** + * PersonalPage — Tabbed layout for personal features + */ + +import { lazy, Suspense } from 'react'; + +import { Bell, CalendarDays, Dumbbell, Newspaper, ScrollText, StickyNote } from 'lucide-react'; + +import { PageContent, PageHeader, PageLayout, Spinner } from '@ui'; + +import { usePersonalPage } from './usePersonalPage'; + +// ── Lazy Tab Content ───────────────────────────────────────── + +const NotesPage = lazy(() => + import('@features/notes/components/NotesPage').then((m) => ({ default: m.NotesPage })), +); +const FitnessPage = lazy(() => + import('@features/fitness/components/FitnessPage').then((m) => ({ default: m.FitnessPage })), +); +const PlannerPage = lazy(() => + import('@features/planner/components/PlannerPage').then((m) => ({ default: m.PlannerPage })), +); +const BriefingPage = lazy(() => + import('@features/briefing/components/BriefingPage').then((m) => ({ default: m.BriefingPage })), +); +const AlertsPage = lazy(() => + import('@features/alerts/components/AlertsPage').then((m) => ({ default: m.AlertsPage })), +); +const ChangelogPage = lazy(() => + import('@features/changelog/components/ChangelogPage').then((m) => ({ default: m.ChangelogPage })), +); + +// ── Constants ──────────────────────────────────────────────── + +const TABS = [ + { id: 'notes' as const, label: 'Notes', icon: StickyNote }, + { id: 'fitness' as const, label: 'Fitness', icon: Dumbbell }, + { id: 'planner' as const, label: 'Planner', icon: CalendarDays }, + { id: 'briefing' as const, label: 'Briefing', icon: Newspaper }, + { id: 'alerts' as const, label: 'Alerts', icon: Bell }, + { id: 'changelog' as const, label: 'Changelog', icon: ScrollText }, +]; + +// ── Component ──────────────────────────────────────────────── + +function TabFallback() { + return ( + <div className="flex flex-1 items-center justify-center"> + <Spinner aria-label="Loading" size="lg" /> + </div> + ); +} + +export function PersonalPage() { + const { activeTab, setActiveTab } = usePersonalPage(); + + return ( + <PageLayout data-testid="personal-page"> + <PageHeader.Tabs + value={activeTab} + onValueChange={(v) => setActiveTab(v as typeof activeTab)} + > + <PageHeader> + <PageHeader.Row> + <PageHeader.Title description="Notes, fitness, planning, briefing, and more"> + Personal + </PageHeader.Title> + </PageHeader.Row> + <PageHeader.TabList> + {TABS.map((tab) => ( + <PageHeader.Tab key={tab.id} value={tab.id}> + <tab.icon className="h-4 w-4" /> + {tab.label} + </PageHeader.Tab> + ))} + </PageHeader.TabList> + </PageHeader> + + <PageContent> + <PageHeader.TabContent value="notes"> + <Suspense fallback={<TabFallback />}> + <NotesPage /> + </Suspense> + </PageHeader.TabContent> + + <PageHeader.TabContent value="fitness"> + <Suspense fallback={<TabFallback />}> + <FitnessPage /> + </Suspense> + </PageHeader.TabContent> + + <PageHeader.TabContent value="planner"> + <Suspense fallback={<TabFallback />}> + <PlannerPage /> + </Suspense> + </PageHeader.TabContent> + + <PageHeader.TabContent value="briefing"> + <Suspense fallback={<TabFallback />}> + <BriefingPage /> + </Suspense> + </PageHeader.TabContent> + + <PageHeader.TabContent value="alerts"> + <Suspense fallback={<TabFallback />}> + <AlertsPage /> + </Suspense> + </PageHeader.TabContent> + + <PageHeader.TabContent value="changelog"> + <Suspense fallback={<TabFallback />}> + <ChangelogPage /> + </Suspense> + </PageHeader.TabContent> + </PageContent> + </PageHeader.Tabs> + </PageLayout> + ); +} diff --git a/src/renderer/features/personal/components/PersonalPage/index.ts b/src/renderer/features/personal/components/PersonalPage/index.ts new file mode 100644 index 00000000..20be468d --- /dev/null +++ b/src/renderer/features/personal/components/PersonalPage/index.ts @@ -0,0 +1 @@ +export { PersonalPage } from './PersonalPage'; diff --git a/src/renderer/features/personal/components/PersonalPage/usePersonalPage.ts b/src/renderer/features/personal/components/PersonalPage/usePersonalPage.ts new file mode 100644 index 00000000..e5c35832 --- /dev/null +++ b/src/renderer/features/personal/components/PersonalPage/usePersonalPage.ts @@ -0,0 +1,28 @@ +/** + * usePersonalPage — logic for PersonalPage + */ + +import { useEffect } from 'react'; + +import { useSearch } from '@tanstack/react-router'; + +import { usePersonalStore } from '../../store'; + +export function usePersonalPage() { + const { activeTab, setActiveTab } = usePersonalStore(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const search = useSearch({ strict: false }); + const tabFromUrl = (search as { tab?: string } | undefined)?.tab; + + // Sync URL ?tab= param to store on mount / when URL changes + useEffect(() => { + if (tabFromUrl !== undefined) { + setActiveTab(tabFromUrl as typeof activeTab); + } + }, [tabFromUrl, setActiveTab]); + + return { + activeTab, + setActiveTab, + }; +} diff --git a/src/renderer/features/personal/fitness/components/BodyComposition.tsx b/src/renderer/features/personal/fitness/components/BodyComposition.tsx deleted file mode 100644 index f0b98911..00000000 --- a/src/renderer/features/personal/fitness/components/BodyComposition.tsx +++ /dev/null @@ -1,351 +0,0 @@ -/** - * BodyComposition — Body composition breakdown and measurement logging - */ - -import { useState } from 'react'; - -import { Pencil, Plus, Scale, Trash2 } from 'lucide-react'; - -import type { BodyMeasurement } from '@shared/types'; - -import { RelativeTime } from '@renderer/shared/components/RelativeTime'; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - Button, - Card, - CardContent, - EmptyState, - Heading, - Input, - Label, - Text, -} from '@ui'; - -import { useDeleteMeasurement, useLogMeasurement, useMeasurements } from '../api/useFitness'; - -import { MeasurementEditDialog } from './MeasurementEditDialog'; - -// ── Component ──────────────────────────────────────────────── - -export function BodyComposition() { - const { data: measurements } = useMeasurements(30); - const logMeasurement = useLogMeasurement(); - const [showForm, setShowForm] = useState(false); - const [weight, setWeight] = useState(''); - const [bodyFat, setBodyFat] = useState(''); - const [dateFrom, setDateFrom] = useState(''); - const [dateTo, setDateTo] = useState(''); - - const allMeasurements = measurements ?? []; - - const displayMeasurements = allMeasurements.filter((m) => { - if (dateFrom && m.date < dateFrom) return false; - if (dateTo && m.date > dateTo) return false; - return true; - }); - - const latest = displayMeasurements.length > 0 ? displayMeasurements[0] : null; - - function handleSubmit() { - const weightNum = weight === '' ? undefined : Number(weight); - const bodyFatNum = bodyFat === '' ? undefined : Number(bodyFat); - - if (weightNum === undefined && bodyFatNum === undefined) return; - - logMeasurement.mutate( - { - date: new Date().toISOString().split('T')[0], - weight: weightNum, - bodyFat: bodyFatNum, - source: 'manual', - }, - { - onSuccess: () => { - setWeight(''); - setBodyFat(''); - setShowForm(false); - }, - }, - ); - } - - const isFiltered = dateFrom !== '' || dateTo !== ''; - - return ( - <div className="space-y-4"> - {/* Date-range filter */} - <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-3"> - <div className="flex-1"> - <Label className="mb-1" htmlFor="measure-date-from"> - From - </Label> - <Input - id="measure-date-from" - type="date" - value={dateFrom} - onChange={(e) => setDateFrom(e.target.value)} - /> - </div> - <div className="flex-1"> - <Label className="mb-1" htmlFor="measure-date-to"> - To - </Label> - <Input - id="measure-date-to" - type="date" - value={dateTo} - onChange={(e) => setDateTo(e.target.value)} - /> - </div> - {isFiltered ? ( - <Button - size="sm" - type="button" - variant="ghost" - onClick={() => { setDateFrom(''); setDateTo(''); }} - > - Clear - </Button> - ) : null} - </div> - - {/* Latest measurements */} - {latest ? ( - <Card> - <CardContent className="p-4"> - <Heading as="h4" className="text-muted-foreground mb-3 text-xs font-medium tracking-wider uppercase"> - Latest Measurements - </Heading> - <div className="grid grid-cols-2 gap-4 sm:grid-cols-3"> - {latest.weight === undefined ? null : ( - <div> - <span className="text-muted-foreground text-xs">Weight</span> - <Text className="text-lg font-bold">{String(latest.weight)} kg</Text> - </div> - )} - {latest.bodyFat === undefined ? null : ( - <div> - <span className="text-muted-foreground text-xs">Body Fat</span> - <Text className="text-lg font-bold">{String(latest.bodyFat)}%</Text> - </div> - )} - {latest.muscleMass === undefined ? null : ( - <div> - <span className="text-muted-foreground text-xs">Muscle Mass</span> - <Text className="text-lg font-bold">{String(latest.muscleMass)} kg</Text> - </div> - )} - {latest.boneMass === undefined ? null : ( - <div> - <span className="text-muted-foreground text-xs">Bone Mass</span> - <Text className="text-lg font-bold">{String(latest.boneMass)} kg</Text> - </div> - )} - {latest.waterPercentage === undefined ? null : ( - <div> - <span className="text-muted-foreground text-xs">Water</span> - <Text className="text-lg font-bold"> - {String(latest.waterPercentage)}% - </Text> - </div> - )} - {latest.visceralFat === undefined ? null : ( - <div> - <span className="text-muted-foreground text-xs">Visceral Fat</span> - <Text className="text-lg font-bold">{String(latest.visceralFat)}</Text> - </div> - )} - </div> - <Text className="mt-2" size="sm" variant="muted"> - {latest.date} · {latest.source} - </Text> - </CardContent> - </Card> - ) : ( - <EmptyState - description={isFiltered ? 'No measurements in this date range' : 'No measurements recorded yet'} - icon={Scale} - size="sm" - title="" - /> - )} - - {/* Add measurement */} - {showForm ? ( - <Card> - <CardContent className="p-4"> - <Heading as="h4" className="text-foreground mb-3 text-sm font-medium">Log Measurement</Heading> - <div className="flex gap-3"> - <div className="flex-1"> - <Label className="mb-1" htmlFor="measure-weight"> - Weight (kg) - </Label> - <Input - id="measure-weight" - placeholder="75.5" - type="number" - value={weight} - onChange={(e) => setWeight(e.target.value)} - /> - </div> - <div className="flex-1"> - <Label className="mb-1" htmlFor="measure-bodyfat"> - Body Fat (%) - </Label> - <Input - id="measure-bodyfat" - placeholder="18.5" - type="number" - value={bodyFat} - onChange={(e) => setBodyFat(e.target.value)} - /> - </div> - </div> - <div className="mt-3 flex gap-2"> - <Button - className="flex-1" - disabled={weight === '' && bodyFat === ''} - type="button" - onClick={handleSubmit} - > - Save - </Button> - <Button - type="button" - variant="ghost" - onClick={() => setShowForm(false)} - > - Cancel - </Button> - </div> - </CardContent> - </Card> - ) : ( - <Button - className="text-primary" - type="button" - variant="ghost" - onClick={() => setShowForm(true)} - > - <Plus className="h-4 w-4" /> - Log Measurement - </Button> - )} - - {/* History */} - {(displayMeasurements.length > 1) ? ( - <Card> - <Heading as="h4" className="text-muted-foreground border-border border-b px-4 py-2 text-xs font-medium tracking-wider uppercase"> - History - </Heading> - <div className="divide-border divide-y"> - {displayMeasurements.slice(0, 10).map((m) => ( - <MeasurementHistoryRow key={m.id} measurement={m} /> - ))} - </div> - </Card> - ) : null} - </div> - ); -} - -// ── MeasurementHistoryRow ──────────────────────────────────── - -interface MeasurementHistoryRowProps { - measurement: BodyMeasurement; -} - -function MeasurementHistoryRow({ measurement: m }: MeasurementHistoryRowProps) { - const deleteMeasurement = useDeleteMeasurement(); - const [editOpen, setEditOpen] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); - - function handleDeleteConfirm(): void { - deleteMeasurement.mutate(m.id); - } - - return ( - <> - <div className="flex items-center justify-between px-4 py-2"> - <div className="flex-1"> - <span className="text-foreground text-sm">{m.date}</span> - <div className="mt-0.5 flex flex-wrap gap-3"> - {m.weight === undefined ? null : ( - <span className="text-muted-foreground text-xs">{String(m.weight)} kg</span> - )} - {m.bodyFat === undefined ? null : ( - <span className="text-muted-foreground text-xs">{String(m.bodyFat)}% fat</span> - )} - {m.muscleMass === undefined ? null : ( - <span className="text-muted-foreground text-xs">{String(m.muscleMass)} kg muscle</span> - )} - {m.boneMass === undefined ? null : ( - <span className="text-muted-foreground text-xs">{String(m.boneMass)} kg bone</span> - )} - {m.waterPercentage === undefined ? null : ( - <span className="text-muted-foreground text-xs">{String(m.waterPercentage)}% water</span> - )} - {m.visceralFat === undefined ? null : ( - <span className="text-muted-foreground text-xs">visceral {String(m.visceralFat)}</span> - )} - <span className="text-muted-foreground text-xs capitalize">{m.source}</span> - </div> - <RelativeTime value={m.createdAt} /> - </div> - <div className="flex items-center gap-1"> - <Button - aria-label="Edit measurement" - className="text-muted-foreground hover:text-foreground" - size="icon" - type="button" - variant="ghost" - onClick={() => setEditOpen(true)} - > - <Pencil className="h-4 w-4" /> - </Button> - <Button - aria-label="Delete measurement" - className="text-muted-foreground hover:text-destructive" - size="icon" - type="button" - variant="ghost" - onClick={() => setDeleteOpen(true)} - > - <Trash2 className="h-4 w-4" /> - </Button> - </div> - </div> - - <MeasurementEditDialog measurement={m} open={editOpen} onOpenChange={setEditOpen} /> - - <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>Delete measurement?</AlertDialogTitle> - <AlertDialogDescription> - This will permanently remove the measurement from {m.date}. This action cannot be - undone. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> - <AlertDialogAction - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - onClick={handleDeleteConfirm} - > - Delete - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - </> - ); -} diff --git a/src/renderer/features/personal/fitness/components/GoalEditDialog.tsx b/src/renderer/features/personal/fitness/components/GoalEditDialog.tsx deleted file mode 100644 index 2525eb3a..00000000 --- a/src/renderer/features/personal/fitness/components/GoalEditDialog.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/** - * GoalEditDialog — Edit an existing fitness goal's type, target, unit, and deadline - */ - -import { useState } from 'react'; - -import type { FitnessGoal, FitnessGoalType } from '@shared/types'; - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@ui'; - -import { useUpdateGoal } from '../api/useFitness'; - -// ── Constants ──────────────────────────────────────────────── - -const GOAL_TYPE_LABELS: Record<FitnessGoalType, string> = { - weight: 'Weight', - workout_frequency: 'Workout Frequency', - lift_target: 'Lift Target', - cardio_target: 'Cardio Target', -}; - -const GOAL_TYPES: FitnessGoalType[] = [ - 'weight', - 'workout_frequency', - 'lift_target', - 'cardio_target', -]; - -// ── Component ──────────────────────────────────────────────── - -interface GoalEditDialogProps { - goal: FitnessGoal; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function GoalEditDialog({ goal, open, onOpenChange }: GoalEditDialogProps) { - const updateGoal = useUpdateGoal(); - - const [goalType, setGoalType] = useState<FitnessGoalType>(goal.type); - const [target, setTarget] = useState(String(goal.target)); - const [unit, setUnit] = useState(goal.unit); - const [deadline, setDeadline] = useState(goal.deadline ?? ''); - - const isValid = Number(target) > 0 && unit.trim().length > 0; - - function handleSave() { - if (!isValid) return; - - updateGoal.mutate( - { - id: goal.id, - type: goalType, - target: Number(target), - unit: unit.trim(), - deadline: deadline.trim().length > 0 ? deadline.trim() : null, - }, - { - onSuccess: () => { - onOpenChange(false); - }, - }, - ); - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle>Edit Goal</DialogTitle> - </DialogHeader> - - <div className="space-y-4 py-2"> - {/* Goal Type */} - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-goal-type" - > - Goal Type - </Label> - <Select value={goalType} onValueChange={(v) => setGoalType(v as FitnessGoalType)}> - <SelectTrigger id="edit-goal-type"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {GOAL_TYPES.map((gt) => ( - <SelectItem key={gt} value={gt}> - {GOAL_TYPE_LABELS[gt]} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - {/* Target + Unit */} - <div className="flex gap-3"> - <div className="flex-1"> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-goal-target" - > - Target - </Label> - <Input - id="edit-goal-target" - min="1" - placeholder="100" - type="number" - value={target} - onChange={(e) => setTarget(e.target.value)} - /> - </div> - <div className="w-24"> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-goal-unit" - > - Unit - </Label> - <Input - id="edit-goal-unit" - placeholder="kg" - type="text" - value={unit} - onChange={(e) => setUnit(e.target.value)} - /> - </div> - </div> - - {/* Deadline */} - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-goal-deadline" - > - Deadline (optional) - </Label> - <Input - id="edit-goal-deadline" - type="date" - value={deadline} - onChange={(e) => setDeadline(e.target.value)} - /> - </div> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - > - Cancel - </Button> - <Button - disabled={!isValid || updateGoal.isPending} - type="button" - onClick={handleSave} - > - Save Changes - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/personal/fitness/components/GoalsPanel.tsx b/src/renderer/features/personal/fitness/components/GoalsPanel.tsx deleted file mode 100644 index 577ca731..00000000 --- a/src/renderer/features/personal/fitness/components/GoalsPanel.tsx +++ /dev/null @@ -1,296 +0,0 @@ -/** - * GoalsPanel — Set and view fitness goals - */ - -import { useState } from 'react'; - -import { Pencil, Plus, Target, Trash2 } from 'lucide-react'; - -import type { FitnessGoal, FitnessGoalType } from '@shared/types'; - -import { RelativeTime } from '@renderer/shared/components/RelativeTime'; -import { useDebounce } from '@renderer/shared/hooks/useDebounce'; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - Button, - Card, - CardContent, - EmptyState, - Heading, - Input, - Label, - Progress, - SearchInput, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Text, -} from '@ui'; - -import { useDeleteGoal, useFitnessGoals, useSetGoal } from '../api/useFitness'; - -import { GoalEditDialog } from './GoalEditDialog'; - -// ── Constants ──────────────────────────────────────────────── - -const GOAL_TYPE_LABELS: Record<FitnessGoalType, string> = { - weight: 'Weight', - workout_frequency: 'Workout Frequency', - lift_target: 'Lift Target', - cardio_target: 'Cardio Target', -}; - -const GOAL_TYPES: FitnessGoalType[] = [ - 'weight', - 'workout_frequency', - 'lift_target', - 'cardio_target', -]; - -// ── Component ──────────────────────────────────────────────── - -export function GoalsPanel() { - const { data: goals } = useFitnessGoals(); - const setGoal = useSetGoal(); - const [showForm, setShowForm] = useState(false); - const [goalType, setGoalType] = useState<FitnessGoalType>('weight'); - const [target, setTarget] = useState(''); - const [unit, setUnit] = useState('kg'); - const [searchQuery, setSearchQuery] = useState(''); - const debouncedQuery = useDebounce(searchQuery); - - const allGoals = goals ?? []; - - const displayGoals = debouncedQuery.trim() - ? allGoals.filter((g) => g.type.toLowerCase().includes(debouncedQuery.toLowerCase())) - : allGoals; - - function handleSubmit() { - const targetNum = Number(target); - if (targetNum <= 0) return; - - setGoal.mutate( - { type: goalType, target: targetNum, unit }, - { - onSuccess: () => { - setTarget(''); - setShowForm(false); - }, - }, - ); - } - - return ( - <div className="space-y-4"> - {/* Search */} - <SearchInput - placeholder="Search goals by type..." - showClear={searchQuery.length > 0} - size="sm" - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onClear={() => setSearchQuery('')} - /> - - {/* Goals list */} - {(displayGoals.length > 0) ? ( - <div className="space-y-3"> - {displayGoals.map((goal) => ( - <GoalCard key={goal.id} goal={goal} /> - ))} - </div> - ) : ( - <EmptyState - description={searchQuery ? 'No goals match your search' : 'No goals set yet'} - icon={Target} - size="sm" - title="" - /> - )} - - {/* Add goal form */} - {showForm ? ( - <Card> - <CardContent className="p-4"> - <Heading as="h4" className="text-foreground mb-3 text-sm font-medium">Set Goal</Heading> - <div className="space-y-3"> - <div> - <Label className="mb-1" htmlFor="goal-type"> - Goal Type - </Label> - <Select - value={goalType} - onValueChange={(v) => setGoalType(v as FitnessGoalType)} - > - <SelectTrigger id="goal-type"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {GOAL_TYPES.map((gt) => ( - <SelectItem key={gt} value={gt}> - {GOAL_TYPE_LABELS[gt]} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - <div className="flex gap-3"> - <div className="flex-1"> - <Label className="mb-1" htmlFor="goal-target"> - Target - </Label> - <Input - id="goal-target" - placeholder="100" - type="number" - value={target} - onChange={(e) => setTarget(e.target.value)} - /> - </div> - <div className="w-24"> - <Label className="mb-1" htmlFor="goal-unit"> - Unit - </Label> - <Input - id="goal-unit" - placeholder="kg" - type="text" - value={unit} - onChange={(e) => setUnit(e.target.value)} - /> - </div> - </div> - <div className="flex gap-2"> - <Button - className="flex-1" - disabled={Number(target) <= 0} - type="button" - onClick={handleSubmit} - > - Set Goal - </Button> - <Button - type="button" - variant="ghost" - onClick={() => setShowForm(false)} - > - Cancel - </Button> - </div> - </div> - </CardContent> - </Card> - ) : ( - <Button - className="text-primary" - type="button" - variant="ghost" - onClick={() => setShowForm(true)} - > - <Plus className="h-4 w-4" /> - Set New Goal - </Button> - )} - </div> - ); -} - -// ── GoalCard ───────────────────────────────────────────────── - -interface GoalCardProps { - goal: FitnessGoal; -} - -function GoalCard({ goal }: GoalCardProps) { - const deleteGoal = useDeleteGoal(); - const [editOpen, setEditOpen] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); - - const progress = goal.target > 0 ? Math.min((goal.current / goal.target) * 100, 100) : 0; - - function handleDeleteConfirm(): void { - deleteGoal.mutate(goal.id); - } - - return ( - <> - <Card> - <CardContent className="p-4"> - <div className="flex items-start justify-between"> - <div> - <span className="text-muted-foreground text-xs font-medium"> - {GOAL_TYPE_LABELS[goal.type]} - </span> - <Text className="font-semibold" size="sm"> - {String(goal.current)} / {String(goal.target)} {goal.unit} - </Text> - </div> - <div className="flex items-center gap-1"> - <Button - aria-label="Edit goal" - className="text-muted-foreground hover:text-foreground h-7 w-7" - size="icon" - type="button" - variant="ghost" - onClick={() => setEditOpen(true)} - > - <Pencil className="h-3 w-3" /> - </Button> - <Button - aria-label="Delete goal" - className="text-muted-foreground hover:text-destructive h-7 w-7" - size="icon" - type="button" - variant="ghost" - onClick={() => setDeleteOpen(true)} - > - <Trash2 className="h-3 w-3" /> - </Button> - </div> - </div> - <Progress className="mt-2" size="sm" value={Math.round(progress)} /> - <Text className="mt-1 text-xs" variant="muted"> - {String(Math.round(progress))}% complete - {goal.deadline ? ` \u00B7 Due ${goal.deadline}` : ''} - </Text> - <div className="mt-1"> - <RelativeTime value={goal.createdAt} /> - </div> - </CardContent> - </Card> - - <GoalEditDialog goal={goal} open={editOpen} onOpenChange={setEditOpen} /> - - <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>Delete goal?</AlertDialogTitle> - <AlertDialogDescription> - This will permanently remove the {GOAL_TYPE_LABELS[goal.type]} goal. This action - cannot be undone. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> - <AlertDialogAction - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - onClick={handleDeleteConfirm} - > - Delete - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - </> - ); -} diff --git a/src/renderer/features/personal/fitness/components/MeasurementEditDialog.tsx b/src/renderer/features/personal/fitness/components/MeasurementEditDialog.tsx deleted file mode 100644 index 7208b9cb..00000000 --- a/src/renderer/features/personal/fitness/components/MeasurementEditDialog.tsx +++ /dev/null @@ -1,221 +0,0 @@ -/** - * MeasurementEditDialog — Edit an existing body measurement - */ - -import { useState } from 'react'; - -import type { BodyMeasurement } from '@shared/types'; - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, -} from '@ui'; - -import { useUpdateMeasurement } from '../api/useFitness'; - -// ── Component ──────────────────────────────────────────────── - -interface MeasurementEditDialogProps { - measurement: BodyMeasurement; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function MeasurementEditDialog({ - measurement, - open, - onOpenChange, -}: MeasurementEditDialogProps) { - const updateMeasurement = useUpdateMeasurement(); - - const [date, setDate] = useState(measurement.date); - const [weight, setWeight] = useState(measurement.weight === undefined ? '' : String(measurement.weight)); - const [bodyFat, setBodyFat] = useState(measurement.bodyFat === undefined ? '' : String(measurement.bodyFat)); - const [muscleMass, setMuscleMass] = useState(measurement.muscleMass === undefined ? '' : String(measurement.muscleMass)); - const [boneMass, setBoneMass] = useState(measurement.boneMass === undefined ? '' : String(measurement.boneMass)); - const [waterPercentage, setWaterPercentage] = useState(measurement.waterPercentage === undefined ? '' : String(measurement.waterPercentage)); - const [visceralFat, setVisceralFat] = useState(measurement.visceralFat === undefined ? '' : String(measurement.visceralFat)); - - const hasAnyValue = - weight !== '' || - bodyFat !== '' || - muscleMass !== '' || - boneMass !== '' || - waterPercentage !== '' || - visceralFat !== ''; - - function handleSave() { - if (!hasAnyValue) return; - - updateMeasurement.mutate( - { - id: measurement.id, - date, - weight: weight === '' ? undefined : Number(weight), - bodyFat: bodyFat === '' ? undefined : Number(bodyFat), - muscleMass: muscleMass === '' ? undefined : Number(muscleMass), - boneMass: boneMass === '' ? undefined : Number(boneMass), - waterPercentage: waterPercentage === '' ? undefined : Number(waterPercentage), - visceralFat: visceralFat === '' ? undefined : Number(visceralFat), - }, - { - onSuccess: () => { - onOpenChange(false); - }, - }, - ); - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle>Edit Measurement</DialogTitle> - </DialogHeader> - - <div className="space-y-4 py-2"> - {/* Date */} - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-measure-date" - > - Date - </Label> - <Input - id="edit-measure-date" - type="date" - value={date} - onChange={(e) => setDate(e.target.value)} - /> - </div> - - {/* Weight + Body Fat */} - <div className="grid grid-cols-2 gap-3"> - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-measure-weight" - > - Weight (kg) - </Label> - <Input - id="edit-measure-weight" - placeholder="75.5" - type="number" - value={weight} - onChange={(e) => setWeight(e.target.value)} - /> - </div> - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-measure-bodyfat" - > - Body Fat (%) - </Label> - <Input - id="edit-measure-bodyfat" - placeholder="18.5" - type="number" - value={bodyFat} - onChange={(e) => setBodyFat(e.target.value)} - /> - </div> - </div> - - {/* Muscle Mass + Bone Mass */} - <div className="grid grid-cols-2 gap-3"> - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-measure-muscle" - > - Muscle Mass (kg) - </Label> - <Input - id="edit-measure-muscle" - placeholder="35.0" - type="number" - value={muscleMass} - onChange={(e) => setMuscleMass(e.target.value)} - /> - </div> - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-measure-bone" - > - Bone Mass (kg) - </Label> - <Input - id="edit-measure-bone" - placeholder="3.2" - type="number" - value={boneMass} - onChange={(e) => setBoneMass(e.target.value)} - /> - </div> - </div> - - {/* Water + Visceral Fat */} - <div className="grid grid-cols-2 gap-3"> - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-measure-water" - > - Water (%) - </Label> - <Input - id="edit-measure-water" - placeholder="55.0" - type="number" - value={waterPercentage} - onChange={(e) => setWaterPercentage(e.target.value)} - /> - </div> - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-measure-visceral" - > - Visceral Fat - </Label> - <Input - id="edit-measure-visceral" - placeholder="8" - type="number" - value={visceralFat} - onChange={(e) => setVisceralFat(e.target.value)} - /> - </div> - </div> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - > - Cancel - </Button> - <Button - disabled={!hasAnyValue || updateMeasurement.isPending} - type="button" - onClick={handleSave} - > - Save Changes - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/personal/fitness/components/WorkoutEditDialog.tsx b/src/renderer/features/personal/fitness/components/WorkoutEditDialog.tsx deleted file mode 100644 index 10d2f1e6..00000000 --- a/src/renderer/features/personal/fitness/components/WorkoutEditDialog.tsx +++ /dev/null @@ -1,256 +0,0 @@ -/** - * WorkoutEditDialog — Edit an existing workout's date, type, duration, exercises, notes - */ - -import { useState } from 'react'; - -import type { Exercise, ExerciseSet, Workout, WorkoutType } from '@shared/types'; - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Textarea, -} from '@ui'; - -import { useUpdateWorkout } from '../api/useFitness'; - -import { WorkoutExerciseList } from './WorkoutExerciseList'; - -// ── Helpers ───────────────────────────────────────────────── - -let nextKey = 0; -function uid(): string { - nextKey += 1; - return `ek-${String(nextKey)}`; -} - -export interface FormExercise extends Exercise { - _key: string; - _setKeys: string[]; -} - -function toFormExercises(exercises: Exercise[]): FormExercise[] { - return exercises.map((e) => ({ - ...e, - _key: uid(), - _setKeys: e.sets.map(() => uid()), - })); -} - -// ── Constants ──────────────────────────────────────────────── - -const WORKOUT_TYPES: Array<{ value: WorkoutType; label: string }> = [ - { value: 'strength', label: 'Strength' }, - { value: 'cardio', label: 'Cardio' }, - { value: 'flexibility', label: 'Flexibility' }, - { value: 'sport', label: 'Sport' }, -]; - -// ── Component ──────────────────────────────────────────────── - -interface WorkoutEditDialogProps { - workout: Workout; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function WorkoutEditDialog({ workout, open, onOpenChange }: WorkoutEditDialogProps) { - const updateWorkout = useUpdateWorkout(); - - const [date, setDate] = useState(workout.date); - const [type, setType] = useState<WorkoutType>(workout.type); - const [duration, setDuration] = useState(String(workout.duration)); - const [notes, setNotes] = useState(workout.notes ?? ''); - const [exercises, setExercises] = useState<FormExercise[]>(() => - toFormExercises(workout.exercises), - ); - - function handleAddExercise() { - setExercises([ - ...exercises, - { name: '', sets: [{}], _key: uid(), _setKeys: [uid()] }, - ]); - } - - function handleRemoveExercise(index: number) { - setExercises(exercises.filter((_, i) => i !== index)); - } - - function handleExerciseNameChange(index: number, name: string) { - const updated = [...exercises]; - updated[index] = { ...updated[index], name }; - setExercises(updated); - } - - function handleAddSet(exerciseIndex: number) { - const updated = [...exercises]; - updated[exerciseIndex] = { - ...updated[exerciseIndex], - sets: [...updated[exerciseIndex].sets, {}], - _setKeys: [...updated[exerciseIndex]._setKeys, uid()], - }; - setExercises(updated); - } - - function handleSetChange( - exerciseIndex: number, - setIndex: number, - field: keyof ExerciseSet, - value: string, - ) { - const updated = [...exercises]; - const sets = [...updated[exerciseIndex].sets]; - const numericValue = value === '' ? undefined : Number(value); - sets[setIndex] = { ...sets[setIndex], [field]: numericValue }; - updated[exerciseIndex] = { ...updated[exerciseIndex], sets }; - setExercises(updated); - } - - function handleSave() { - const durationNum = Number(duration); - if (durationNum <= 0) return; - - const validExercises = exercises.filter((e) => e.name.trim().length > 0); - - updateWorkout.mutate( - { - id: workout.id, - date, - type, - duration: durationNum, - exercises: validExercises, - notes: notes.trim().length > 0 ? notes.trim() : undefined, - }, - { - onSuccess: () => { - onOpenChange(false); - }, - }, - ); - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg"> - <DialogHeader> - <DialogTitle>Edit Workout</DialogTitle> - </DialogHeader> - - <div className="space-y-4 py-2"> - {/* Date + Type */} - <div className="flex gap-3"> - <div className="flex-1"> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-workout-date" - > - Date - </Label> - <Input - id="edit-workout-date" - type="date" - value={date} - onChange={(e) => setDate(e.target.value)} - /> - </div> - <div className="flex-1"> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-workout-type" - > - Type - </Label> - <Select value={type} onValueChange={(v) => setType(v as WorkoutType)}> - <SelectTrigger id="edit-workout-type"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {WORKOUT_TYPES.map((opt) => ( - <SelectItem key={opt.value} value={opt.value}> - {opt.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - - {/* Duration */} - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-workout-duration" - > - Duration (minutes) - </Label> - <Input - id="edit-workout-duration" - min="1" - placeholder="45" - type="number" - value={duration} - onChange={(e) => setDuration(e.target.value)} - /> - </div> - - {/* Exercises */} - <WorkoutExerciseList - exercises={exercises} - onAddExercise={handleAddExercise} - onAddSet={handleAddSet} - onExerciseNameChange={handleExerciseNameChange} - onRemoveExercise={handleRemoveExercise} - onSetChange={handleSetChange} - /> - - {/* Notes */} - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="edit-workout-notes" - > - Notes - </Label> - <Textarea - className="h-16" - id="edit-workout-notes" - placeholder="Optional notes..." - resize="none" - value={notes} - onChange={(e) => setNotes(e.target.value)} - /> - </div> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - > - Cancel - </Button> - <Button - disabled={Number(duration) <= 0 || updateWorkout.isPending} - type="button" - onClick={handleSave} - > - Save Changes - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} - diff --git a/src/renderer/features/personal/fitness/components/WorkoutForm.tsx b/src/renderer/features/personal/fitness/components/WorkoutForm.tsx deleted file mode 100644 index 00442981..00000000 --- a/src/renderer/features/personal/fitness/components/WorkoutForm.tsx +++ /dev/null @@ -1,337 +0,0 @@ -/** - * WorkoutForm — Log a new workout - */ - -import { useState } from 'react'; - -import { Plus, Send, Trash2, X } from 'lucide-react'; - -import type { Exercise, ExerciseSet, WorkoutType } from '@shared/types'; - -import { - Button, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Textarea, -} from '@ui'; - -import { useLogWorkout } from '../api/useFitness'; -import { useFitnessUI } from '../store'; - -// ── Helpers ───────────────────────────────────────────────── - -let nextId = 0; -function uid(): string { - nextId += 1; - return `ex-${String(nextId)}`; -} - -interface FormExercise extends Exercise { - _key: string; - _setKeys: string[]; -} - -// ── Constants ──────────────────────────────────────────────── - -const WORKOUT_TYPES: Array<{ value: WorkoutType; label: string }> = [ - { value: 'strength', label: 'Strength' }, - { value: 'cardio', label: 'Cardio' }, - { value: 'flexibility', label: 'Flexibility' }, - { value: 'sport', label: 'Sport' }, -]; - -// ── Component ──────────────────────────────────────────────── - -export function WorkoutForm() { - const logWorkout = useLogWorkout(); - const { setShowWorkoutForm } = useFitnessUI(); - - const [date, setDate] = useState(new Date().toISOString().split('T')[0]); - const [type, setType] = useState<WorkoutType>('strength'); - const [duration, setDuration] = useState(''); - const [notes, setNotes] = useState(''); - const [exercises, setExercises] = useState<FormExercise[]>([]); - - function handleAddExercise() { - setExercises([...exercises, { name: '', sets: [{}], _key: uid(), _setKeys: [uid()] }]); - } - - function handleRemoveExercise(index: number) { - setExercises(exercises.filter((_, i) => i !== index)); - } - - function handleExerciseNameChange(index: number, name: string) { - const updated = [...exercises]; - updated[index] = { ...updated[index], name }; - setExercises(updated); - } - - function handleAddSet(exerciseIndex: number) { - const updated = [...exercises]; - updated[exerciseIndex] = { - ...updated[exerciseIndex], - sets: [...updated[exerciseIndex].sets, {}], - _setKeys: [...updated[exerciseIndex]._setKeys, uid()], - }; - setExercises(updated); - } - - function handleSetChange( - exerciseIndex: number, - setIndex: number, - field: keyof ExerciseSet, - value: string, - ) { - const updated = [...exercises]; - const sets = [...updated[exerciseIndex].sets]; - const numericValue = value === '' ? undefined : Number(value); - sets[setIndex] = { ...sets[setIndex], [field]: numericValue }; - updated[exerciseIndex] = { ...updated[exerciseIndex], sets }; - setExercises(updated); - } - - function handleSubmit() { - const durationNum = Number(duration); - if (durationNum <= 0) return; - - const validExercises = exercises.filter((e) => e.name.trim().length > 0); - - logWorkout.mutate( - { - date, - type, - duration: durationNum, - exercises: validExercises, - notes: notes.trim().length > 0 ? notes.trim() : undefined, - }, - { - onSuccess: () => { - setShowWorkoutForm(false); - }, - }, - ); - } - - return ( - <div className="bg-card border-border rounded-lg border"> - {/* Header */} - <div className="border-border flex items-center justify-between border-b px-4 py-3"> - <h3 className="text-foreground text-sm font-semibold">Log Workout</h3> - <Button - aria-label="Close form" - size="icon" - type="button" - variant="ghost" - onClick={() => setShowWorkoutForm(false)} - > - <X className="h-4 w-4" /> - </Button> - </div> - - <div className="space-y-4 p-4"> - {/* Date + Type */} - <div className="flex gap-3"> - <div className="flex-1"> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="workout-date" - > - Date - </Label> - <Input - id="workout-date" - type="date" - value={date} - onChange={(e) => setDate(e.target.value)} - /> - </div> - <div className="flex-1"> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="workout-type" - > - Type - </Label> - <Select value={type} onValueChange={(v) => setType(v as WorkoutType)}> - <SelectTrigger id="workout-type"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {WORKOUT_TYPES.map((opt) => ( - <SelectItem key={opt.value} value={opt.value}> - {opt.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - - {/* Duration */} - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="workout-duration" - > - Duration (minutes) - </Label> - <Input - id="workout-duration" - min="1" - placeholder="45" - type="number" - value={duration} - onChange={(e) => setDuration(e.target.value)} - /> - </div> - - {/* Exercises */} - <div> - <div className="mb-2 flex items-center justify-between"> - <span className="text-muted-foreground text-xs font-medium">Exercises</span> - <Button - className="text-primary h-auto p-0 text-xs font-medium" - size="sm" - type="button" - variant="ghost" - onClick={handleAddExercise} - > - <Plus className="h-3 w-3" /> - Add Exercise - </Button> - </div> - <div className="space-y-3"> - {exercises.map((exercise, exerciseIndex) => ( - <ExerciseInput - key={exercise._key} - exercise={exercise} - exerciseIndex={exerciseIndex} - onAddSet={() => handleAddSet(exerciseIndex)} - onNameChange={(name) => handleExerciseNameChange(exerciseIndex, name)} - onRemove={() => handleRemoveExercise(exerciseIndex)} - onSetChange={(setIndex, field, value) => - handleSetChange(exerciseIndex, setIndex, field, value) - } - /> - ))} - </div> - </div> - - {/* Notes */} - <div> - <Label - className="text-muted-foreground mb-1 block text-xs font-medium" - htmlFor="workout-notes" - > - Notes - </Label> - <Textarea - className="h-16" - id="workout-notes" - placeholder="Optional notes..." - resize="none" - value={notes} - onChange={(e) => setNotes(e.target.value)} - /> - </div> - - {/* Submit */} - <Button - className="w-full" - disabled={Number(duration) <= 0} - type="button" - onClick={handleSubmit} - > - <Send className="h-4 w-4" /> - Log Workout - </Button> - </div> - </div> - ); -} - -// ── ExerciseInput ──────────────────────────────────────────── - -interface ExerciseInputProps { - exercise: FormExercise; - exerciseIndex: number; - onNameChange: (name: string) => void; - onRemove: () => void; - onAddSet: () => void; - onSetChange: (setIndex: number, field: keyof ExerciseSet, value: string) => void; -} - -function ExerciseInput({ - exercise, - exerciseIndex, - onNameChange, - onRemove, - onAddSet, - onSetChange, -}: ExerciseInputProps) { - return ( - <div className="bg-muted/50 rounded-md p-3"> - <div className="mb-2 flex items-center gap-2"> - <Input - aria-label={`Exercise ${String(exerciseIndex + 1)} name`} - className="flex-1" - placeholder="Exercise name" - size="sm" - type="text" - value={exercise.name} - onChange={(e) => onNameChange(e.target.value)} - /> - <Button - aria-label="Remove exercise" - className="text-muted-foreground hover:text-destructive h-auto p-1" - size="icon" - type="button" - variant="ghost" - onClick={onRemove} - > - <Trash2 className="h-3 w-3" /> - </Button> - </div> - <div className="space-y-1"> - {exercise.sets.map((exerciseSet, setIndex) => ( - <div key={exercise._setKeys[setIndex]} className="flex items-center gap-2"> - <span className="text-muted-foreground w-8 text-xs">S{String(setIndex + 1)}</span> - <Input - aria-label={`Set ${String(setIndex + 1)} reps`} - className="w-16" - placeholder="Reps" - size="sm" - type="number" - value={exerciseSet.reps ?? ''} - onChange={(e) => onSetChange(setIndex, 'reps', e.target.value)} - /> - <Input - aria-label={`Set ${String(setIndex + 1)} weight`} - className="w-20" - placeholder="Weight" - size="sm" - type="number" - value={exerciseSet.weight ?? ''} - onChange={(e) => onSetChange(setIndex, 'weight', e.target.value)} - /> - <span className="text-muted-foreground text-xs">lbs</span> - </div> - ))} - </div> - <Button - className="text-primary mt-1 h-auto p-0 text-xs font-medium" - size="sm" - type="button" - variant="ghost" - onClick={onAddSet} - > - + Add Set - </Button> - </div> - ); -} diff --git a/src/renderer/features/personal/fitness/components/WorkoutLog.tsx b/src/renderer/features/personal/fitness/components/WorkoutLog.tsx deleted file mode 100644 index b7456e54..00000000 --- a/src/renderer/features/personal/fitness/components/WorkoutLog.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * WorkoutLog — Recent workouts list - */ - -import { useState } from 'react'; - -import { Pencil, Trash2 } from 'lucide-react'; - -import type { Workout } from '@shared/types'; - -import { RelativeTime } from '@renderer/shared/components/RelativeTime'; -import { useDebounce } from '@renderer/shared/hooks/useDebounce'; - -import { Badge, Button, EmptyState, SearchInput, Text } from '@ui'; - -import { useDeleteWorkout, useWorkouts } from '../api/useFitness'; - -import { WorkoutEditDialog } from './WorkoutEditDialog'; - -// ── Constants ──────────────────────────────────────────────── - -const TYPE_LABELS: Record<string, string> = { - strength: 'Strength', - cardio: 'Cardio', - flexibility: 'Flexibility', - sport: 'Sport', -}; - -// ── Component ──────────────────────────────────────────────── - -export function WorkoutLog() { - const { data: workouts } = useWorkouts(); - const deleteWorkout = useDeleteWorkout(); - const [searchQuery, setSearchQuery] = useState(''); - const debouncedQuery = useDebounce(searchQuery); - - const allWorkouts = workouts ?? []; - - const displayWorkouts = debouncedQuery.trim() - ? allWorkouts.filter((w) => { - const q = debouncedQuery.toLowerCase(); - return w.type.toLowerCase().includes(q) || (w.notes ?? '').toLowerCase().includes(q); - }) - : allWorkouts; - - return ( - <div className="flex flex-col gap-3"> - <SearchInput - placeholder="Search workouts by type or notes..." - showClear={searchQuery.length > 0} - size="sm" - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onClear={() => setSearchQuery('')} - /> - {displayWorkouts.length === 0 ? ( - <EmptyState - description={searchQuery ? 'No workouts match your search' : 'No workouts logged yet'} - size="sm" - title="" - /> - ) : ( - <div className="divide-border divide-y"> - {displayWorkouts.slice(0, 20).map((workout) => ( - <WorkoutItem - key={workout.id} - workout={workout} - onDelete={() => deleteWorkout.mutate(workout.id)} - /> - ))} - </div> - )} - </div> - ); -} - -// ── WorkoutItem ────────────────────────────────────────────── - -interface WorkoutItemProps { - workout: Workout; - onDelete: () => void; -} - -function getWorkoutVariant(type: string): 'default' | 'success' | 'info' | 'warning' | 'secondary' { - if (type === 'strength') return 'default'; - if (type === 'cardio') return 'success'; - if (type === 'flexibility') return 'info'; - if (type === 'sport') return 'warning'; - return 'secondary'; -} - -function WorkoutItem({ workout, onDelete }: WorkoutItemProps) { - const [editOpen, setEditOpen] = useState(false); - const exerciseCount = workout.exercises.length; - const totalSets = workout.exercises.reduce((sum, e) => sum + e.sets.length, 0); - - return ( - <> - <div className="flex items-start justify-between px-4 py-3"> - <div className="flex-1"> - <div className="flex items-center gap-2"> - <Badge variant={getWorkoutVariant(workout.type)}> - {TYPE_LABELS[workout.type] ?? workout.type} - </Badge> - <span className="text-muted-foreground text-xs">{workout.date}</span> - </div> - <Text className="mt-1" size="sm"> - {String(exerciseCount)} exercise{exerciseCount === 1 ? '' : 's'} ·{' '} - {String(totalSets)} set{totalSets === 1 ? '' : 's'} · {String(workout.duration)}{' '} - min - </Text> - {(workout.exercises.length > 0) ? ( - <Text className="mt-0.5 text-xs" variant="muted"> - {workout.exercises.map((e) => e.name).join(', ')} - </Text> - ) : null} - {workout.notes ? ( - <Text className="mt-1 italic" size="sm" variant="muted">{workout.notes}</Text> - ) : null} - <div className="mt-1"> - <RelativeTime value={workout.createdAt} /> - </div> - </div> - <div className="flex items-center gap-1"> - <Button - aria-label="Edit workout" - className="text-muted-foreground hover:text-foreground" - size="icon" - type="button" - variant="ghost" - onClick={() => setEditOpen(true)} - > - <Pencil className="h-4 w-4" /> - </Button> - <Button - aria-label="Delete workout" - className="text-muted-foreground hover:text-destructive" - size="icon" - type="button" - variant="ghost" - onClick={onDelete} - > - <Trash2 className="h-4 w-4" /> - </Button> - </div> - </div> - <WorkoutEditDialog open={editOpen} workout={workout} onOpenChange={setEditOpen} /> - </> - ); -} diff --git a/src/renderer/features/personal/index.ts b/src/renderer/features/personal/index.ts index f5770a71..32672e87 100644 --- a/src/renderer/features/personal/index.ts +++ b/src/renderer/features/personal/index.ts @@ -1,17 +1,16 @@ /** - * Personal feature — public API + * Personal route shell — public API + * + * Exports the route-level components and store only. + * Sub-features (notes, alerts, fitness, planner, briefing, changelog) + * are now top-level features and must be imported directly: + * import { useCreateNote } from '@features/notes' + * import { AlertsPage } from '@features/alerts' + * etc. */ // Store export { usePersonalStore } from './store'; -// Components +// Route component export { PersonalPage } from './components/PersonalPage'; - -// Sub-feature re-exports -export { NotesPage, NoteEditor, NotesList, QuickNote, useNotesUI, useNotes, useCreateNote, useUpdateNote, useDeleteNote, useSearchNotes, useNoteEvents, noteKeys } from './notes/index'; -export { AlertsPage, AlertNotification, useAlerts, useCreateAlert, useDismissAlert, useDeleteAlert, useAlertEvents, useAlertStore, alertKeys } from './alerts/index'; -export { FitnessPage, useFitnessUI, useFitnessEvents, fitnessKeys, useDeleteGoal, useDeleteMeasurement, useDeleteWorkout, useFitnessGoals, useFitnessStats, useLogMeasurement, useLogWorkout, useMeasurements, useSetGoal, useUpdateGoalProgress, useUpdateMeasurement, useUpdateWorkout, useWorkouts } from './fitness/index'; -export { PlannerPage, WeeklyReviewPage, usePlannerUI, usePlannerEvents, plannerKeys, useDay, useUpdateDay, useAddTimeBlock, useUpdateTimeBlock, useRemoveTimeBlock, useWeeklyReview, useGenerateWeeklyReview, useUpdateWeeklyReflection } from './planner/index'; -export { BriefingPage, SuggestionCard, useDailyBriefing, useGenerateBriefing, useBriefingConfig, useUpdateBriefingConfig, useSuggestions, briefingKeys } from './briefing/index'; -export { ChangelogPage, useChangelog, useAddChangelogEntry, useGenerateChangelog, changelogKeys } from './changelog/index'; diff --git a/src/renderer/features/personal/notes/components/NoteEditor.tsx b/src/renderer/features/personal/notes/components/NoteEditor.tsx deleted file mode 100644 index 7de4e4b8..00000000 --- a/src/renderer/features/personal/notes/components/NoteEditor.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/** - * NoteEditor — Title + content textarea for editing a note - */ - -import { useCallback, useEffect, useState } from 'react'; - -import { Pin, PinOff, Save, Trash2, X } from 'lucide-react'; - -import type { Note } from '@shared/types'; - -import { Button, Input, Textarea } from '@ui'; - -import { useDeleteNote, useUpdateNote } from '../api/useNotes'; -import { useNotesUI } from '../store'; - -// ── Types ──────────────────────────────────────────────────── - -interface NoteEditorProps { - note: Note; -} - -// ── Component ──────────────────────────────────────────────── - -export function NoteEditor({ note }: NoteEditorProps) { - const updateNote = useUpdateNote(); - const deleteNote = useDeleteNote(); - const { selectNote } = useNotesUI(); - - const [title, setTitle] = useState(note.title); - const [content, setContent] = useState(note.content); - const [tagsInput, setTagsInput] = useState(note.tags.join(', ')); - - // Sync local state when the selected note changes - useEffect(() => { - setTitle(note.title); - setContent(note.content); - setTagsInput(note.tags.join(', ')); - }, [note.id, note.title, note.content, note.tags]); - - const handleSave = useCallback(() => { - const tags = tagsInput - .split(',') - .map((t) => t.trim()) - .filter((t) => t.length > 0); - - updateNote.mutate({ - id: note.id, - title, - content, - tags, - }); - }, [note.id, title, content, tagsInput, updateNote]); - - function handleDelete() { - deleteNote.mutate(note.id); - selectNote(null); - } - - function handleTogglePin() { - updateNote.mutate({ id: note.id, pinned: !note.pinned }); - } - - function handleClose() { - selectNote(null); - } - - const hasChanges = - title !== note.title || content !== note.content || tagsInput !== note.tags.join(', '); - - return ( - <div className="flex h-full flex-col"> - {/* Toolbar */} - <div className="border-border flex items-center justify-between border-b px-4 py-2"> - <div className="flex items-center gap-2"> - <Button - aria-label={note.pinned ? 'Unpin note' : 'Pin note'} - className={note.pinned ? 'text-primary' : 'text-muted-foreground'} - size="icon" - variant="ghost" - onClick={handleTogglePin} - > - {note.pinned ? <Pin className="h-4 w-4" /> : <PinOff className="h-4 w-4" />} - </Button> - <Button - aria-label="Save note" - className={hasChanges ? 'text-primary' : 'text-muted-foreground'} - disabled={!hasChanges} - size="icon" - variant="ghost" - onClick={handleSave} - > - <Save className="h-4 w-4" /> - </Button> - </div> - <div className="flex items-center gap-2"> - <Button - aria-label="Delete note" - className="text-muted-foreground hover:text-destructive" - size="icon" - variant="ghost" - onClick={handleDelete} - > - <Trash2 className="h-4 w-4" /> - </Button> - <Button - aria-label="Close editor" - size="icon" - variant="ghost" - onClick={handleClose} - > - <X className="h-4 w-4" /> - </Button> - </div> - </div> - - {/* Title */} - <div className="border-border border-b px-4 py-3"> - <Input - aria-label="Note title" - className="border-none bg-transparent text-lg font-semibold shadow-none focus-visible:ring-0" - placeholder="Note title..." - value={title} - onChange={(e) => setTitle(e.target.value)} - /> - </div> - - {/* Tags */} - <div className="border-border border-b px-4 py-2"> - <Input - aria-label="Tags (comma separated)" - className="border-none bg-transparent text-muted-foreground shadow-none focus-visible:ring-0" - placeholder="Tags (comma separated)..." - value={tagsInput} - onChange={(e) => setTagsInput(e.target.value)} - /> - </div> - - {/* Content */} - <div className="flex-1 p-4"> - <Textarea - aria-label="Note content" - className="h-full resize-none border-none bg-transparent shadow-none focus-visible:ring-0" - placeholder="Write your note..." - value={content} - onChange={(e) => setContent(e.target.value)} - /> - </div> - - {/* Footer */} - <div className="border-border text-muted-foreground border-t px-4 py-2 text-xs"> - Updated {new Date(note.updatedAt).toLocaleString()} - </div> - </div> - ); -} diff --git a/src/renderer/features/personal/planner/api/queryKeys.ts b/src/renderer/features/planner/api/queryKeys.ts similarity index 100% rename from src/renderer/features/personal/planner/api/queryKeys.ts rename to src/renderer/features/planner/api/queryKeys.ts diff --git a/src/renderer/features/personal/planner/api/usePlanner.ts b/src/renderer/features/planner/api/usePlanner.ts similarity index 100% rename from src/renderer/features/personal/planner/api/usePlanner.ts rename to src/renderer/features/planner/api/usePlanner.ts diff --git a/src/renderer/features/personal/planner/api/useWeeklyReview.ts b/src/renderer/features/planner/api/useWeeklyReview.ts similarity index 100% rename from src/renderer/features/personal/planner/api/useWeeklyReview.ts rename to src/renderer/features/planner/api/useWeeklyReview.ts diff --git a/src/renderer/features/personal/planner/components/CalendarOverlay.tsx b/src/renderer/features/planner/components/CalendarOverlay.tsx similarity index 100% rename from src/renderer/features/personal/planner/components/CalendarOverlay.tsx rename to src/renderer/features/planner/components/CalendarOverlay.tsx diff --git a/src/renderer/features/planner/components/CalendarOverlay/CalendarOverlay.tsx b/src/renderer/features/planner/components/CalendarOverlay/CalendarOverlay.tsx new file mode 100644 index 00000000..ae45b066 --- /dev/null +++ b/src/renderer/features/planner/components/CalendarOverlay/CalendarOverlay.tsx @@ -0,0 +1,145 @@ +/** + * CalendarOverlay — Shows Google Calendar events in the planner + * + * Displays calendar events as read-only blocks with a distinct visual style + * (translucent, different border) to distinguish from user-created time blocks. + */ + +import { Calendar, Clock, MapPin, Users } from 'lucide-react'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { useCalendarOverlay } from './useCalendarOverlay'; + +// ── Types ───────────────────────────────────────────────────── + +interface CalendarOverlayProps { + /** Date in YYYY-MM-DD format */ + date: string; + /** Whether to show the calendar overlay */ + visible: boolean; +} + +interface CalendarEvent { + id: string; + summary: string; + start?: string; + end?: string; + location?: string; + status: string; + attendees: number; +} + +// ── Helpers ─────────────────────────────────────────────────── + +function formatTime(dateString: string | undefined): string { + if (!dateString) { + return 'All day'; + } + try { + return new Date(dateString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } catch { + return dateString; + } +} + +function isCurrentEvent(start: string | undefined, end: string | undefined): boolean { + if (!start || !end) { + return false; + } + const now = Date.now(); + return now >= new Date(start).getTime() && now <= new Date(end).getTime(); +} + +function getEventTimeDisplay(event: CalendarEvent): string { + if (!event.start) { + return 'All day'; + } + const startTime = formatTime(event.start); + const endTime = event.end ? formatTime(event.end) : ''; + return endTime ? `${startTime} - ${endTime}` : startTime; +} + +// ── Component ───────────────────────────────────────────────── + +export function CalendarOverlay({ date, visible }: CalendarOverlayProps) { + const { isLoading, sortedEvents } = useCalendarOverlay(date); + + if (!visible) { + return null; + } + + if (isLoading) { + return ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Calendar className="text-info h-4 w-4" /> + <span className="text-muted-foreground text-xs">Loading calendar...</span> + </div> + </div> + ); + } + + if (sortedEvents.length === 0) { + return null; + } + + return ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Calendar className="text-info h-4 w-4" /> + <span className="text-muted-foreground text-xs font-medium"> + Calendar Events ({String(sortedEvents.length)}) + </span> + </div> + <div className="space-y-1.5"> + {sortedEvents.map((event) => { + const isCurrent = isCurrentEvent(event.start, event.end); + return ( + <div + key={event.id} + className={cn( + 'rounded-md border-l-2 px-3 py-2 transition-colors', + 'border-info/60 bg-info/8', + 'border border-l-2 border-dashed', + isCurrent && 'border-info bg-info/15 border-solid', + )} + style={{ + borderLeftStyle: 'solid', + }} + > + <div className="flex items-start justify-between"> + <div className="min-w-0 flex-1"> + <p className="text-foreground text-sm font-medium">{event.summary}</p> + <div className="text-muted-foreground mt-0.5 flex flex-wrap items-center gap-2 text-xs"> + <span className="flex items-center gap-1"> + <Clock className="h-3 w-3" /> + {getEventTimeDisplay(event)} + </span> + {event.location ? ( + <span className="flex items-center gap-1"> + <MapPin className="h-3 w-3" /> + <span className="max-w-[120px] truncate">{event.location}</span> + </span> + ) : null} + {event.attendees > 0 ? ( + <span className="flex items-center gap-1"> + <Users className="h-3 w-3" /> + {String(event.attendees)} + </span> + ) : null} + </div> + </div> + {isCurrent ? ( + <span className="bg-info/20 text-info-foreground shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium"> + Now + </span> + ) : null} + </div> + </div> + ); + })} + </div> + </div> + ); +} diff --git a/src/renderer/features/planner/components/CalendarOverlay/index.ts b/src/renderer/features/planner/components/CalendarOverlay/index.ts new file mode 100644 index 00000000..8cf3ca54 --- /dev/null +++ b/src/renderer/features/planner/components/CalendarOverlay/index.ts @@ -0,0 +1 @@ +export { CalendarOverlay } from './CalendarOverlay'; diff --git a/src/renderer/features/planner/components/CalendarOverlay/useCalendarOverlay.ts b/src/renderer/features/planner/components/CalendarOverlay/useCalendarOverlay.ts new file mode 100644 index 00000000..36c4fcfc --- /dev/null +++ b/src/renderer/features/planner/components/CalendarOverlay/useCalendarOverlay.ts @@ -0,0 +1,36 @@ +/** + * useCalendarOverlay — logic for CalendarOverlay + */ + +import { useMemo } from 'react'; + +import { useCalendarEvents } from '@features/productivity'; + +export function useCalendarOverlay(date: string) { + const { timeMin, timeMax } = useMemo(() => { + const start = new Date(`${date}T00:00:00`); + const end = new Date(`${date}T23:59:59`); + return { + timeMin: start.toISOString(), + timeMax: end.toISOString(), + }; + }, [date]); + + const { data: events, isLoading } = useCalendarEvents(timeMin, timeMax); + + const sortedEvents = useMemo(() => { + if (!events || events.length === 0) { + return []; + } + return [...events].sort((a, b) => { + if (!a.start) return -1; + if (!b.start) return 1; + return new Date(a.start).getTime() - new Date(b.start).getTime(); + }); + }, [events]); + + return { + isLoading, + sortedEvents, + }; +} diff --git a/src/renderer/features/personal/planner/components/CategoryBar.tsx b/src/renderer/features/planner/components/CategoryBar.tsx similarity index 100% rename from src/renderer/features/personal/planner/components/CategoryBar.tsx rename to src/renderer/features/planner/components/CategoryBar.tsx diff --git a/src/renderer/features/personal/planner/components/DayCompact.tsx b/src/renderer/features/planner/components/DayCompact.tsx similarity index 100% rename from src/renderer/features/personal/planner/components/DayCompact.tsx rename to src/renderer/features/planner/components/DayCompact.tsx diff --git a/src/renderer/features/personal/planner/components/DayView.tsx b/src/renderer/features/planner/components/DayView.tsx similarity index 100% rename from src/renderer/features/personal/planner/components/DayView.tsx rename to src/renderer/features/planner/components/DayView.tsx diff --git a/src/renderer/features/planner/components/DayView/DayView.tsx b/src/renderer/features/planner/components/DayView/DayView.tsx new file mode 100644 index 00000000..d1ff2381 --- /dev/null +++ b/src/renderer/features/planner/components/DayView/DayView.tsx @@ -0,0 +1,181 @@ +/** + * DayView — Timeline with time blocks for a single day + */ + +import { Calendar, Clock, Edit2, Plus, Trash2 } from 'lucide-react'; + +import type { ScheduledTask, TimeBlock } from '@shared/types'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { Button, Code, Heading, Text } from '@ui'; + +import { CalendarOverlay } from '../CalendarOverlay'; +import { TimeBlockEditor } from '../TimeBlockEditor'; + +import { useDayView } from './useDayView'; + +const BLOCK_TYPE_STYLES: Record<TimeBlock['type'], string> = { + focus: 'border-l-primary bg-primary/5', + meeting: 'border-l-info bg-info/5', + break: 'border-l-success bg-success/5', + other: 'border-l-muted-foreground bg-muted/30', +}; + +const BLOCK_TYPE_LABELS: Record<TimeBlock['type'], string> = { + focus: 'Focus', + meeting: 'Meeting', + break: 'Break', + other: 'Other', +}; + +interface DayViewProps { + /** Date in YYYY-MM-DD format */ + date: string; + timeBlocks: TimeBlock[]; + scheduledTasks?: ScheduledTask[]; + onAdd: (block: Omit<TimeBlock, 'id'>) => void; + onUpdate: (blockId: string, updates: Partial<Omit<TimeBlock, 'id'>>) => void; + onRemove: (blockId: string) => void; +} + +export function DayView({ date, timeBlocks, scheduledTasks = [], onAdd, onUpdate, onRemove }: DayViewProps) { + const { + showEditor, + setShowEditor, + editingBlock, + showCalendarOverlay, + sorted, + handleSave, + handleEdit, + handleCancel, + handleToggleCalendar, + } = useDayView(timeBlocks, onAdd, onUpdate); + + return ( + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <Heading as="h3" className="text-foreground text-sm font-semibold">Schedule</Heading> + <div className="flex items-center gap-2"> + <Button + aria-label={showCalendarOverlay ? 'Hide calendar events' : 'Show calendar events'} + size="sm" + title={showCalendarOverlay ? 'Hide calendar events' : 'Show calendar events'} + type="button" + variant="ghost" + className={cn( + 'inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors', + showCalendarOverlay + ? 'bg-info/10 text-info hover:bg-info/20' + : 'text-muted-foreground hover:text-foreground', + )} + onClick={handleToggleCalendar} + > + <Calendar className="h-3.5 w-3.5" /> + Calendar + </Button> + {showEditor ? null : ( + <Button + className="text-muted-foreground hover:text-primary inline-flex items-center gap-1 text-xs transition-colors" + size="sm" + type="button" + variant="ghost" + onClick={() => setShowEditor(true)} + > + <Plus className="h-3.5 w-3.5" /> + Add Block + </Button> + )} + </div> + </div> + + {showEditor ? ( + <TimeBlockEditor editingBlock={editingBlock} onCancel={handleCancel} onSave={handleSave} /> + ) : null} + + {/* Calendar Events Overlay */} + <CalendarOverlay date={date} visible={showCalendarOverlay} /> + + {/* User Time Blocks */} + {sorted.length === 0 && !showEditor ? ( + <Text className="text-muted-foreground text-xs">No time blocks scheduled.</Text> + ) : ( + <div className="space-y-2"> + {sorted.map((block) => ( + <div + key={block.id} + className={cn( + 'group rounded-md border-l-2 px-3 py-2.5 transition-colors', + BLOCK_TYPE_STYLES[block.type], + )} + > + <div className="flex items-start justify-between"> + <div className="min-w-0 flex-1"> + <Text className="text-foreground text-sm font-medium">{block.label}</Text> + <div className="text-muted-foreground mt-0.5 flex items-center gap-2 text-xs"> + <Clock className="h-3 w-3" /> + <span> + {block.startTime} - {block.endTime} + </span> + <span className="bg-muted rounded px-1.5 py-0.5 text-[10px]"> + {BLOCK_TYPE_LABELS[block.type]} + </span> + </div> + </div> + <div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> + <Button + aria-label="Edit block" + className="text-muted-foreground hover:text-foreground rounded p-1 transition-colors" + size="icon" + variant="ghost" + onClick={() => handleEdit(block)} + > + <Edit2 className="h-3.5 w-3.5" /> + </Button> + <Button + aria-label="Remove block" + className="text-muted-foreground hover:text-destructive rounded p-1 transition-colors" + size="icon" + variant="ghost" + onClick={() => onRemove(block.id)} + > + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </div> + </div> + </div> + ))} + </div> + )} + + {/* Scheduled Tasks */} + {scheduledTasks.length > 0 ? ( + <div className="mt-4 space-y-2"> + <Heading as="h3" className="text-foreground text-sm font-semibold">Scheduled Tasks</Heading> + <div className="space-y-2"> + {scheduledTasks.map((task) => ( + <div + key={task.taskId} + className="border-l-muted-foreground bg-muted/20 group rounded-md border-l-2 px-3 py-2" + > + <div className="flex items-start justify-between"> + <div className={cn('min-w-0 flex-1', task.completed ? 'opacity-50' : '')}> + <Code>{task.taskId}</Code> + {task.scheduledTime === undefined && task.estimatedDuration === undefined ? null : ( + <div className="text-muted-foreground mt-0.5 flex items-center gap-2 text-xs"> + {task.scheduledTime === undefined ? null : <span>{task.scheduledTime}</span>} + {task.estimatedDuration === undefined ? null : ( + <span>{task.estimatedDuration} min</span> + )} + </div> + )} + </div> + </div> + </div> + ))} + </div> + </div> + ) : null} + </div> + ); +} diff --git a/src/renderer/features/planner/components/DayView/index.ts b/src/renderer/features/planner/components/DayView/index.ts new file mode 100644 index 00000000..c16fdecf --- /dev/null +++ b/src/renderer/features/planner/components/DayView/index.ts @@ -0,0 +1 @@ +export { DayView } from './DayView'; diff --git a/src/renderer/features/planner/components/DayView/useDayView.ts b/src/renderer/features/planner/components/DayView/useDayView.ts new file mode 100644 index 00000000..555a506c --- /dev/null +++ b/src/renderer/features/planner/components/DayView/useDayView.ts @@ -0,0 +1,57 @@ +/** + * useDayView — logic for DayView + */ + +import { useState } from 'react'; + +import type { TimeBlock } from '@shared/types'; + +import { usePlannerUI } from '../../store'; + +export function useDayView( + timeBlocks: TimeBlock[], + onAdd: (block: Omit<TimeBlock, 'id'>) => void, + onUpdate: (blockId: string, updates: Partial<Omit<TimeBlock, 'id'>>) => void, +) { + const [showEditor, setShowEditor] = useState(false); + const [editingBlock, setEditingBlock] = useState<TimeBlock | undefined>(); + const { showCalendarOverlay, setShowCalendarOverlay } = usePlannerUI(); + + const sorted = [...timeBlocks].sort((a, b) => a.startTime.localeCompare(b.startTime)); + + function handleSave(block: Omit<TimeBlock, 'id'>) { + if (editingBlock) { + onUpdate(editingBlock.id, block); + } else { + onAdd(block); + } + setShowEditor(false); + setEditingBlock(undefined); + } + + function handleEdit(block: TimeBlock) { + setEditingBlock(block); + setShowEditor(true); + } + + function handleCancel() { + setShowEditor(false); + setEditingBlock(undefined); + } + + function handleToggleCalendar() { + setShowCalendarOverlay(!showCalendarOverlay); + } + + return { + showEditor, + setShowEditor, + editingBlock, + showCalendarOverlay, + sorted, + handleSave, + handleEdit, + handleCancel, + handleToggleCalendar, + }; +} diff --git a/src/renderer/features/personal/planner/components/GoalsList.tsx b/src/renderer/features/planner/components/GoalsList/GoalsList.tsx similarity index 80% rename from src/renderer/features/personal/planner/components/GoalsList.tsx rename to src/renderer/features/planner/components/GoalsList/GoalsList.tsx index 8369d724..1a8e3512 100644 --- a/src/renderer/features/personal/planner/components/GoalsList.tsx +++ b/src/renderer/features/planner/components/GoalsList/GoalsList.tsx @@ -3,13 +3,13 @@ * Completion is persisted via the onToggle callback (not local state). */ -import { useState } from 'react'; - import { Check, Plus, Trash2, X } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; -import { Button, Input } from '@ui'; +import { Button, Heading, Input } from '@ui'; + +import { useGoalsList } from './useGoalsList'; interface GoalsListProps { goals: string[]; @@ -19,34 +19,18 @@ interface GoalsListProps { } export function GoalsList({ goals, completedGoals, onUpdate, onToggle }: GoalsListProps) { - const [newGoal, setNewGoal] = useState(''); - const completedSet = new Set(completedGoals); - - function handleAdd() { - const trimmed = newGoal.trim(); - if (trimmed.length === 0) return; - onUpdate([...goals, trimmed]); - setNewGoal(''); - } - - function handleKeyDown(event: React.KeyboardEvent) { - if (event.key === 'Enter') { - handleAdd(); - } - } - - function handleRemove(index: number) { - const removedText = goals[index]; - const updated = goals.filter((_g, idx) => idx !== index); - onUpdate(updated); - if (completedSet.has(removedText)) { - onToggle(removedText); - } - } + const { + newGoal, + completedSet, + setNewGoal, + handleAdd, + handleKeyDown, + handleRemove, + } = useGoalsList(goals, completedGoals, onUpdate, onToggle); return ( <div className="space-y-3"> - <h3 className="text-foreground text-sm font-semibold">Daily Goals</h3> + <Heading as="h3" className="text-sm">Daily Goals</Heading> {goals.length === 0 ? ( <p className="text-muted-foreground text-xs">No goals set for today.</p> diff --git a/src/renderer/features/planner/components/GoalsList/index.ts b/src/renderer/features/planner/components/GoalsList/index.ts new file mode 100644 index 00000000..6d7e1aac --- /dev/null +++ b/src/renderer/features/planner/components/GoalsList/index.ts @@ -0,0 +1 @@ +export { GoalsList } from './GoalsList'; diff --git a/src/renderer/features/planner/components/GoalsList/useGoalsList.ts b/src/renderer/features/planner/components/GoalsList/useGoalsList.ts new file mode 100644 index 00000000..27041a9c --- /dev/null +++ b/src/renderer/features/planner/components/GoalsList/useGoalsList.ts @@ -0,0 +1,46 @@ +/** + * useGoalsList — logic for GoalsList + */ + +import { useState } from 'react'; + +export function useGoalsList( + goals: string[], + completedGoals: string[], + onUpdate: (goals: string[]) => void, + onToggle: (goalText: string) => void, +) { + const [newGoal, setNewGoal] = useState(''); + const completedSet = new Set(completedGoals); + + function handleAdd() { + const trimmed = newGoal.trim(); + if (trimmed.length === 0) return; + onUpdate([...goals, trimmed]); + setNewGoal(''); + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Enter') { + handleAdd(); + } + } + + function handleRemove(index: number) { + const removedText = goals[index]; + const updated = goals.filter((_g, idx) => idx !== index); + onUpdate(updated); + if (completedSet.has(removedText)) { + onToggle(removedText); + } + } + + return { + newGoal, + completedSet, + setNewGoal, + handleAdd, + handleKeyDown, + handleRemove, + }; +} diff --git a/src/renderer/features/personal/planner/components/PlannerPage.tsx b/src/renderer/features/planner/components/PlannerPage.tsx similarity index 100% rename from src/renderer/features/personal/planner/components/PlannerPage.tsx rename to src/renderer/features/planner/components/PlannerPage.tsx diff --git a/src/renderer/features/planner/components/PlannerPage/PlannerPage.tsx b/src/renderer/features/planner/components/PlannerPage/PlannerPage.tsx new file mode 100644 index 00000000..1f6d1380 --- /dev/null +++ b/src/renderer/features/planner/components/PlannerPage/PlannerPage.tsx @@ -0,0 +1,240 @@ +/** + * PlannerPage — Main daily planner layout + */ + +import { Link } from '@tanstack/react-router'; +import { + CalendarDays, + ChevronLeft, + ChevronRight, + Loader2, + MessageSquare, +} from 'lucide-react'; + +import { ROUTES } from '@shared/constants'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { Button, Heading, PageContent, PageHeader, PageLayout, Text, Textarea } from '@ui'; + +import { DayView } from '../DayView'; +import { GoalsList } from '../GoalsList'; +import { WeekOverview } from '../WeekOverview'; + +import { usePlannerPage } from './usePlannerPage'; + +function formatDateLabel(dateStr: string): string { + const date = new Date(`${dateStr}T00:00:00`); + return new Intl.DateTimeFormat('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }).format(date); +} + +function isToday(dateStr: string): boolean { + return dateStr === new Date().toISOString().slice(0, 10); +} + +function ReflectionDisplay({ text }: { text?: string }) { + if (text) { + return <Text className="text-muted-foreground text-sm">{text}</Text>; + } + return <Text className="text-muted-foreground text-xs italic">No reflection yet.</Text>; +} + +export function PlannerPage() { + const { + selectedDate, + setSelectedDate, + viewMode, + setViewMode, + plan, + isLoading, + addTimeBlock, + updateTimeBlock, + removeTimeBlock, + reflection, + setReflection, + isEditingReflection, + setIsEditingReflection, + handlePrevDay, + handleNextDay, + handleGoToday, + handleGoalsUpdate, + handleGoalToggle, + handleSaveReflection, + handleStartEditReflection, + } = usePlannerPage(); + + if (isLoading) { + return ( + <div className="flex h-full items-center justify-center"> + <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" /> + </div> + ); + } + + return ( + <PageLayout> + <PageHeader> + <PageHeader.Row> + <PageHeader.Title>Daily Planner</PageHeader.Title> + <PageHeader.Actions> + {isToday(selectedDate) ? null : ( + <Button + className="text-primary hover:text-primary/80 px-2.5 py-1 text-xs" + size="sm" + type="button" + variant="ghost" + onClick={handleGoToday} + > + Today + </Button> + )} + + <div className="border-border flex items-center rounded-md border"> + <Button + aria-label="Previous day" + className="h-auto p-1.5" + size="icon" + type="button" + variant="ghost" + onClick={handlePrevDay} + > + <ChevronLeft className="h-4 w-4" /> + </Button> + <span className="text-foreground min-w-[180px] px-2 text-center text-sm font-medium"> + {formatDateLabel(selectedDate)} + </span> + <Button + aria-label="Next day" + className="h-auto p-1.5" + size="icon" + type="button" + variant="ghost" + onClick={handleNextDay} + > + <ChevronRight className="h-4 w-4" /> + </Button> + </div> + + <div className="border-border ml-2 flex rounded-md border"> + <Button + type="button" + variant={viewMode === 'day' ? 'primary' : 'ghost'} + className={cn( + 'h-auto rounded-r-none px-3 py-1 text-xs', + viewMode === 'day' ? '' : 'text-muted-foreground', + )} + onClick={() => setViewMode('day')} + > + Day + </Button> + <Button + type="button" + variant={viewMode === 'week' ? 'primary' : 'ghost'} + className={cn( + 'h-auto rounded-l-none px-3 py-1 text-xs', + viewMode === 'week' ? '' : 'text-muted-foreground', + )} + onClick={() => setViewMode('week')} + > + Week + </Button> + </div> + + <Link + className="text-muted-foreground hover:text-primary ml-2 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors" + to={ROUTES.PLANNER_WEEKLY} + > + <CalendarDays className="h-3.5 w-3.5" /> + Weekly Review + </Link> + </PageHeader.Actions> + </PageHeader.Row> + </PageHeader> + + <PageContent> + {viewMode === 'week' ? ( + <WeekOverview selectedDate={selectedDate} onSelectDate={setSelectedDate} /> + ) : null} + + <div className={cn('grid gap-6', viewMode === 'week' ? 'mt-6 grid-cols-1' : 'grid-cols-2')}> + {/* Left column — Goals */} + <div className="space-y-6"> + <GoalsList + completedGoals={plan?.completedGoals ?? []} + goals={plan?.goals ?? []} + onToggle={handleGoalToggle} + onUpdate={handleGoalsUpdate} + /> + + {/* Reflection */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Heading as="h3" className="text-foreground text-sm font-semibold">Reflection</Heading> + {isEditingReflection ? null : ( + <Button + className="text-muted-foreground hover:text-primary h-auto gap-1 p-0 text-xs" + size="sm" + type="button" + variant="ghost" + onClick={handleStartEditReflection} + > + <MessageSquare className="h-3 w-3" /> + {plan?.reflection ? 'Edit' : 'Add'} + </Button> + )} + </div> + + {isEditingReflection ? ( + <div className="space-y-2"> + <Textarea + placeholder="How did today go? What did you learn?" + resize="none" + rows={4} + value={reflection} + onChange={(event) => setReflection(event.target.value)} + /> + <div className="flex justify-end gap-2"> + <Button + size="sm" + type="button" + variant="ghost" + onClick={() => setIsEditingReflection(false)} + > + Cancel + </Button> + <Button + size="sm" + type="button" + onClick={handleSaveReflection} + > + Save + </Button> + </div> + </div> + ) : ( + <ReflectionDisplay text={plan?.reflection} /> + )} + </div> + </div> + + {/* Right column — Schedule */} + <DayView + date={selectedDate} + scheduledTasks={plan?.scheduledTasks ?? []} + timeBlocks={plan?.timeBlocks ?? []} + onAdd={(block) => addTimeBlock.mutate({ date: selectedDate, timeBlock: block })} + onRemove={(blockId) => removeTimeBlock.mutate({ date: selectedDate, blockId })} + onUpdate={(blockId, updates) => + updateTimeBlock.mutate({ date: selectedDate, blockId, updates }) + } + /> + </div> + </PageContent> + </PageLayout> + ); +} diff --git a/src/renderer/features/planner/components/PlannerPage/index.ts b/src/renderer/features/planner/components/PlannerPage/index.ts new file mode 100644 index 00000000..152acf6c --- /dev/null +++ b/src/renderer/features/planner/components/PlannerPage/index.ts @@ -0,0 +1 @@ +export { PlannerPage } from './PlannerPage'; diff --git a/src/renderer/features/planner/components/PlannerPage/usePlannerPage.ts b/src/renderer/features/planner/components/PlannerPage/usePlannerPage.ts new file mode 100644 index 00000000..398bd2d8 --- /dev/null +++ b/src/renderer/features/planner/components/PlannerPage/usePlannerPage.ts @@ -0,0 +1,89 @@ +/** + * usePlannerPage — logic for PlannerPage + */ + +import { useState } from 'react'; + +import { + useAddTimeBlock, + useDay, + useRemoveTimeBlock, + useUpdateDay, + useUpdateTimeBlock, +} from '../../api/usePlanner'; +import { usePlannerEvents } from '../../hooks/usePlannerEvents'; +import { usePlannerUI } from '../../store'; + +export function usePlannerPage() { + const { selectedDate, setSelectedDate, viewMode, setViewMode } = usePlannerUI(); + const { data: plan, isLoading } = useDay(selectedDate); + const updateDay = useUpdateDay(); + const addTimeBlock = useAddTimeBlock(); + const updateTimeBlock = useUpdateTimeBlock(); + const removeTimeBlock = useRemoveTimeBlock(); + const [reflection, setReflection] = useState(''); + const [isEditingReflection, setIsEditingReflection] = useState(false); + + usePlannerEvents(); + + function handlePrevDay() { + const current = new Date(`${selectedDate}T00:00:00`); + current.setDate(current.getDate() - 1); + setSelectedDate(current.toISOString().slice(0, 10)); + } + + function handleNextDay() { + const current = new Date(`${selectedDate}T00:00:00`); + current.setDate(current.getDate() + 1); + setSelectedDate(current.toISOString().slice(0, 10)); + } + + function handleGoToday() { + setSelectedDate(new Date().toISOString().slice(0, 10)); + } + + function handleGoalsUpdate(goals: string[]) { + updateDay.mutate({ date: selectedDate, goals }); + } + + function handleGoalToggle(goalText: string) { + const current = plan?.completedGoals ?? []; + const next = current.includes(goalText) + ? current.filter((g) => g !== goalText) + : [...current, goalText]; + updateDay.mutate({ date: selectedDate, completedGoals: next }); + } + + function handleSaveReflection() { + updateDay.mutate({ date: selectedDate, reflection }); + setIsEditingReflection(false); + } + + function handleStartEditReflection() { + setReflection(plan?.reflection ?? ''); + setIsEditingReflection(true); + } + + return { + selectedDate, + setSelectedDate, + viewMode, + setViewMode, + plan, + isLoading, + addTimeBlock, + updateTimeBlock, + removeTimeBlock, + reflection, + setReflection, + isEditingReflection, + setIsEditingReflection, + handlePrevDay, + handleNextDay, + handleGoToday, + handleGoalsUpdate, + handleGoalToggle, + handleSaveReflection, + handleStartEditReflection, + }; +} diff --git a/src/renderer/features/personal/planner/components/StatCard.tsx b/src/renderer/features/planner/components/StatCard.tsx similarity index 100% rename from src/renderer/features/personal/planner/components/StatCard.tsx rename to src/renderer/features/planner/components/StatCard.tsx diff --git a/src/renderer/features/personal/planner/components/TimeBlockEditor.tsx b/src/renderer/features/planner/components/TimeBlockEditor/TimeBlockEditor.tsx similarity index 88% rename from src/renderer/features/personal/planner/components/TimeBlockEditor.tsx rename to src/renderer/features/planner/components/TimeBlockEditor/TimeBlockEditor.tsx index 11a85cc6..e15bbd48 100644 --- a/src/renderer/features/personal/planner/components/TimeBlockEditor.tsx +++ b/src/renderer/features/planner/components/TimeBlockEditor/TimeBlockEditor.tsx @@ -2,14 +2,13 @@ * TimeBlockEditor — Create/edit time blocks */ -import { useState } from 'react'; - import { Plus, X } from 'lucide-react'; import type { TimeBlock } from '@shared/types'; import { Button, + Heading, Input, Label, Select, @@ -19,6 +18,8 @@ import { SelectValue, } from '@ui'; +import { useTimeBlockEditor } from './useTimeBlockEditor'; + const BLOCK_TYPES: Array<{ value: TimeBlock['type']; label: string }> = [ { value: 'focus', label: 'Focus' }, { value: 'meeting', label: 'Meeting' }, @@ -33,10 +34,16 @@ interface TimeBlockEditorProps { } export function TimeBlockEditor({ editingBlock, onSave, onCancel }: TimeBlockEditorProps) { - const [startTime, setStartTime] = useState(editingBlock?.startTime ?? '09:00'); - const [endTime, setEndTime] = useState(editingBlock?.endTime ?? '10:00'); - const [label, setLabel] = useState(editingBlock?.label ?? ''); - const [blockType, setBlockType] = useState<TimeBlock['type']>(editingBlock?.type ?? 'focus'); + const { + startTime, + endTime, + label, + blockType, + setStartTime, + setEndTime, + setLabel, + setBlockType, + } = useTimeBlockEditor(editingBlock); function handleSubmit(event: React.SyntheticEvent) { event.preventDefault(); @@ -53,9 +60,9 @@ export function TimeBlockEditor({ editingBlock, onSave, onCancel }: TimeBlockEdi return ( <form className="border-border bg-card space-y-3 rounded-lg border p-4" onSubmit={handleSubmit}> <div className="flex items-center justify-between"> - <h4 className="text-foreground text-sm font-medium"> + <Heading as="h4" className="text-sm font-medium"> {editingBlock ? 'Edit Time Block' : 'New Time Block'} - </h4> + </Heading> <Button aria-label="Cancel" size="icon" diff --git a/src/renderer/features/planner/components/TimeBlockEditor/index.ts b/src/renderer/features/planner/components/TimeBlockEditor/index.ts new file mode 100644 index 00000000..603f61e8 --- /dev/null +++ b/src/renderer/features/planner/components/TimeBlockEditor/index.ts @@ -0,0 +1 @@ +export { TimeBlockEditor } from './TimeBlockEditor'; diff --git a/src/renderer/features/planner/components/TimeBlockEditor/useTimeBlockEditor.ts b/src/renderer/features/planner/components/TimeBlockEditor/useTimeBlockEditor.ts new file mode 100644 index 00000000..2d6538af --- /dev/null +++ b/src/renderer/features/planner/components/TimeBlockEditor/useTimeBlockEditor.ts @@ -0,0 +1,25 @@ +/** + * useTimeBlockEditor — logic for TimeBlockEditor + */ + +import { useState } from 'react'; + +import type { TimeBlock } from '@shared/types'; + +export function useTimeBlockEditor(editingBlock?: TimeBlock) { + const [startTime, setStartTime] = useState(editingBlock?.startTime ?? '09:00'); + const [endTime, setEndTime] = useState(editingBlock?.endTime ?? '10:00'); + const [label, setLabel] = useState(editingBlock?.label ?? ''); + const [blockType, setBlockType] = useState<TimeBlock['type']>(editingBlock?.type ?? 'focus'); + + return { + startTime, + endTime, + label, + blockType, + setStartTime, + setEndTime, + setLabel, + setBlockType, + }; +} diff --git a/src/renderer/features/personal/planner/components/WeekOverview.tsx b/src/renderer/features/planner/components/WeekOverview.tsx similarity index 100% rename from src/renderer/features/personal/planner/components/WeekOverview.tsx rename to src/renderer/features/planner/components/WeekOverview.tsx diff --git a/src/renderer/features/planner/components/WeekOverview/WeekOverview.tsx b/src/renderer/features/planner/components/WeekOverview/WeekOverview.tsx new file mode 100644 index 00000000..a872c6b2 --- /dev/null +++ b/src/renderer/features/planner/components/WeekOverview/WeekOverview.tsx @@ -0,0 +1,72 @@ +/** + * WeekOverview — 7-day summary view + */ + +import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { Button } from '@ui'; + +import { useWeekOverview } from './useWeekOverview'; + +interface WeekOverviewProps { + selectedDate: string; + onSelectDate: (date: string) => void; +} + +export function WeekOverview({ selectedDate, onSelectDate }: WeekOverviewProps) { + const { days, weekLabel, handlePrevWeek, handleNextWeek } = useWeekOverview( + selectedDate, + onSelectDate, + ); + + return ( + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Calendar className="text-muted-foreground h-4 w-4" /> + <span className="text-foreground text-sm font-medium">{weekLabel}</span> + </div> + <div className="flex items-center gap-1"> + <Button + aria-label="Previous week" + className="text-muted-foreground hover:text-foreground rounded p-1 transition-colors" + size="icon" + variant="ghost" + onClick={handlePrevWeek} + > + <ChevronLeft className="h-4 w-4" /> + </Button> + <Button + aria-label="Next week" + className="text-muted-foreground hover:text-foreground rounded p-1 transition-colors" + size="icon" + variant="ghost" + onClick={handleNextWeek} + > + <ChevronRight className="h-4 w-4" /> + </Button> + </div> + </div> + + <div className="grid grid-cols-7 gap-1.5"> + {days.map((day) => ( + <Button + key={day.date} + variant="ghost" + className={cn( + 'flex h-auto flex-col items-center gap-0.5 rounded-lg px-2 py-2 text-center transition-colors', + day.date === selectedDate ? 'bg-primary text-primary-foreground' : 'hover:bg-accent', + day.isToday && day.date !== selectedDate && 'ring-primary ring-1', + )} + onClick={() => onSelectDate(day.date)} + > + <span className="text-[10px] font-medium uppercase">{day.dayLabel}</span> + <span className="text-sm font-semibold">{day.dayNumber}</span> + </Button> + ))} + </div> + </div> + ); +} diff --git a/src/renderer/features/planner/components/WeekOverview/index.ts b/src/renderer/features/planner/components/WeekOverview/index.ts new file mode 100644 index 00000000..8e6a5c41 --- /dev/null +++ b/src/renderer/features/planner/components/WeekOverview/index.ts @@ -0,0 +1 @@ +export { WeekOverview } from './WeekOverview'; diff --git a/src/renderer/features/planner/components/WeekOverview/useWeekOverview.ts b/src/renderer/features/planner/components/WeekOverview/useWeekOverview.ts new file mode 100644 index 00000000..b0527af6 --- /dev/null +++ b/src/renderer/features/planner/components/WeekOverview/useWeekOverview.ts @@ -0,0 +1,73 @@ +/** + * useWeekOverview — logic for WeekOverview + */ + +import { useMemo } from 'react'; + +import { useToday } from '@renderer/shared/hooks/useToday'; + +import { useWeekDayNavigation } from '../../hooks/useWeekDayNavigation'; + +interface WeekDay { + date: string; + dayLabel: string; + dayNumber: number; + isToday: boolean; +} + +function getWeekDays(centerDate: string, today: string): WeekDay[] { + const center = new Date(`${centerDate}T00:00:00`); + const dayOfWeek = center.getDay(); + const monday = new Date(center); + monday.setDate(center.getDate() - ((dayOfWeek + 6) % 7)); + + const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + const days: WeekDay[] = []; + for (let index = 0; index < 7; index++) { + const d = new Date(monday); + d.setDate(monday.getDate() + index); + const dateStr = d.toISOString().slice(0, 10); + days.push({ + date: dateStr, + dayLabel: dayNames[index], + dayNumber: d.getDate(), + isToday: dateStr === today, + }); + } + return days; +} + +function formatWeekRange(days: WeekDay[]): string { + if (days.length === 0) return ''; + const firstDay = days[0]; + const lastDay = days.at(-1) ?? firstDay; + const first = new Date(`${firstDay.date}T00:00:00`); + const last = new Date(`${lastDay.date}T00:00:00`); + const monthFmt = new Intl.DateTimeFormat('en-US', { month: 'short' }); + const startMonth = monthFmt.format(first); + const endMonth = monthFmt.format(last); + + if (startMonth === endMonth) { + return `${startMonth} ${String(first.getDate())} - ${String(last.getDate())}, ${String(first.getFullYear())}`; + } + return `${startMonth} ${String(first.getDate())} - ${endMonth} ${String(last.getDate())}, ${String(last.getFullYear())}`; +} + +export function useWeekOverview(selectedDate: string, onSelectDate: (date: string) => void) { + const today = useToday(); + const days = useMemo(() => getWeekDays(selectedDate, today), [selectedDate, today]); + const weekLabel = useMemo(() => formatWeekRange(days), [days]); + + const { handlePrevWeek, handleNextWeek } = useWeekDayNavigation({ + currentDate: selectedDate, + onDateChange: onSelectDate, + }); + + return { + days, + weekLabel, + handlePrevWeek, + handleNextWeek, + }; +} diff --git a/src/renderer/features/personal/planner/components/WeeklyReflectionSection.tsx b/src/renderer/features/planner/components/WeeklyReflectionSection/WeeklyReflectionSection.tsx similarity index 76% rename from src/renderer/features/personal/planner/components/WeeklyReflectionSection.tsx rename to src/renderer/features/planner/components/WeeklyReflectionSection/WeeklyReflectionSection.tsx index f8b3f615..a8bc546c 100644 --- a/src/renderer/features/personal/planner/components/WeeklyReflectionSection.tsx +++ b/src/renderer/features/planner/components/WeeklyReflectionSection/WeeklyReflectionSection.tsx @@ -2,13 +2,11 @@ * WeeklyReflectionSection — Editable weekly reflection card */ -import { useState } from 'react'; - import { MessageSquare } from 'lucide-react'; -import { Button, Card, CardContent, Textarea } from '@ui'; +import { Button, Card, CardContent, Heading, Textarea } from '@ui'; -import { useUpdateWeeklyReflection } from '../api/useWeeklyReview'; +import { useWeeklyReflectionSection } from './useWeeklyReflectionSection'; interface WeeklyReflectionSectionProps { weekStart: string; @@ -27,25 +25,21 @@ function ReflectionDisplay({ text }: { text?: string }) { } export function WeeklyReflectionSection({ weekStart, reflection }: WeeklyReflectionSectionProps) { - const [isEditing, setIsEditing] = useState(false); - const [reflectionText, setReflectionText] = useState(''); - const updateReflection = useUpdateWeeklyReflection(); - - function handleStartEdit() { - setReflectionText(reflection ?? ''); - setIsEditing(true); - } - - function handleSave() { - updateReflection.mutate({ startDate: weekStart, reflection: reflectionText }); - setIsEditing(false); - } + const { + isEditing, + setIsEditing, + reflectionText, + setReflectionText, + updateReflection, + handleStartEdit, + handleSave, + } = useWeeklyReflectionSection(weekStart, reflection); return ( <Card> <CardContent className="p-4"> <div className="mb-3 flex items-center justify-between"> - <h2 className="text-foreground text-sm font-semibold">Weekly Reflection</h2> + <Heading as="h2" className="text-sm">Weekly Reflection</Heading> {isEditing ? null : ( <Button className="text-muted-foreground hover:text-primary h-auto gap-1 p-0 text-xs" diff --git a/src/renderer/features/planner/components/WeeklyReflectionSection/index.ts b/src/renderer/features/planner/components/WeeklyReflectionSection/index.ts new file mode 100644 index 00000000..a1c5ca04 --- /dev/null +++ b/src/renderer/features/planner/components/WeeklyReflectionSection/index.ts @@ -0,0 +1 @@ +export { WeeklyReflectionSection } from './WeeklyReflectionSection'; diff --git a/src/renderer/features/planner/components/WeeklyReflectionSection/useWeeklyReflectionSection.ts b/src/renderer/features/planner/components/WeeklyReflectionSection/useWeeklyReflectionSection.ts new file mode 100644 index 00000000..c0322aef --- /dev/null +++ b/src/renderer/features/planner/components/WeeklyReflectionSection/useWeeklyReflectionSection.ts @@ -0,0 +1,33 @@ +/** + * useWeeklyReflectionSection — logic for WeeklyReflectionSection + */ + +import { useState } from 'react'; + +import { useUpdateWeeklyReflection } from '../../api/useWeeklyReview'; + +export function useWeeklyReflectionSection(weekStart: string, reflection?: string) { + const [isEditing, setIsEditing] = useState(false); + const [reflectionText, setReflectionText] = useState(''); + const updateReflection = useUpdateWeeklyReflection(); + + function handleStartEdit() { + setReflectionText(reflection ?? ''); + setIsEditing(true); + } + + function handleSave() { + updateReflection.mutate({ startDate: weekStart, reflection: reflectionText }); + setIsEditing(false); + } + + return { + isEditing, + setIsEditing, + reflectionText, + setReflectionText, + updateReflection, + handleStartEdit, + handleSave, + }; +} diff --git a/src/renderer/features/personal/planner/components/WeeklyReviewPage.tsx b/src/renderer/features/planner/components/WeeklyReviewPage/WeeklyReviewPage.tsx similarity index 80% rename from src/renderer/features/personal/planner/components/WeeklyReviewPage.tsx rename to src/renderer/features/planner/components/WeeklyReviewPage/WeeklyReviewPage.tsx index 04040c9a..e947c232 100644 --- a/src/renderer/features/personal/planner/components/WeeklyReviewPage.tsx +++ b/src/renderer/features/planner/components/WeeklyReviewPage/WeeklyReviewPage.tsx @@ -2,8 +2,6 @@ * WeeklyReviewPage — Weekly planner data aggregation view */ -import { useState } from 'react'; - import { Link } from '@tanstack/react-router'; import { BarChart3, @@ -21,46 +19,28 @@ import { ROUTES } from '@shared/constants'; import { cn } from '@renderer/shared/lib/utils'; -import { Button, PageHeader, PageLayout } from '@ui'; - +import { Button, Heading, PageHeader, PageLayout } from '@ui'; -import { useWeeklyReview, useGenerateWeeklyReview } from '../api/useWeeklyReview'; +import { CategoryBar } from '../CategoryBar'; +import { DayCompact } from '../DayCompact'; +import { StatCard } from '../StatCard'; +import { formatWeekRange } from '../weekly-review-utils'; +import { WeeklyReflectionSection } from '../WeeklyReflectionSection'; -import { CategoryBar } from './CategoryBar'; -import { DayCompact } from './DayCompact'; -import { StatCard } from './StatCard'; -import { formatWeekRange, getWeekMonday } from './weekly-review-utils'; -import { WeeklyReflectionSection } from './WeeklyReflectionSection'; +import { useWeeklyReviewPage } from './useWeeklyReviewPage'; export function WeeklyReviewPage() { - const [weekStart, setWeekStart] = useState(() => - getWeekMonday(new Date().toISOString().slice(0, 10)), - ); - - const { data: review, isLoading } = useWeeklyReview(weekStart); - const generateReview = useGenerateWeeklyReview(); - - function handlePrevWeek() { - const date = new Date(`${weekStart}T00:00:00`); - date.setDate(date.getDate() - 7); - setWeekStart(date.toISOString().slice(0, 10)); - } - - function handleNextWeek() { - const date = new Date(`${weekStart}T00:00:00`); - date.setDate(date.getDate() + 7); - setWeekStart(date.toISOString().slice(0, 10)); - } - - function handleGoThisWeek() { - setWeekStart(getWeekMonday(new Date().toISOString().slice(0, 10))); - } - - function handleRefresh() { - generateReview.mutate(weekStart); - } - - const isThisWeek = weekStart === getWeekMonday(new Date().toISOString().slice(0, 10)); + const { + weekStart, + review, + isLoading, + generateReview, + isThisWeek, + handlePrevWeek, + handleNextWeek, + handleGoThisWeek, + handleRefresh, + } = useWeeklyReviewPage(); if (isLoading) { return ( @@ -176,9 +156,9 @@ export function WeeklyReviewPage() { {/* Category breakdown */} <div className="bg-card border-border rounded-lg border p-4"> - <h2 className="text-foreground mb-4 text-sm font-semibold"> + <Heading as="h2" className="mb-4 text-sm"> Time Distribution by Category - </h2> + </Heading> <div className="space-y-3"> <CategoryBar colorClass="bg-primary" @@ -209,7 +189,7 @@ export function WeeklyReviewPage() { {/* Daily overview grid */} <div> - <h2 className="text-foreground mb-3 text-sm font-semibold">Daily Overview</h2> + <Heading as="h2" className="mb-3 text-sm">Daily Overview</Heading> <div className="grid grid-cols-7 gap-3"> {review?.days.map((day) => <DayCompact key={day.date} plan={day} />)} </div> diff --git a/src/renderer/features/planner/components/WeeklyReviewPage/index.ts b/src/renderer/features/planner/components/WeeklyReviewPage/index.ts new file mode 100644 index 00000000..88e95204 --- /dev/null +++ b/src/renderer/features/planner/components/WeeklyReviewPage/index.ts @@ -0,0 +1 @@ +export { WeeklyReviewPage } from './WeeklyReviewPage'; diff --git a/src/renderer/features/planner/components/WeeklyReviewPage/useWeeklyReviewPage.ts b/src/renderer/features/planner/components/WeeklyReviewPage/useWeeklyReviewPage.ts new file mode 100644 index 00000000..61fa9234 --- /dev/null +++ b/src/renderer/features/planner/components/WeeklyReviewPage/useWeeklyReviewPage.ts @@ -0,0 +1,47 @@ +/** + * useWeeklyReviewPage — logic for WeeklyReviewPage + */ + +import { useState } from 'react'; + +import { useToday } from '@renderer/shared/hooks/useToday'; + +import { useGenerateWeeklyReview, useWeeklyReview } from '../../api/useWeeklyReview'; +import { useWeekDayNavigation } from '../../hooks/useWeekDayNavigation'; +import { getWeekMonday } from '../weekly-review-utils'; + +export function useWeeklyReviewPage() { + const today = useToday(); + + const [weekStart, setWeekStart] = useState(() => getWeekMonday(today)); + + const { data: review, isLoading } = useWeeklyReview(weekStart); + const generateReview = useGenerateWeeklyReview(); + + const { handlePrevWeek, handleNextWeek } = useWeekDayNavigation({ + currentDate: weekStart, + onDateChange: setWeekStart, + }); + + function handleGoThisWeek() { + setWeekStart(getWeekMonday(today)); + } + + function handleRefresh() { + generateReview.mutate(weekStart); + } + + const isThisWeek = weekStart === getWeekMonday(today); + + return { + weekStart, + review, + isLoading, + generateReview, + isThisWeek, + handlePrevWeek, + handleNextWeek, + handleGoThisWeek, + handleRefresh, + }; +} diff --git a/src/renderer/features/personal/planner/components/weekly-review-utils.ts b/src/renderer/features/planner/components/weekly-review-utils.ts similarity index 100% rename from src/renderer/features/personal/planner/components/weekly-review-utils.ts rename to src/renderer/features/planner/components/weekly-review-utils.ts diff --git a/src/renderer/features/personal/planner/hooks/usePlannerEvents.ts b/src/renderer/features/planner/hooks/usePlannerEvents.ts similarity index 100% rename from src/renderer/features/personal/planner/hooks/usePlannerEvents.ts rename to src/renderer/features/planner/hooks/usePlannerEvents.ts diff --git a/src/renderer/features/planner/hooks/useWeekDayNavigation.ts b/src/renderer/features/planner/hooks/useWeekDayNavigation.ts new file mode 100644 index 00000000..06c22c11 --- /dev/null +++ b/src/renderer/features/planner/hooks/useWeekDayNavigation.ts @@ -0,0 +1,44 @@ +/** + * useWeekDayNavigation — shared date navigation (prev/next week) for planner views. + * + * Extracts the identical prev-week / next-week date arithmetic + * from WeekOverview and WeeklyReviewPage. + */ + +interface UseWeekDayNavigationOptions { + /** Current selected or start date (YYYY-MM-DD) */ + currentDate: string; + /** Callback to update the date */ + onDateChange: (date: string) => void; +} + +function shiftDays(dateStr: string, days: number): string { + const d = new Date(`${dateStr}T00:00:00`); + d.setDate(d.getDate() + days); + return d.toISOString().slice(0, 10); +} + +export function useWeekDayNavigation({ currentDate, onDateChange }: UseWeekDayNavigationOptions) { + function handlePrevWeek(): void { + onDateChange(shiftDays(currentDate, -7)); + } + + function handleNextWeek(): void { + onDateChange(shiftDays(currentDate, 7)); + } + + function handlePrevDay(): void { + onDateChange(shiftDays(currentDate, -1)); + } + + function handleNextDay(): void { + onDateChange(shiftDays(currentDate, 1)); + } + + return { + handlePrevWeek, + handleNextWeek, + handlePrevDay, + handleNextDay, + }; +} diff --git a/src/renderer/features/personal/planner/index.ts b/src/renderer/features/planner/index.ts similarity index 100% rename from src/renderer/features/personal/planner/index.ts rename to src/renderer/features/planner/index.ts diff --git a/src/renderer/features/personal/planner/store.ts b/src/renderer/features/planner/store.ts similarity index 100% rename from src/renderer/features/personal/planner/store.ts rename to src/renderer/features/planner/store.ts diff --git a/src/renderer/features/planning/components/PlanningPage.tsx b/src/renderer/features/planning/components/PlanningPage.tsx index f908a9f6..5777644a 100644 --- a/src/renderer/features/planning/components/PlanningPage.tsx +++ b/src/renderer/features/planning/components/PlanningPage.tsx @@ -1,24 +1,22 @@ /** * PlanningPage — Consolidated planning tools with tabbed navigation * - * Combines Roadmap, Ideation, and Insights + * Combines Ideation and Insights * into a single tabbed view. All tools are project-scoped. */ -import { BarChart3, Lightbulb, Map } from 'lucide-react'; +import { BarChart3, Lightbulb } from 'lucide-react'; import { PageContent, PageHeader, PageLayout } from '@ui'; -import { IdeationPage } from '@features/ideation'; +import { IdeationPage } from '@features/ideas'; import { InsightsPage } from '@features/insights'; -import { RoadmapPage } from '@features/roadmap'; import { usePlanningStore } from '../store'; import type { PlanningTab } from '../store'; -const TABS: Array<{ id: PlanningTab; label: string; icon: typeof Map }> = [ - { id: 'roadmap', label: 'Roadmap', icon: Map }, +const TABS: Array<{ id: PlanningTab; label: string; icon: typeof BarChart3 }> = [ { id: 'ideation', label: 'Ideation', icon: Lightbulb }, { id: 'insights', label: 'Insights', icon: BarChart3 }, ]; @@ -34,7 +32,7 @@ export function PlanningPage() { > <PageHeader> <PageHeader.Row> - <PageHeader.Title description="Roadmap, ideas, and project analytics"> + <PageHeader.Title description="Ideas and project analytics"> Planning </PageHeader.Title> </PageHeader.Row> @@ -48,9 +46,6 @@ export function PlanningPage() { </PageHeader.TabList> </PageHeader> <PageContent className="p-0"> - <PageHeader.TabContent value="roadmap"> - <RoadmapPage /> - </PageHeader.TabContent> <PageHeader.TabContent value="ideation"> <IdeationPage /> </PageHeader.TabContent> diff --git a/src/renderer/features/planning/store.ts b/src/renderer/features/planning/store.ts index 26bcb9e4..b45ad18c 100644 --- a/src/renderer/features/planning/store.ts +++ b/src/renderer/features/planning/store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -type PlanningTab = 'roadmap' | 'ideation' | 'insights'; +type PlanningTab = 'ideation' | 'insights'; interface PlanningState { activeTab: PlanningTab; @@ -8,7 +8,7 @@ interface PlanningState { } export const usePlanningStore = create<PlanningState>()((set) => ({ - activeTab: 'roadmap', + activeTab: 'ideation', setActiveTab: (tab) => set({ activeTab: tab }), })); diff --git a/src/renderer/features/productivity/api/useCalendar.ts b/src/renderer/features/productivity/api/useCalendar.ts index d549ce16..58d9d340 100644 --- a/src/renderer/features/productivity/api/useCalendar.ts +++ b/src/renderer/features/productivity/api/useCalendar.ts @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { CALENDAR } from '@shared/ipc/misc/calendar.channels'; +import { CALENDAR } from '@shared/ipc/calendar'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/productivity/components/CalendarWidget.tsx b/src/renderer/features/productivity/components/CalendarWidget/CalendarWidget.tsx similarity index 85% rename from src/renderer/features/productivity/components/CalendarWidget.tsx rename to src/renderer/features/productivity/components/CalendarWidget/CalendarWidget.tsx index b1d48827..30715afb 100644 --- a/src/renderer/features/productivity/components/CalendarWidget.tsx +++ b/src/renderer/features/productivity/components/CalendarWidget/CalendarWidget.tsx @@ -2,28 +2,16 @@ * CalendarWidget — Today's schedule from Google Calendar */ -import { useMemo } from 'react'; - import { Calendar, Clock, MapPin, Trash2 } from 'lucide-react'; import { IntegrationRequired } from '@renderer/shared/components/IntegrationRequired'; -import { Button, Card, CardContent } from '@ui'; +import { Button, Card, CardContent, Heading } from '@ui'; -import { useCalendarDeleteEvent, useCalendarEvents } from '../api/useCalendar'; +import { useCalendarWidget } from './useCalendarWidget'; // ── Helpers ────────────────────────────────────────────────── -function getTodayRange(): { timeMin: string; timeMax: string } { - const now = new Date(); - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const end = new Date(start.getTime() + 86_400_000); - return { - timeMin: start.toISOString(), - timeMax: end.toISOString(), - }; -} - function formatTime(dateString: string | undefined): string { if (!dateString) { return 'All day'; @@ -46,9 +34,7 @@ function isCurrentEvent(start: string | undefined, end: string | undefined): boo // ── Component ──────────────────────────────────────────────── export function CalendarWidget() { - const { timeMin, timeMax } = useMemo(() => getTodayRange(), []); - const { data: events, isLoading } = useCalendarEvents(timeMin, timeMax); - const deleteMutation = useCalendarDeleteEvent(); + const { events, isLoading, deleteMutation } = useCalendarWidget(); return ( <Card> @@ -60,7 +46,7 @@ export function CalendarWidget() { /> <div className="flex items-center gap-2"> <Calendar className="text-primary h-4 w-4" /> - <h3 className="text-foreground text-sm font-semibold">Today's Schedule</h3> + <Heading as="h3" className="text-sm">Today's Schedule</Heading> <span className="text-muted-foreground text-xs"> {new Date().toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' })} </span> diff --git a/src/renderer/features/productivity/components/CalendarWidget/index.ts b/src/renderer/features/productivity/components/CalendarWidget/index.ts new file mode 100644 index 00000000..37da0a11 --- /dev/null +++ b/src/renderer/features/productivity/components/CalendarWidget/index.ts @@ -0,0 +1 @@ +export { CalendarWidget } from './CalendarWidget'; diff --git a/src/renderer/features/productivity/components/CalendarWidget/useCalendarWidget.ts b/src/renderer/features/productivity/components/CalendarWidget/useCalendarWidget.ts new file mode 100644 index 00000000..cd018ef8 --- /dev/null +++ b/src/renderer/features/productivity/components/CalendarWidget/useCalendarWidget.ts @@ -0,0 +1,29 @@ +/** + * useCalendarWidget — logic for CalendarWidget + */ + +import { useMemo } from 'react'; + +import { useCalendarDeleteEvent, useCalendarEvents } from '../../api/useCalendar'; + +function getTodayRange(): { timeMin: string; timeMax: string } { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const end = new Date(start.getTime() + 86_400_000); + return { + timeMin: start.toISOString(), + timeMax: end.toISOString(), + }; +} + +export function useCalendarWidget() { + const { timeMin, timeMax } = useMemo(() => getTodayRange(), []); + const { data: events, isLoading } = useCalendarEvents(timeMin, timeMax); + const deleteMutation = useCalendarDeleteEvent(); + + return { + events, + isLoading, + deleteMutation, + }; +} diff --git a/src/renderer/features/productivity/components/ProductivityPage.tsx b/src/renderer/features/productivity/components/ProductivityPage.tsx index bd4886fb..72276e6c 100644 --- a/src/renderer/features/productivity/components/ProductivityPage.tsx +++ b/src/renderer/features/productivity/components/ProductivityPage.tsx @@ -6,8 +6,11 @@ import { Bell, Calendar, CalendarDays, Globe, Headphones, LayoutGrid, Newspaper, import { PageContent, PageHeader, PageLayout } from '@ui'; +import { AlertsPage } from '@features/alerts'; +import { BriefingPage } from '@features/briefing'; import { IntegrationsPage } from '@features/integrations'; -import { AlertsPage, BriefingPage, NotesPage, PlannerPage } from '@features/personal'; +import { NotesPage } from '@features/notes'; +import { PlannerPage } from '@features/planner'; import { useProductivityStore } from '../store'; diff --git a/src/renderer/features/productivity/components/SpotifyWidget.tsx b/src/renderer/features/productivity/components/SpotifyWidget/SpotifyWidget.tsx similarity index 82% rename from src/renderer/features/productivity/components/SpotifyWidget.tsx rename to src/renderer/features/productivity/components/SpotifyWidget/SpotifyWidget.tsx index ad3fc331..8bf1aa6b 100644 --- a/src/renderer/features/productivity/components/SpotifyWidget.tsx +++ b/src/renderer/features/productivity/components/SpotifyWidget/SpotifyWidget.tsx @@ -2,32 +2,21 @@ * SpotifyWidget — Now playing display with playback controls */ -import { useState } from 'react'; - import { Music, Pause, Play, SkipBack, SkipForward, Volume2 } from 'lucide-react'; import { IntegrationRequired } from '@renderer/shared/components/IntegrationRequired'; import { cn } from '@renderer/shared/lib/utils'; -import { Button, Card, CardContent, SearchInput, Slider } from '@ui'; +import { Button, Card, CardContent, Heading, SearchInput, Slider } from '@ui'; -import { - useSpotifyAddToQueue, - useSpotifyNext, - useSpotifyPause, - useSpotifyPlay, - useSpotifyPlayback, - useSpotifyPrevious, - useSpotifySearch, - useSpotifyVolume, -} from '../api/useSpotify'; +import { useSpotifyWidget } from './useSpotifyWidget'; // ── Constants ──────────────────────────────────────────────── const CONTROL_BUTTON = 'rounded-full p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'; -// ── Sub-components ─────────────────────────────────────────── +// ── Helpers ────────────────────────────────────────────────── function formatDuration(ms: number): string { const minutes = Math.floor(ms / 60_000); @@ -35,6 +24,8 @@ function formatDuration(ms: number): string { return `${String(minutes)}:${String(seconds).padStart(2, '0')}`; } +// ── NowPlaying Sub-component ───────────────────────────────── + interface NowPlayingProps { track: string; artist: string; @@ -43,6 +34,11 @@ interface NowPlayingProps { progressMs: number; durationMs: number; volume: number; + onPlay: () => void; + onPause: () => void; + onNext: () => void; + onPrevious: () => void; + onVolumeChange: (volume: number) => void; } function NowPlaying({ @@ -53,13 +49,12 @@ function NowPlaying({ progressMs, durationMs, volume, + onPlay, + onPause, + onNext, + onPrevious, + onVolumeChange, }: NowPlayingProps) { - const playMutation = useSpotifyPlay(); - const pauseMutation = useSpotifyPause(); - const nextMutation = useSpotifyNext(); - const previousMutation = useSpotifyPrevious(); - const volumeMutation = useSpotifyVolume(); - const progressPercent = durationMs > 0 ? (progressMs / durationMs) * 100 : 0; return ( @@ -98,9 +93,7 @@ function NowPlaying({ title="Previous" type="button" variant="ghost" - onClick={() => { - previousMutation.mutate(); - }} + onClick={onPrevious} > <SkipBack className="h-4 w-4" /> </Button> @@ -113,13 +106,7 @@ function NowPlaying({ CONTROL_BUTTON, 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground', )} - onClick={() => { - if (isPlaying) { - pauseMutation.mutate(); - } else { - playMutation.mutate({}); - } - }} + onClick={isPlaying ? onPause : onPlay} > {isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />} </Button> @@ -129,9 +116,7 @@ function NowPlaying({ title="Next" type="button" variant="ghost" - onClick={() => { - nextMutation.mutate(); - }} + onClick={onNext} > <SkipForward className="h-4 w-4" /> </Button> @@ -146,7 +131,7 @@ function NowPlaying({ min={0} value={[volume]} onValueChange={(values) => { - volumeMutation.mutate(values[0] ?? volume); + onVolumeChange(values[0] ?? volume); }} /> <span className="text-muted-foreground w-8 text-right text-xs">{String(volume)}%</span> @@ -158,11 +143,20 @@ function NowPlaying({ // ── Main Component ─────────────────────────────────────────── export function SpotifyWidget() { - const { data: playback, isLoading } = useSpotifyPlayback(); - const [searchQuery, setSearchQuery] = useState(''); - const { data: searchResults } = useSpotifySearch(searchQuery); - const playMutation = useSpotifyPlay(); - const addToQueueMutation = useSpotifyAddToQueue(); + const { + playback, + isLoading, + searchQuery, + setSearchQuery, + searchResults, + playMutation, + addToQueueMutation, + handlePlay, + handlePause, + handleNext, + handlePrevious, + handleVolumeChange, + } = useSpotifyWidget(); return ( <Card> @@ -172,7 +166,7 @@ export function SpotifyWidget() { provider="spotify" title="Connect Spotify" /> - <h3 className="text-foreground text-sm font-semibold">Spotify</h3> + <Heading as="h3" className="text-sm">Spotify</Heading> {/* Now Playing */} {isLoading ? <p className="text-muted-foreground text-sm">Loading playback...</p> : null} @@ -186,6 +180,11 @@ export function SpotifyWidget() { progressMs={playback.progressMs ?? 0} track={playback.track ?? 'No track'} volume={playback.volume ?? 50} + onNext={handleNext} + onPause={handlePause} + onPlay={() => handlePlay()} + onPrevious={handlePrevious} + onVolumeChange={handleVolumeChange} /> ) : null} diff --git a/src/renderer/features/productivity/components/SpotifyWidget/index.ts b/src/renderer/features/productivity/components/SpotifyWidget/index.ts new file mode 100644 index 00000000..5db6cfa2 --- /dev/null +++ b/src/renderer/features/productivity/components/SpotifyWidget/index.ts @@ -0,0 +1 @@ +export { SpotifyWidget } from './SpotifyWidget'; diff --git a/src/renderer/features/productivity/components/SpotifyWidget/useSpotifyWidget.ts b/src/renderer/features/productivity/components/SpotifyWidget/useSpotifyWidget.ts new file mode 100644 index 00000000..52761c90 --- /dev/null +++ b/src/renderer/features/productivity/components/SpotifyWidget/useSpotifyWidget.ts @@ -0,0 +1,71 @@ +/** + * useSpotifyWidget — logic for SpotifyWidget + */ + +import { useCallback, useState } from 'react'; + +import { + useSpotifyAddToQueue, + useSpotifyNext, + useSpotifyPause, + useSpotifyPlay, + useSpotifyPlayback, + useSpotifyPrevious, + useSpotifySearch, + useSpotifyVolume, +} from '../../api/useSpotify'; + +export function useSpotifyWidget() { + const { data: playback, isLoading } = useSpotifyPlayback(); + const [searchQuery, setSearchQuery] = useState(''); + const { data: searchResults } = useSpotifySearch(searchQuery); + const playMutation = useSpotifyPlay(); + const addToQueueMutation = useSpotifyAddToQueue(); + + // NowPlaying controls + const pauseMutation = useSpotifyPause(); + const nextMutation = useSpotifyNext(); + const previousMutation = useSpotifyPrevious(); + const volumeMutation = useSpotifyVolume(); + + const handlePlay = useCallback( + (uri?: string) => { + playMutation.mutate(uri ? { uri } : {}); + }, + [playMutation], + ); + + const handlePause = useCallback(() => { + pauseMutation.mutate(); + }, [pauseMutation]); + + const handleNext = useCallback(() => { + nextMutation.mutate(); + }, [nextMutation]); + + const handlePrevious = useCallback(() => { + previousMutation.mutate(); + }, [previousMutation]); + + const handleVolumeChange = useCallback( + (volume: number) => { + volumeMutation.mutate(volume); + }, + [volumeMutation], + ); + + return { + playback, + isLoading, + searchQuery, + setSearchQuery, + searchResults, + playMutation, + addToQueueMutation, + handlePlay, + handlePause, + handleNext, + handlePrevious, + handleVolumeChange, + }; +} diff --git a/src/renderer/features/projects/api/useGit.ts b/src/renderer/features/projects/api/useGit.ts index 8552dee5..d1ea67c6 100644 --- a/src/renderer/features/projects/api/useGit.ts +++ b/src/renderer/features/projects/api/useGit.ts @@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { GIT } from '@shared/ipc/git/channels'; -import { MERGE } from '@shared/ipc/misc/merge.channels'; +import { MERGE } from '@shared/ipc/merge'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/projects/components/BranchSelector.tsx b/src/renderer/features/projects/components/BranchSelector.tsx index 561f0087..4d58275f 100644 --- a/src/renderer/features/projects/components/BranchSelector.tsx +++ b/src/renderer/features/projects/components/BranchSelector.tsx @@ -1,127 +1 @@ -/** - * BranchSelector — Displays and selects git branches for a project - */ - -import { useState } from 'react'; - -import { ChevronDown, GitBranch, Plus } from 'lucide-react'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { - Button, - Input, - Popover, - PopoverContent, - PopoverTrigger, - Spinner, -} from '@ui'; - -import { useGitBranches, useGitStatus, useCreateBranch } from '../api/useGit'; - -interface BranchSelectorProps { - repoPath: string; -} - -export function BranchSelector({ repoPath }: BranchSelectorProps) { - const { data: status, isLoading: statusLoading } = useGitStatus(repoPath); - const { data: branches, isLoading: branchesLoading } = useGitBranches(repoPath); - const createBranch = useCreateBranch(); - - const [isOpen, setIsOpen] = useState(false); - const [showNewBranch, setShowNewBranch] = useState(false); - const [newBranchName, setNewBranchName] = useState(''); - - const isLoading = statusLoading || branchesLoading; - - async function handleCreateBranch() { - if (!newBranchName) return; - await createBranch.mutateAsync({ repoPath, branchName: newBranchName }); - setNewBranchName(''); - setShowNewBranch(false); - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter') { - void handleCreateBranch(); - } - } - - if (isLoading) { - return ( - <div className="flex items-center gap-2 text-sm"> - <Spinner className="text-muted-foreground" size="sm" /> - <span className="text-muted-foreground">Loading...</span> - </div> - ); - } - - return ( - <Popover open={isOpen} onOpenChange={setIsOpen}> - <PopoverTrigger asChild> - <Button className="gap-2" size="sm" variant="outline"> - <GitBranch className="h-3.5 w-3.5" /> - <span className="font-medium">{status?.branch ?? 'unknown'}</span> - {status ? ( - <span className="text-muted-foreground text-xs"> - {status.isClean ? '' : `${String(status.modified.length)} modified`} - </span> - ) : null} - <ChevronDown className="h-3 w-3" /> - </Button> - </PopoverTrigger> - - <PopoverContent align="start" className="w-[200px] p-0"> - <div className="max-h-60 overflow-y-auto p-1"> - {branches?.map((b) => ( - <div - key={b.name} - className={cn( - 'flex items-center gap-2 rounded px-3 py-1.5 text-sm', - b.current ? 'bg-accent font-medium' : 'hover:bg-accent/50 cursor-pointer', - )} - > - <GitBranch className="h-3 w-3" /> - <span>{b.name}</span> - {b.current ? ( - <span className="text-muted-foreground ml-auto text-xs">current</span> - ) : null} - </div> - ))} - </div> - - <div className="border-border border-t p-1"> - {showNewBranch ? ( - <div className="flex items-center gap-1 px-2 py-1"> - <Input - className="h-6 flex-1 rounded border px-2 py-0.5 text-xs" - placeholder="new-branch" - value={newBranchName} - onChange={(e) => setNewBranchName(e.target.value)} - onKeyDown={handleKeyDown} - /> - <Button - className="h-6 px-2 py-0.5 text-xs" - disabled={!newBranchName || createBranch.isPending} - size="sm" - onClick={handleCreateBranch} - > - Create - </Button> - </div> - ) : ( - <Button - className="w-full justify-start gap-2" - size="sm" - variant="ghost" - onClick={() => setShowNewBranch(true)} - > - <Plus className="h-3 w-3" /> - New branch - </Button> - )} - </div> - </PopoverContent> - </Popover> - ); -} +export { BranchSelector } from './BranchSelector/index'; diff --git a/src/renderer/features/projects/components/BranchSelector/BranchSelector.tsx b/src/renderer/features/projects/components/BranchSelector/BranchSelector.tsx new file mode 100644 index 00000000..ff48d5ab --- /dev/null +++ b/src/renderer/features/projects/components/BranchSelector/BranchSelector.tsx @@ -0,0 +1,117 @@ +/** + * BranchSelector — Displays and selects git branches for a project + */ + +import { ChevronDown, GitBranch, Plus } from 'lucide-react'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { + Button, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, +} from '@ui'; + +import { useBranchSelector } from './useBranchSelector'; + +interface BranchSelectorProps { + repoPath: string; +} + +export function BranchSelector({ repoPath }: BranchSelectorProps) { + const { + status, + branches, + isLoading, + isOpen, + showNewBranch, + newBranchName, + createBranch, + setIsOpen, + setShowNewBranch, + setNewBranchName, + handleCreateBranch, + handleKeyDown, + } = useBranchSelector({ repoPath }); + + if (isLoading) { + return ( + <div className="flex items-center gap-2 text-sm"> + <Spinner className="text-muted-foreground" size="sm" /> + <span className="text-muted-foreground">Loading...</span> + </div> + ); + } + + return ( + <Popover open={isOpen} onOpenChange={setIsOpen}> + <PopoverTrigger asChild> + <Button className="gap-2" size="sm" variant="outline"> + <GitBranch className="h-3.5 w-3.5" /> + <span className="font-medium">{status?.branch ?? 'unknown'}</span> + {status ? ( + <span className="text-muted-foreground text-xs"> + {status.isClean ? '' : `${String(status.modified.length)} modified`} + </span> + ) : null} + <ChevronDown className="h-3 w-3" /> + </Button> + </PopoverTrigger> + + <PopoverContent align="start" className="w-[200px] p-0"> + <div className="max-h-60 overflow-y-auto p-1"> + {branches?.map((b) => ( + <div + key={b.name} + className={cn( + 'flex items-center gap-2 rounded px-3 py-1.5 text-sm', + b.current ? 'bg-accent font-medium' : 'hover:bg-accent/50 cursor-pointer', + )} + > + <GitBranch className="h-3 w-3" /> + <span>{b.name}</span> + {b.current ? ( + <span className="text-muted-foreground ml-auto text-xs">current</span> + ) : null} + </div> + ))} + </div> + + <div className="border-border border-t p-1"> + {showNewBranch ? ( + <div className="flex items-center gap-1 px-2 py-1"> + <Input + className="h-6 flex-1 rounded border px-2 py-0.5 text-xs" + placeholder="new-branch" + value={newBranchName} + onChange={(e) => setNewBranchName(e.target.value)} + onKeyDown={handleKeyDown} + /> + <Button + className="h-6 px-2 py-0.5 text-xs" + disabled={!newBranchName || createBranch.isPending} + size="sm" + onClick={handleCreateBranch} + > + Create + </Button> + </div> + ) : ( + <Button + className="w-full justify-start gap-2" + size="sm" + variant="ghost" + onClick={() => setShowNewBranch(true)} + > + <Plus className="h-3 w-3" /> + New branch + </Button> + )} + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/src/renderer/features/projects/components/BranchSelector/index.ts b/src/renderer/features/projects/components/BranchSelector/index.ts new file mode 100644 index 00000000..2b674c49 --- /dev/null +++ b/src/renderer/features/projects/components/BranchSelector/index.ts @@ -0,0 +1,2 @@ +export { BranchSelector } from './BranchSelector'; +export { useBranchSelector } from './useBranchSelector'; diff --git a/src/renderer/features/projects/components/BranchSelector/useBranchSelector.ts b/src/renderer/features/projects/components/BranchSelector/useBranchSelector.ts new file mode 100644 index 00000000..b06997b6 --- /dev/null +++ b/src/renderer/features/projects/components/BranchSelector/useBranchSelector.ts @@ -0,0 +1,47 @@ +import { useState } from 'react'; + +import { useGitBranches, useGitStatus, useCreateBranch } from '../../api/useGit'; + +interface UseBranchSelectorProps { + repoPath: string; +} + +export function useBranchSelector({ repoPath }: UseBranchSelectorProps) { + const { data: status, isLoading: statusLoading } = useGitStatus(repoPath); + const { data: branches, isLoading: branchesLoading } = useGitBranches(repoPath); + const createBranch = useCreateBranch(); + + const [isOpen, setIsOpen] = useState(false); + const [showNewBranch, setShowNewBranch] = useState(false); + const [newBranchName, setNewBranchName] = useState(''); + + const isLoading = statusLoading || branchesLoading; + + async function handleCreateBranch() { + if (!newBranchName) return; + await createBranch.mutateAsync({ repoPath, branchName: newBranchName }); + setNewBranchName(''); + setShowNewBranch(false); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + void handleCreateBranch(); + } + } + + return { + status, + branches, + isLoading, + isOpen, + showNewBranch, + newBranchName, + createBranch, + setIsOpen, + setShowNewBranch, + setNewBranchName, + handleCreateBranch, + handleKeyDown, + }; +} diff --git a/src/renderer/features/projects/components/CreateProjectWizard.tsx b/src/renderer/features/projects/components/CreateProjectWizard.tsx index bc6c9dd4..9b986607 100644 --- a/src/renderer/features/projects/components/CreateProjectWizard.tsx +++ b/src/renderer/features/projects/components/CreateProjectWizard.tsx @@ -1,287 +1 @@ -/** - * CreateProjectWizard — Multi-step wizard for creating a new project from scratch - * - * Steps: - * 0. Project details (name, description, folder) - * 1. Tech stack selection - * 2. GitHub repository settings - * 3. Review and create - */ - -import { useEffect, useState } from 'react'; - -import { useMutation } from '@tanstack/react-query'; -import { Check, ChevronLeft, ChevronRight, Loader2, X } from 'lucide-react'; - -import { PROJECTS } from '@shared/ipc/projects/channels'; -import type { CreateProjectInput } from '@shared/types/project-setup'; - -import { ipc } from '@renderer/shared/lib/ipc'; -import { cn } from '@renderer/shared/lib/utils'; - -import { Button } from '@ui'; - -import { StepDetails, StepGitHub, StepReview, StepTechStack } from './create-wizard-steps'; - -interface CreateProjectWizardProps { - open: boolean; - onClose: () => void; - onProjectCreated?: (projectId: string) => void; -} - -interface WizardState { - name: string; - description: string; - path: string; - techStack: string[]; - createGitHubRepo: boolean; - githubVisibility: 'public' | 'private'; -} - -const STEP_LABELS = ['Details', 'Tech Stack', 'GitHub', 'Review'] as const; -const TOTAL_STEPS = STEP_LABELS.length; - -function getStepIndicatorClass(currentStep: number, stepIndex: number): string { - if (currentStep === stepIndex) return 'bg-primary text-primary-foreground'; - if (currentStep > stepIndex) return 'bg-success text-success-foreground'; - return 'bg-muted text-muted-foreground'; -} - -function canProceed(step: number, state: WizardState): boolean { - if (step === 0) { - return state.name.trim().length > 0 && state.path.length > 0; - } - return true; -} - -export function CreateProjectWizard({ - open, - onClose, - onProjectCreated, -}: CreateProjectWizardProps) { - // 1. Hooks - const [step, setStep] = useState(0); - const [state, setState] = useState<WizardState>({ - name: '', - description: '', - path: '', - techStack: [], - createGitHubRepo: true, - githubVisibility: 'private', - }); - - const selectDirectory = useMutation({ - mutationFn: () => ipc(PROJECTS.SELECT.DIRECTORY, {}), - }); - - const createProject = useMutation({ - mutationFn: (input: CreateProjectInput) => ipc(PROJECTS.CREATE.NEW, input), - }); - - // 2. Derived state - const isLastStep = step === TOTAL_STEPS - 1; - const isFirstStep = step === 0; - - // 3. Event handlers - function handleNext() { - if (step < TOTAL_STEPS - 1) { - setStep(step + 1); - } - } - - function handleBack() { - if (step > 0) { - setStep(step - 1); - } - } - - async function handleSelectFolder() { - const result = await selectDirectory.mutateAsync(); - if (result.path) { - const folderName = result.path.split(/[\\/]/).pop() ?? ''; - setState((prev) => ({ - ...prev, - path: result.path ?? '', - name: prev.name.length === 0 ? folderName : prev.name, - })); - } - } - - async function handleCreate() { - const input: CreateProjectInput = { - name: state.name.trim(), - description: state.description.trim() || undefined, - path: state.path, - techStack: state.techStack.length > 0 ? state.techStack : undefined, - createGitHubRepo: state.createGitHubRepo, - githubVisibility: state.githubVisibility, - }; - - const project = await createProject.mutateAsync(input); - onProjectCreated?.(project.id); - } - - // Close on Escape key - useEffect(() => { - if (!open) return; - function handleKeyDown(event: KeyboardEvent) { - if (event.key === 'Escape') { - onClose(); - } - } - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [open, onClose]); - - // 4. Render - if (!open) return null; - - return ( - <div - aria-label="Create new project" - aria-modal="true" - className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" - role="dialog" - > - <div className="bg-card border-border mx-4 flex max-h-[90vh] w-full max-w-lg flex-col rounded-xl border shadow-xl"> - {/* Header */} - <div className="border-border flex items-center justify-between border-b px-6 py-4"> - <h2 className="text-lg font-semibold">Create New Project</h2> - <Button - aria-label="Close" - className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors" - size="icon" - type="button" - variant="ghost" - onClick={onClose} - > - <X className="h-5 w-5" /> - </Button> - </div> - - {/* Step Indicator */} - <div className="border-border flex items-center gap-2 border-b px-6 py-3"> - {STEP_LABELS.map((label, idx) => ( - <div key={label} className="flex items-center gap-2"> - {idx > 0 ? <div className="bg-border h-px w-4" /> : null} - <div - className={cn( - 'flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium', - getStepIndicatorClass(step, idx), - )} - > - {step > idx ? <Check className="h-3.5 w-3.5" /> : String(idx + 1)} - </div> - <span - className={cn( - 'text-xs', - step === idx ? 'text-foreground font-medium' : 'text-muted-foreground', - )} - > - {label} - </span> - </div> - ))} - </div> - - {/* Content */} - <div className="min-h-[280px] flex-1 overflow-y-auto px-6 py-5"> - {step === 0 ? ( - <StepDetails - description={state.description} - isSelectingFolder={selectDirectory.isPending} - name={state.name} - path={state.path} - onDescriptionChange={(description) => setState((prev) => ({ ...prev, description }))} - onNameChange={(name) => setState((prev) => ({ ...prev, name }))} - onSelectFolder={handleSelectFolder} - /> - ) : null} - - {step === 1 ? ( - <StepTechStack - techStack={state.techStack} - onTechStackChange={(techStack) => setState((prev) => ({ ...prev, techStack }))} - /> - ) : null} - - {step === 2 ? ( - <StepGitHub - createGitHubRepo={state.createGitHubRepo} - githubVisibility={state.githubVisibility} - onCreateRepoChange={(createGitHubRepo) => - setState((prev) => ({ ...prev, createGitHubRepo })) - } - onVisibilityChange={(githubVisibility) => - setState((prev) => ({ ...prev, githubVisibility })) - } - /> - ) : null} - - {step === 3 ? ( - <StepReview - createGitHubRepo={state.createGitHubRepo} - description={state.description} - githubVisibility={state.githubVisibility} - name={state.name} - path={state.path} - techStack={state.techStack} - /> - ) : null} - </div> - - {/* Footer */} - <div className="border-border flex items-center justify-between border-t px-6 py-4"> - <Button - disabled={isFirstStep} - type="button" - variant="ghost" - className={cn( - 'text-muted-foreground flex items-center gap-1 text-sm transition-colors', - 'hover:text-foreground disabled:invisible', - )} - onClick={handleBack} - > - <ChevronLeft className="h-4 w-4" /> - Back - </Button> - - {isLastStep ? ( - <Button - disabled={createProject.isPending || !canProceed(0, state)} - type="button" - className={cn( - 'bg-primary text-primary-foreground rounded-lg px-5 py-2 text-sm font-medium transition-colors', - 'hover:bg-primary/90 disabled:opacity-50', - )} - onClick={handleCreate} - > - {createProject.isPending ? ( - <span className="flex items-center gap-2"> - <Loader2 className="h-4 w-4 animate-spin" /> - Creating... - </span> - ) : ( - 'Create Project' - )} - </Button> - ) : ( - <Button - disabled={!canProceed(step, state)} - type="button" - variant="ghost" - className={cn( - 'text-primary flex items-center gap-1 text-sm font-medium transition-colors', - 'hover:text-primary/80 disabled:opacity-50', - )} - onClick={handleNext} - > - Next - <ChevronRight className="h-4 w-4" /> - </Button> - )} - </div> - </div> - </div> - ); -} +export { CreateProjectWizard } from './CreateProjectWizard/index'; diff --git a/src/renderer/features/projects/components/CreateProjectWizard/CreateProjectWizard.tsx b/src/renderer/features/projects/components/CreateProjectWizard/CreateProjectWizard.tsx new file mode 100644 index 00000000..b03ff41c --- /dev/null +++ b/src/renderer/features/projects/components/CreateProjectWizard/CreateProjectWizard.tsx @@ -0,0 +1,204 @@ +/** + * CreateProjectWizard — Multi-step wizard for creating a new project from scratch + * + * Steps: + * 0. Project details (name, description, folder) + * 1. Tech stack selection + * 2. GitHub repository settings + * 3. Review and create + */ + +import { Check, ChevronLeft, ChevronRight, Loader2, X } from 'lucide-react'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { Button, Heading } from '@ui'; + +import { StepDetails, StepGitHub, StepReview, StepTechStack } from '../create-wizard-steps'; + +import { useCreateProjectWizard } from './useCreateProjectWizard'; + +interface CreateProjectWizardProps { + open: boolean; + onClose: () => void; + onProjectCreated?: (projectId: string) => void; +} + +function getStepIndicatorClass(currentStep: number, stepIndex: number): string { + if (currentStep === stepIndex) return 'bg-primary text-primary-foreground'; + if (currentStep > stepIndex) return 'bg-success text-success-foreground'; + return 'bg-muted text-muted-foreground'; +} + +export function CreateProjectWizard({ + open, + onClose, + onProjectCreated, +}: CreateProjectWizardProps) { + const { + step, + state, + setState, + selectDirectory, + createProject, + isLastStep, + isFirstStep, + handleNext, + handleBack, + handleSelectFolder, + handleCreate, + canProceed, + STEP_LABELS, + } = useCreateProjectWizard({ open, onClose, onProjectCreated }); + + if (!open) return null; + + return ( + <div + aria-label="Create new project" + aria-modal="true" + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" + role="dialog" + > + <div className="bg-card border-border mx-4 flex max-h-[90vh] w-full max-w-lg flex-col rounded-xl border shadow-xl"> + {/* Header */} + <div className="border-border flex items-center justify-between border-b px-6 py-4"> + <Heading as="h2" className="text-lg">Create New Project</Heading> + <Button + aria-label="Close" + className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors" + size="icon" + type="button" + variant="ghost" + onClick={onClose} + > + <X className="h-5 w-5" /> + </Button> + </div> + + {/* Step Indicator */} + <div className="border-border flex items-center gap-2 border-b px-6 py-3"> + {STEP_LABELS.map((label, idx) => ( + <div key={label} className="flex items-center gap-2"> + {idx > 0 ? <div className="bg-border h-px w-4" /> : null} + <div + className={cn( + 'flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium', + getStepIndicatorClass(step, idx), + )} + > + {step > idx ? <Check className="h-3.5 w-3.5" /> : String(idx + 1)} + </div> + <span + className={cn( + 'text-xs', + step === idx ? 'text-foreground font-medium' : 'text-muted-foreground', + )} + > + {label} + </span> + </div> + ))} + </div> + + {/* Content */} + <div className="min-h-[280px] flex-1 overflow-y-auto px-6 py-5"> + {step === 0 ? ( + <StepDetails + description={state.description} + isSelectingFolder={selectDirectory.isPending} + name={state.name} + path={state.path} + onDescriptionChange={(description) => setState((prev) => ({ ...prev, description }))} + onNameChange={(name) => setState((prev) => ({ ...prev, name }))} + onSelectFolder={handleSelectFolder} + /> + ) : null} + + {step === 1 ? ( + <StepTechStack + techStack={state.techStack} + onTechStackChange={(techStack) => setState((prev) => ({ ...prev, techStack }))} + /> + ) : null} + + {step === 2 ? ( + <StepGitHub + createGitHubRepo={state.createGitHubRepo} + githubVisibility={state.githubVisibility} + onCreateRepoChange={(createGitHubRepo) => + setState((prev) => ({ ...prev, createGitHubRepo })) + } + onVisibilityChange={(githubVisibility) => + setState((prev) => ({ ...prev, githubVisibility })) + } + /> + ) : null} + + {step === 3 ? ( + <StepReview + createGitHubRepo={state.createGitHubRepo} + description={state.description} + githubVisibility={state.githubVisibility} + name={state.name} + path={state.path} + techStack={state.techStack} + /> + ) : null} + </div> + + {/* Footer */} + <div className="border-border flex items-center justify-between border-t px-6 py-4"> + <Button + disabled={isFirstStep} + type="button" + variant="ghost" + className={cn( + 'text-muted-foreground flex items-center gap-1 text-sm transition-colors', + 'hover:text-foreground disabled:invisible', + )} + onClick={handleBack} + > + <ChevronLeft className="h-4 w-4" /> + Back + </Button> + + {isLastStep ? ( + <Button + disabled={createProject.isPending || !canProceed(0, state)} + type="button" + className={cn( + 'bg-primary text-primary-foreground rounded-lg px-5 py-2 text-sm font-medium transition-colors', + 'hover:bg-primary/90 disabled:opacity-50', + )} + onClick={handleCreate} + > + {createProject.isPending ? ( + <span className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + Creating... + </span> + ) : ( + 'Create Project' + )} + </Button> + ) : ( + <Button + disabled={!canProceed(step, state)} + type="button" + variant="ghost" + className={cn( + 'text-primary flex items-center gap-1 text-sm font-medium transition-colors', + 'hover:text-primary/80 disabled:opacity-50', + )} + onClick={handleNext} + > + Next + <ChevronRight className="h-4 w-4" /> + </Button> + )} + </div> + </div> + </div> + ); +} diff --git a/src/renderer/features/projects/components/CreateProjectWizard/index.ts b/src/renderer/features/projects/components/CreateProjectWizard/index.ts new file mode 100644 index 00000000..b47d898b --- /dev/null +++ b/src/renderer/features/projects/components/CreateProjectWizard/index.ts @@ -0,0 +1,2 @@ +export { CreateProjectWizard } from './CreateProjectWizard'; +export { useCreateProjectWizard } from './useCreateProjectWizard'; diff --git a/src/renderer/features/projects/components/CreateProjectWizard/useCreateProjectWizard.ts b/src/renderer/features/projects/components/CreateProjectWizard/useCreateProjectWizard.ts new file mode 100644 index 00000000..4d32f78b --- /dev/null +++ b/src/renderer/features/projects/components/CreateProjectWizard/useCreateProjectWizard.ts @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; + +import { useMutation } from '@tanstack/react-query'; + +import { PROJECTS } from '@shared/ipc/projects/channels'; +import type { CreateProjectInput } from '@shared/types/project-setup'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { useWizardNavigation } from '../../hooks/useWizardNavigation'; + +const STEP_LABELS = ['Details', 'Tech Stack', 'GitHub', 'Review'] as const; +const TOTAL_STEPS = STEP_LABELS.length; + +interface WizardState { + name: string; + description: string; + path: string; + techStack: string[]; + createGitHubRepo: boolean; + githubVisibility: 'public' | 'private'; +} + +function canProceed(step: number, state: WizardState): boolean { + if (step === 0) { + return state.name.trim().length > 0 && state.path.length > 0; + } + return true; +} + +interface UseCreateProjectWizardProps { + open: boolean; + onClose: () => void; + onProjectCreated?: (projectId: string) => void; +} + +export function useCreateProjectWizard({ open, onClose, onProjectCreated }: UseCreateProjectWizardProps) { + const { step, setStep, handleNext, handleBack, isFirst: isFirstStep, isLast: isLastStep } = + useWizardNavigation(TOTAL_STEPS); + + const [state, setState] = useState<WizardState>({ + name: '', + description: '', + path: '', + techStack: [], + createGitHubRepo: true, + githubVisibility: 'private', + }); + + const selectDirectory = useMutation({ + mutationFn: () => ipc(PROJECTS.SELECT.DIRECTORY, {}), + }); + + const createProject = useMutation({ + mutationFn: (input: CreateProjectInput) => ipc(PROJECTS.CREATE.NEW, input), + }); + + async function handleSelectFolder() { + const result = await selectDirectory.mutateAsync(); + if (result.path) { + const folderName = result.path.split(/[\\/]/).pop() ?? ''; + setState((prev) => ({ + ...prev, + path: result.path ?? '', + name: prev.name.length === 0 ? folderName : prev.name, + })); + } + } + + async function handleCreate() { + const input: CreateProjectInput = { + name: state.name.trim(), + description: state.description.trim() || undefined, + path: state.path, + techStack: state.techStack.length > 0 ? state.techStack : undefined, + createGitHubRepo: state.createGitHubRepo, + githubVisibility: state.githubVisibility, + }; + + const project = await createProject.mutateAsync(input); + onProjectCreated?.(project.id); + } + + useEffect(() => { + if (!open) return; + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + onClose(); + } + } + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open, onClose]); + + return { + step, + setStep, + state, + setState, + selectDirectory, + createProject, + isLastStep, + isFirstStep, + handleNext, + handleBack, + handleSelectFolder, + handleCreate, + canProceed, + STEP_LABELS, + TOTAL_STEPS, + }; +} diff --git a/src/renderer/features/projects/components/ProjectEditDialog.tsx b/src/renderer/features/projects/components/ProjectEditDialog.tsx index d545f184..3df36104 100644 --- a/src/renderer/features/projects/components/ProjectEditDialog.tsx +++ b/src/renderer/features/projects/components/ProjectEditDialog.tsx @@ -1,273 +1 @@ -/** - * ProjectEditDialog — Modal for editing project details (name, description, branch, git URL, workspace). - * Includes delete functionality via ConfirmDialog. - */ - -import { useEffect, useState } from 'react'; - -import { Loader2, Pencil, Trash2 } from 'lucide-react'; - -import type { Project } from '@shared/types'; - -import { ConfirmDialog } from '@renderer/shared/components/ConfirmDialog'; - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Textarea, -} from '@ui'; - -import { useWorkspaces } from '@features/workspaces'; - -import { useRemoveProject, useUpdateProject } from '../api/useProjects'; - -interface ProjectEditDialogProps { - project: Project | null; - onClose: () => void; -} - -export function ProjectEditDialog({ project, onClose }: ProjectEditDialogProps) { - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [defaultBranch, setDefaultBranch] = useState(''); - const [gitUrl, setGitUrl] = useState(''); - const [workspaceId, setWorkspaceId] = useState(''); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [errorMessage, setErrorMessage] = useState<string | null>(null); - - const updateProject = useUpdateProject(); - const removeProject = useRemoveProject(); - const { data: workspaces } = useWorkspaces(); - - // Initialize form state when project changes - useEffect(() => { - if (project !== null) { - setName(project.name); - setDescription(project.description ?? ''); - setDefaultBranch(project.defaultBranch ?? ''); - setGitUrl(project.gitUrl ?? ''); - setWorkspaceId(project.workspaceId ?? ''); - setErrorMessage(null); - } - }, [project]); - - const nameIsEmpty = name.trim().length === 0; - - function handleSave() { - if (project === null || nameIsEmpty) { - return; - } - - setErrorMessage(null); - - // Only send changed fields - const updates: Record<string, string> = {}; - if (name.trim() !== project.name) { - updates.name = name.trim(); - } - if (description.trim() !== (project.description ?? '')) { - updates.description = description.trim(); - } - if (defaultBranch.trim() !== (project.defaultBranch ?? '')) { - updates.defaultBranch = defaultBranch.trim(); - } - if (gitUrl.trim() !== (project.gitUrl ?? '')) { - updates.gitUrl = gitUrl.trim(); - } - if (workspaceId !== (project.workspaceId ?? '')) { - updates.workspaceId = workspaceId; - } - - // If nothing changed, just close - if (Object.keys(updates).length === 0) { - onClose(); - return; - } - - updateProject.mutate( - { projectId: project.id, ...updates }, - { - onSuccess: () => { - onClose(); - }, - onError: (error) => { - setErrorMessage(error instanceof Error ? error.message : 'Failed to update project'); - }, - }, - ); - } - - function handleDeleteConfirm() { - if (project === null) { - return; - } - - removeProject.mutate(project.id, { - onSuccess: () => { - setDeleteConfirmOpen(false); - onClose(); - }, - onError: (error) => { - setErrorMessage( - error instanceof Error ? error.message : 'Failed to delete project', - ); - setDeleteConfirmOpen(false); - }, - }); - } - - return ( - <> - <Dialog open={project !== null} onOpenChange={(open) => { if (!open) onClose(); }}> - <DialogContent className="max-w-lg"> - <DialogHeader> - <DialogTitle className="flex items-center gap-3"> - <Pencil className="text-primary h-5 w-5" /> - Edit Project - </DialogTitle> - </DialogHeader> - - {/* Body */} - <div className="space-y-4 py-2"> - {/* Name */} - <div> - <Label htmlFor="edit-name"> - Name <span className="text-destructive">*</span> - </Label> - <Input - className="mt-1" - id="edit-name" - placeholder="Project name" - type="text" - value={name} - onChange={(e) => setName(e.target.value)} - /> - </div> - - {/* Description */} - <div> - <Label htmlFor="edit-description">Description</Label> - <Textarea - className="mt-1 resize-none" - id="edit-description" - placeholder="Optional project description" - rows={3} - value={description} - onChange={(e) => setDescription(e.target.value)} - /> - </div> - - {/* Default Branch */} - <div> - <Label htmlFor="edit-default-branch">Default Branch</Label> - <Input - className="mt-1" - id="edit-default-branch" - placeholder="main" - type="text" - value={defaultBranch} - onChange={(e) => setDefaultBranch(e.target.value)} - /> - </div> - - {/* Git URL */} - <div> - <Label htmlFor="edit-git-url">Git URL</Label> - <Input - className="mt-1" - id="edit-git-url" - placeholder="https://github.com/user/repo.git" - type="text" - value={gitUrl} - onChange={(e) => setGitUrl(e.target.value)} - /> - </div> - - {/* Workspace */} - {(workspaces?.length ?? 0) > 0 ? ( - <div> - <Label htmlFor="edit-workspace">Workspace</Label> - <Select value={workspaceId} onValueChange={setWorkspaceId}> - <SelectTrigger className="mt-1" id="edit-workspace"> - <SelectValue placeholder="No workspace" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="">No workspace</SelectItem> - {workspaces?.map((ws) => ( - <SelectItem key={ws.id} value={ws.id}> - {ws.name} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - ) : null} - </div> - - {/* Error message */} - {errorMessage === null ? null : ( - <div className="rounded-md bg-destructive/10 p-3"> - <p className="text-destructive text-sm">{errorMessage}</p> - </div> - )} - - <DialogFooter className="flex items-center justify-between sm:justify-between"> - {/* Delete button (left side) */} - <Button - className="text-destructive hover:bg-destructive/10 hover:text-destructive" - variant="ghost" - onClick={() => setDeleteConfirmOpen(true)} - > - <Trash2 className="h-4 w-4" /> - Delete Project - </Button> - - {/* Cancel + Save (right side) */} - <div className="flex gap-2"> - <Button variant="ghost" onClick={onClose}> - Cancel - </Button> - <Button - disabled={nameIsEmpty || updateProject.isPending} - onClick={handleSave} - > - {updateProject.isPending ? ( - <> - <Loader2 className="h-4 w-4 animate-spin" /> - Saving... - </> - ) : ( - 'Save' - )} - </Button> - </div> - </DialogFooter> - </DialogContent> - </Dialog> - - {/* Delete confirmation dialog */} - {project === null ? null : ( - <ConfirmDialog - confirmLabel="Delete" - description={`Are you sure you want to delete "${project.name}"? This action cannot be undone.`} - open={deleteConfirmOpen} - title="Delete Project" - variant="destructive" - onConfirm={handleDeleteConfirm} - onOpenChange={setDeleteConfirmOpen} - /> - )} - </> - ); -} +export { ProjectEditDialog } from './ProjectEditDialog/index'; diff --git a/src/renderer/features/projects/components/ProjectEditDialog/ProjectEditDialog.tsx b/src/renderer/features/projects/components/ProjectEditDialog/ProjectEditDialog.tsx new file mode 100644 index 00000000..b2412510 --- /dev/null +++ b/src/renderer/features/projects/components/ProjectEditDialog/ProjectEditDialog.tsx @@ -0,0 +1,204 @@ +/** + * ProjectEditDialog — Modal for editing project details (name, description, branch, git URL, workspace). + * Includes delete functionality via ConfirmDialog. + */ + +import { Loader2, Pencil, Trash2 } from 'lucide-react'; + +import type { Project } from '@shared/types'; + +import { ConfirmDialog } from '@renderer/shared/components/ConfirmDialog'; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from '@ui'; + +import { useProjectEditDialog } from './useProjectEditDialog'; + +interface ProjectEditDialogProps { + project: Project | null; + onClose: () => void; +} + +export function ProjectEditDialog({ project, onClose }: ProjectEditDialogProps) { + const { + name, + description, + defaultBranch, + gitUrl, + workspaceId, + deleteConfirmOpen, + errorMessage, + nameIsEmpty, + updateProject, + workspaces, + setName, + setDescription, + setDefaultBranch, + setGitUrl, + setWorkspaceId, + setDeleteConfirmOpen, + handleSave, + handleDeleteConfirm, + } = useProjectEditDialog({ project, onClose }); + + return ( + <> + <Dialog open={project !== null} onOpenChange={(open) => { if (!open) onClose(); }}> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle className="flex items-center gap-3"> + <Pencil className="text-primary h-5 w-5" /> + Edit Project + </DialogTitle> + </DialogHeader> + + {/* Body */} + <div className="space-y-4 py-2"> + {/* Name */} + <div> + <Label htmlFor="edit-name"> + Name <span className="text-destructive">*</span> + </Label> + <Input + className="mt-1" + id="edit-name" + placeholder="Project name" + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + /> + </div> + + {/* Description */} + <div> + <Label htmlFor="edit-description">Description</Label> + <Textarea + className="mt-1 resize-none" + id="edit-description" + placeholder="Optional project description" + rows={3} + value={description} + onChange={(e) => setDescription(e.target.value)} + /> + </div> + + {/* Default Branch */} + <div> + <Label htmlFor="edit-default-branch">Default Branch</Label> + <Input + className="mt-1" + id="edit-default-branch" + placeholder="main" + type="text" + value={defaultBranch} + onChange={(e) => setDefaultBranch(e.target.value)} + /> + </div> + + {/* Git URL */} + <div> + <Label htmlFor="edit-git-url">Git URL</Label> + <Input + className="mt-1" + id="edit-git-url" + placeholder="https://github.com/user/repo.git" + type="text" + value={gitUrl} + onChange={(e) => setGitUrl(e.target.value)} + /> + </div> + + {/* Workspace */} + {(workspaces?.length ?? 0) > 0 ? ( + <div> + <Label htmlFor="edit-workspace">Workspace</Label> + <Select + value={workspaceId === '' ? '__none__' : workspaceId} + onValueChange={(val) => setWorkspaceId(val === '__none__' ? '' : val)} + > + <SelectTrigger className="mt-1" id="edit-workspace"> + <SelectValue placeholder="No workspace" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__none__">No workspace</SelectItem> + {workspaces?.map((ws) => ( + <SelectItem key={ws.id} value={ws.id}> + {ws.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + ) : null} + </div> + + {/* Error message */} + {errorMessage === null ? null : ( + <div className="rounded-md bg-destructive/10 p-3"> + <p className="text-destructive text-sm">{errorMessage}</p> + </div> + )} + + <DialogFooter className="flex items-center justify-between sm:justify-between"> + {/* Delete button (left side) */} + <Button + className="text-destructive hover:bg-destructive/10 hover:text-destructive" + variant="ghost" + onClick={() => setDeleteConfirmOpen(true)} + > + <Trash2 className="h-4 w-4" /> + Delete Project + </Button> + + {/* Cancel + Save (right side) */} + <div className="flex gap-2"> + <Button variant="ghost" onClick={onClose}> + Cancel + </Button> + <Button + disabled={nameIsEmpty || updateProject.isPending} + onClick={handleSave} + > + {updateProject.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Saving... + </> + ) : ( + 'Save' + )} + </Button> + </div> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Delete confirmation dialog */} + {project === null ? null : ( + <ConfirmDialog + confirmLabel="Delete" + description={`Are you sure you want to delete "${project.name}"? This action cannot be undone.`} + open={deleteConfirmOpen} + title="Delete Project" + variant="destructive" + onConfirm={handleDeleteConfirm} + onOpenChange={setDeleteConfirmOpen} + /> + )} + </> + ); +} diff --git a/src/renderer/features/projects/components/ProjectEditDialog/index.ts b/src/renderer/features/projects/components/ProjectEditDialog/index.ts new file mode 100644 index 00000000..cbf07427 --- /dev/null +++ b/src/renderer/features/projects/components/ProjectEditDialog/index.ts @@ -0,0 +1,2 @@ +export { ProjectEditDialog } from './ProjectEditDialog'; +export { useProjectEditDialog } from './useProjectEditDialog'; diff --git a/src/renderer/features/projects/components/ProjectEditDialog/useProjectEditDialog.ts b/src/renderer/features/projects/components/ProjectEditDialog/useProjectEditDialog.ts new file mode 100644 index 00000000..584ad4e9 --- /dev/null +++ b/src/renderer/features/projects/components/ProjectEditDialog/useProjectEditDialog.ts @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react'; + +import type { Project } from '@shared/types'; + +import { useDialogWithMutation } from '@renderer/shared/hooks/useDialogWithMutation'; + +import { useWorkspaces } from '@features/workspace'; + +import { useRemoveProject, useUpdateProject } from '../../api/useProjects'; + +interface UseProjectEditDialogProps { + project: Project | null; + onClose: () => void; +} + +export function useProjectEditDialog({ project, onClose }: UseProjectEditDialogProps) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [defaultBranch, setDefaultBranch] = useState(''); + const [gitUrl, setGitUrl] = useState(''); + const [workspaceId, setWorkspaceId] = useState(''); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [errorMessage, setErrorMessage] = useState<string | null>(null); + + const updateProject = useUpdateProject(); + const removeProject = useRemoveProject(); + const { data: workspaces } = useWorkspaces(); + + const { handleSubmit: submitUpdate } = useDialogWithMutation(updateProject, { + onClose, + }); + + const { handleSubmit: submitDelete } = useDialogWithMutation(removeProject, { + onClose: () => { + setDeleteConfirmOpen(false); + onClose(); + }, + }); + + useEffect(() => { + if (project !== null) { + setName(project.name); + setDescription(project.description ?? ''); + setDefaultBranch(project.defaultBranch ?? ''); + setGitUrl(project.gitUrl ?? ''); + setWorkspaceId(project.workspaceId ?? ''); + setErrorMessage(null); + } + }, [project]); + + const nameIsEmpty = name.trim().length === 0; + + function handleSave() { + if (project === null || nameIsEmpty) { + return; + } + + setErrorMessage(null); + + const updates: Record<string, string> = {}; + if (name.trim() !== project.name) { + updates.name = name.trim(); + } + if (description.trim() !== (project.description ?? '')) { + updates.description = description.trim(); + } + if (defaultBranch.trim() !== (project.defaultBranch ?? '')) { + updates.defaultBranch = defaultBranch.trim(); + } + if (gitUrl.trim() !== (project.gitUrl ?? '')) { + updates.gitUrl = gitUrl.trim(); + } + if (workspaceId !== (project.workspaceId ?? '')) { + updates.workspaceId = workspaceId; + } + + if (Object.keys(updates).length === 0) { + onClose(); + return; + } + + submitUpdate({ projectId: project.id, ...updates }); + } + + function handleDeleteConfirm() { + if (project === null) { + return; + } + + submitDelete(project.id); + } + + return { + name, + description, + defaultBranch, + gitUrl, + workspaceId, + deleteConfirmOpen, + errorMessage, + nameIsEmpty, + updateProject, + removeProject, + workspaces, + setName, + setDescription, + setDefaultBranch, + setGitUrl, + setWorkspaceId, + setDeleteConfirmOpen, + handleSave, + handleDeleteConfirm, + }; +} diff --git a/src/renderer/features/projects/components/ProjectInitWizard.tsx b/src/renderer/features/projects/components/ProjectInitWizard.tsx index 9f4bee8a..d335d243 100644 --- a/src/renderer/features/projects/components/ProjectInitWizard.tsx +++ b/src/renderer/features/projects/components/ProjectInitWizard.tsx @@ -1,321 +1 @@ -/** - * ProjectInitWizard — Multi-step wizard for project initialization - * - * Steps: - * 1. Select folder - * 2. Show detection results - * 3. If multi-repo, show SubRepoSelector - * 4. Configure project settings - * 5. Confirm and create - */ - -import { useEffect, useState } from 'react'; - -import { useMutation, useQuery } from '@tanstack/react-query'; -import { Check, ChevronLeft, ChevronRight, Loader2, X } from 'lucide-react'; - -import { PROJECTS } from '@shared/ipc/projects/channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; -import { cn } from '@renderer/shared/lib/utils'; - -import { Button } from '@ui'; - -import { useWorkspaces } from '@features/workspaces'; - -import { useAddProject, useCreateSubProject, useSetupExisting } from '../api/useProjects'; - -import { - StepConfigure, - StepConfirm, - StepDetection, - StepFolder, - StepSubRepos, -} from './wizard-steps'; - -interface ProjectInitWizardProps { - onClose: () => void; - onSetupStarted: (projectId: string) => void; -} - -const STEP_LABELS = ['Select Folder', 'Detection', 'Sub-Repos', 'Configure', 'Confirm'] as const; - -function getVisibleSteps(hasChildRepos: boolean): number[] { - if (hasChildRepos) return [0, 1, 2, 3, 4]; - return [0, 1, 3, 4]; -} - -function getStepIndicatorClass(currentStep: number, stepIndex: number): string { - if (currentStep === stepIndex) return 'bg-primary text-primary-foreground'; - if (currentStep > stepIndex) return 'bg-success text-success-foreground'; - return 'bg-muted text-muted-foreground'; -} - -// ── Main Component ─────────────────────────────────────────── - -export function ProjectInitWizard({ onClose, onSetupStarted }: ProjectInitWizardProps) { - const [step, setStep] = useState(0); - const [selectedPath, setSelectedPath] = useState<string | null>(null); - const [repoType, setRepoType] = useState('single'); - const [selectedRepos, setSelectedRepos] = useState<Set<string>>(new Set()); - const [projectName, setProjectName] = useState(''); - const [workspaceId, setWorkspaceId] = useState<string | null>(null); - const [description, setDescription] = useState(''); - - const addProject = useAddProject(); - const createSubProject = useCreateSubProject(); - const setupExisting = useSetupExisting(); - const { data: workspaces } = useWorkspaces(); - - const selectDirectory = useMutation({ - mutationFn: () => ipc(PROJECTS.SELECT.DIRECTORY, {}), - }); - - const detection = useQuery({ - queryKey: ['projects', 'detectRepo', selectedPath], - queryFn: () => ipc(PROJECTS.DETECT.REPO, { path: selectedPath ?? '' }), - enabled: selectedPath !== null && step >= 1, - staleTime: 300_000, - }); - - const hasChildRepos = (detection.data?.childRepos.length ?? 0) > 0; - const visibleSteps = getVisibleSteps(hasChildRepos); - const currentVisibleIndex = visibleSteps.indexOf(step); - const isLastStep = currentVisibleIndex === visibleSteps.length - 1; - - // Update repo type when detection completes - useEffect(() => { - if (detection.data && step === 1 && repoType === 'single') { - setRepoType(detection.data.repoType); - } - }, [detection.data, step, repoType]); - - async function handleSelectFolder() { - const result = await selectDirectory.mutateAsync(); - if (result.path) { - setSelectedPath(result.path); - const folderName = result.path.split(/[\\/]/).pop() ?? ''; - setProjectName(folderName); - setStep(1); - } - } - - function handleNext() { - const nextIndex = currentVisibleIndex + 1; - if (nextIndex < visibleSteps.length) { - setStep(visibleSteps[nextIndex]); - } - } - - function handleBack() { - const prevIndex = currentVisibleIndex - 1; - if (prevIndex >= 0) { - setStep(visibleSteps[prevIndex]); - } - } - - async function handleConfirm() { - if (!selectedPath) return; - const project = await addProject.mutateAsync({ - path: selectedPath, - name: projectName.trim() || undefined, - workspaceId: workspaceId ?? undefined, - description: description.trim() || undefined, - repoStructure: repoType as 'single' | 'monorepo' | 'multi-repo', - defaultBranch: detection.data?.defaultBranch ?? undefined, - }); - - // Create sub-projects from selected repos - if (selectedRepos.size > 0 && detection.data) { - const repos = detection.data.childRepos.filter((r) => selectedRepos.has(r.path)); - await Promise.all( - repos.map((repo) => - createSubProject.mutateAsync({ - projectId: project.id, - name: repo.name, - relativePath: repo.relativePath, - gitUrl: repo.gitUrl, - }), - ), - ); - } - - // Start the setup pipeline (fire-and-forget — progress tracked via events) - void setupExisting.mutateAsync({ projectId: project.id }); - - onSetupStarted(project.id); - } - - return ( - <div - aria-label="Initialize project" - aria-modal="true" - className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" - role="dialog" - > - <div className="bg-card border-border mx-4 flex max-h-[90vh] w-full max-w-lg flex-col rounded-xl border shadow-xl"> - {/* Header */} - <div className="border-border flex items-center justify-between border-b px-6 py-4"> - <h2 className="text-lg font-semibold">Initialize Project</h2> - <Button - aria-label="Close" - className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors" - size="icon" - type="button" - variant="ghost" - onClick={onClose} - > - <X className="h-5 w-5" /> - </Button> - </div> - - {/* Step Indicator */} - <div className="border-border flex items-center gap-2 border-b px-6 py-3"> - {visibleSteps.map((stepIndex, idx) => ( - <div key={stepIndex} className="flex items-center gap-2"> - {idx > 0 ? ( - <div className="bg-border h-px w-4" /> - ) : null} - <div - className={cn( - 'flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium', - getStepIndicatorClass(step, stepIndex), - )} - > - {step > stepIndex ? ( - <Check className="h-3.5 w-3.5" /> - ) : ( - String(idx + 1) - )} - </div> - <span - className={cn( - 'text-xs', - step === stepIndex ? 'text-foreground font-medium' : 'text-muted-foreground', - )} - > - {STEP_LABELS[stepIndex]} - </span> - </div> - ))} - </div> - - {/* Content */} - <div className="min-h-[280px] flex-1 overflow-y-auto px-6 py-5"> - {step === 0 ? ( - <StepFolder - isPending={selectDirectory.isPending} - selectedPath={selectedPath} - onSelect={handleSelectFolder} - /> - ) : null} - - {step === 1 ? ( - <StepDetection - detection={detection.data} - error={detection.error} - isLoading={detection.isLoading} - repoType={repoType} - selectedPath={selectedPath} - onTypeChange={setRepoType} - /> - ) : null} - - {step === 2 ? ( - <StepSubRepos - repos={detection.data?.childRepos ?? []} - selected={selectedRepos} - onSelectionChange={setSelectedRepos} - /> - ) : null} - - {step === 3 ? ( - <StepConfigure - description={description} - hasChildRepos={hasChildRepos} - projectName={projectName} - repoType={repoType} - selectedPath={selectedPath} - selectedReposSize={selectedRepos.size} - workspaceId={workspaceId} - workspaces={workspaces ?? []} - onDescriptionChange={setDescription} - onNameChange={setProjectName} - onWorkspaceChange={setWorkspaceId} - /> - ) : null} - - {step === 4 ? ( - <StepConfirm - defaultBranch={detection.data?.defaultBranch} - description={description} - hasChildRepos={hasChildRepos} - projectName={projectName} - repoType={repoType} - selectedPath={selectedPath} - selectedReposSize={selectedRepos.size} - workspaceId={workspaceId} - workspaces={workspaces ?? []} - /> - ) : null} - </div> - - {/* Footer */} - <div className="border-border flex items-center justify-between border-t px-6 py-4"> - <Button - disabled={currentVisibleIndex <= 0} - type="button" - variant="ghost" - className={cn( - 'text-muted-foreground flex items-center gap-1 text-sm transition-colors', - 'hover:text-foreground disabled:invisible', - )} - onClick={handleBack} - > - <ChevronLeft className="h-4 w-4" /> - Back - </Button> - - {isLastStep ? ( - <Button - type="button" - className={cn( - 'bg-primary text-primary-foreground rounded-lg px-5 py-2 text-sm font-medium transition-colors', - 'hover:bg-primary/90 disabled:opacity-50', - )} - disabled={ - addProject.isPending || - createSubProject.isPending || - projectName.trim().length === 0 - } - onClick={handleConfirm} - > - {addProject.isPending || createSubProject.isPending ? ( - <span className="flex items-center gap-2"> - <Loader2 className="h-4 w-4 animate-spin" /> - Creating... - </span> - ) : ( - 'Create Project' - )} - </Button> - ) : ( - <Button - disabled={step === 0 && selectedPath === null} - type="button" - variant="ghost" - className={cn( - 'text-primary flex items-center gap-1 text-sm font-medium transition-colors', - 'hover:text-primary/80 disabled:opacity-50', - )} - onClick={handleNext} - > - Next - <ChevronRight className="h-4 w-4" /> - </Button> - )} - </div> - </div> - </div> - ); -} +export { ProjectInitWizard } from './ProjectInitWizard/index'; diff --git a/src/renderer/features/projects/components/ProjectInitWizard/ProjectInitWizard.tsx b/src/renderer/features/projects/components/ProjectInitWizard/ProjectInitWizard.tsx new file mode 100644 index 00000000..f685f6eb --- /dev/null +++ b/src/renderer/features/projects/components/ProjectInitWizard/ProjectInitWizard.tsx @@ -0,0 +1,241 @@ +/** + * ProjectInitWizard — Multi-step wizard for project initialization + * + * Steps: + * 1. Select folder + * 2. Show detection results + * 3. If multi-repo, show SubRepoSelector + * 4. Configure project settings + * 5. Confirm and create + */ + +import { Check, ChevronLeft, ChevronRight, Loader2, X } from 'lucide-react'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { Button, Heading } from '@ui'; + +import { + StepConfigure, + StepConfirm, + StepDetection, + StepFolder, + StepSubRepos, +} from '../wizard-steps'; + +import { useProjectInitWizard } from './useProjectInitWizard'; + +interface ProjectInitWizardProps { + onClose: () => void; + onSetupStarted: (projectId: string) => void; +} + +function getStepIndicatorClass(currentStep: number, stepIndex: number): string { + if (currentStep === stepIndex) return 'bg-primary text-primary-foreground'; + if (currentStep > stepIndex) return 'bg-success text-success-foreground'; + return 'bg-muted text-muted-foreground'; +} + +export function ProjectInitWizard({ onClose, onSetupStarted }: ProjectInitWizardProps) { + const { + step, + selectedPath, + repoType, + selectedRepos, + projectName, + workspaceId, + description, + detection, + hasChildRepos, + visibleSteps, + currentVisibleIndex, + isLastStep, + addProject, + createSubProject, + selectDirectory, + workspaces, + setRepoType, + setSelectedRepos, + setProjectName, + setWorkspaceId, + setDescription, + handleSelectFolder, + handleNext, + handleBack, + handleConfirm, + STEP_LABELS, + } = useProjectInitWizard({ onClose, onSetupStarted }); + + return ( + <div + aria-label="Initialize project" + aria-modal="true" + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" + role="dialog" + > + <div className="bg-card border-border mx-4 flex max-h-[90vh] w-full max-w-lg flex-col rounded-xl border shadow-xl"> + {/* Header */} + <div className="border-border flex items-center justify-between border-b px-6 py-4"> + <Heading as="h2" className="text-lg">Initialize Project</Heading> + <Button + aria-label="Close" + className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors" + size="icon" + type="button" + variant="ghost" + onClick={onClose} + > + <X className="h-5 w-5" /> + </Button> + </div> + + {/* Step Indicator */} + <div className="border-border flex items-center gap-2 border-b px-6 py-3"> + {visibleSteps.map((stepIndex, idx) => ( + <div key={stepIndex} className="flex items-center gap-2"> + {idx > 0 ? ( + <div className="bg-border h-px w-4" /> + ) : null} + <div + className={cn( + 'flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium', + getStepIndicatorClass(step, stepIndex), + )} + > + {step > stepIndex ? ( + <Check className="h-3.5 w-3.5" /> + ) : ( + String(idx + 1) + )} + </div> + <span + className={cn( + 'text-xs', + step === stepIndex ? 'text-foreground font-medium' : 'text-muted-foreground', + )} + > + {STEP_LABELS[stepIndex]} + </span> + </div> + ))} + </div> + + {/* Content */} + <div className="min-h-[280px] flex-1 overflow-y-auto px-6 py-5"> + {step === 0 ? ( + <StepFolder + isPending={selectDirectory.isPending} + selectedPath={selectedPath} + onSelect={handleSelectFolder} + /> + ) : null} + + {step === 1 ? ( + <StepDetection + detection={detection.data} + error={detection.error} + isLoading={detection.isLoading} + repoType={repoType} + selectedPath={selectedPath} + onTypeChange={setRepoType} + /> + ) : null} + + {step === 2 ? ( + <StepSubRepos + repos={detection.data?.childRepos ?? []} + selected={selectedRepos} + onSelectionChange={setSelectedRepos} + /> + ) : null} + + {step === 3 ? ( + <StepConfigure + description={description} + hasChildRepos={hasChildRepos} + projectName={projectName} + repoType={repoType} + selectedPath={selectedPath} + selectedReposSize={selectedRepos.size} + workspaceId={workspaceId} + workspaces={workspaces ?? []} + onDescriptionChange={setDescription} + onNameChange={setProjectName} + onWorkspaceChange={setWorkspaceId} + /> + ) : null} + + {step === 4 ? ( + <StepConfirm + defaultBranch={detection.data?.defaultBranch} + description={description} + hasChildRepos={hasChildRepos} + projectName={projectName} + repoType={repoType} + selectedPath={selectedPath} + selectedReposSize={selectedRepos.size} + workspaceId={workspaceId} + workspaces={workspaces ?? []} + /> + ) : null} + </div> + + {/* Footer */} + <div className="border-border flex items-center justify-between border-t px-6 py-4"> + <Button + disabled={currentVisibleIndex <= 0} + type="button" + variant="ghost" + className={cn( + 'text-muted-foreground flex items-center gap-1 text-sm transition-colors', + 'hover:text-foreground disabled:invisible', + )} + onClick={handleBack} + > + <ChevronLeft className="h-4 w-4" /> + Back + </Button> + + {isLastStep ? ( + <Button + type="button" + className={cn( + 'bg-primary text-primary-foreground rounded-lg px-5 py-2 text-sm font-medium transition-colors', + 'hover:bg-primary/90 disabled:opacity-50', + )} + disabled={ + addProject.isPending || + createSubProject.isPending || + projectName.trim().length === 0 + } + onClick={handleConfirm} + > + {addProject.isPending || createSubProject.isPending ? ( + <span className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + Creating... + </span> + ) : ( + 'Create Project' + )} + </Button> + ) : ( + <Button + disabled={step === 0 && selectedPath === null} + type="button" + variant="ghost" + className={cn( + 'text-primary flex items-center gap-1 text-sm font-medium transition-colors', + 'hover:text-primary/80 disabled:opacity-50', + )} + onClick={handleNext} + > + Next + <ChevronRight className="h-4 w-4" /> + </Button> + )} + </div> + </div> + </div> + ); +} diff --git a/src/renderer/features/projects/components/ProjectInitWizard/index.ts b/src/renderer/features/projects/components/ProjectInitWizard/index.ts new file mode 100644 index 00000000..75a2db88 --- /dev/null +++ b/src/renderer/features/projects/components/ProjectInitWizard/index.ts @@ -0,0 +1,2 @@ +export { ProjectInitWizard } from './ProjectInitWizard'; +export { useProjectInitWizard } from './useProjectInitWizard'; diff --git a/src/renderer/features/projects/components/ProjectInitWizard/useProjectInitWizard.ts b/src/renderer/features/projects/components/ProjectInitWizard/useProjectInitWizard.ts new file mode 100644 index 00000000..6f58c7dc --- /dev/null +++ b/src/renderer/features/projects/components/ProjectInitWizard/useProjectInitWizard.ts @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { PROJECTS } from '@shared/ipc/projects/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { useWorkspaces } from '@features/workspace'; + +import { useAddProject, useCreateSubProject, useSetupExisting } from '../../api/useProjects'; +import { useWizardNavigation } from '../../hooks/useWizardNavigation'; + +const STEP_LABELS = ['Select Folder', 'Detection', 'Sub-Repos', 'Configure', 'Confirm'] as const; +const TOTAL_STEPS = STEP_LABELS.length; + +function getVisibleSteps(hasChildRepos: boolean): number[] { + if (hasChildRepos) return [0, 1, 2, 3, 4]; + return [0, 1, 3, 4]; +} + +interface UseProjectInitWizardProps { + onClose: () => void; + onSetupStarted: (projectId: string) => void; +} + +export function useProjectInitWizard({ onClose, onSetupStarted }: UseProjectInitWizardProps) { + const { step, setStep } = useWizardNavigation(TOTAL_STEPS); + + const [selectedPath, setSelectedPath] = useState<string | null>(null); + const [repoType, setRepoType] = useState('single'); + const [selectedRepos, setSelectedRepos] = useState<Set<string>>(new Set()); + const [projectName, setProjectName] = useState(''); + const [workspaceId, setWorkspaceId] = useState<string | null>(null); + const [description, setDescription] = useState(''); + + const addProject = useAddProject(); + const createSubProject = useCreateSubProject(); + const setupExisting = useSetupExisting(); + const { data: workspaces } = useWorkspaces(); + + const selectDirectory = useMutation({ + mutationFn: () => ipc(PROJECTS.SELECT.DIRECTORY, {}), + }); + + const detection = useQuery({ + queryKey: ['projects', 'detectRepo', selectedPath], + queryFn: () => ipc(PROJECTS.DETECT.REPO, { path: selectedPath ?? '' }), + enabled: selectedPath !== null && step >= 1, + staleTime: 300_000, + }); + + const hasChildRepos = (detection.data?.childRepos.length ?? 0) > 0; + const visibleSteps = getVisibleSteps(hasChildRepos); + const currentVisibleIndex = visibleSteps.indexOf(step); + const isLastStep = currentVisibleIndex === visibleSteps.length - 1; + + useEffect(() => { + if (detection.data && step === 1 && repoType === 'single') { + setRepoType(detection.data.repoType); + } + }, [detection.data, step, repoType]); + + async function handleSelectFolder() { + const result = await selectDirectory.mutateAsync(); + if (result.path) { + setSelectedPath(result.path); + const folderName = result.path.split(/[\\/]/).pop() ?? ''; + setProjectName(folderName); + setStep(1); + } + } + + const handleNext = useCallback(() => { + const nextIndex = currentVisibleIndex + 1; + if (nextIndex < visibleSteps.length) { + setStep(visibleSteps[nextIndex]); + } + }, [currentVisibleIndex, visibleSteps, setStep]); + + const handleBack = useCallback(() => { + const prevIndex = currentVisibleIndex - 1; + if (prevIndex >= 0) { + setStep(visibleSteps[prevIndex]); + } + }, [currentVisibleIndex, visibleSteps, setStep]); + + async function handleConfirm() { + if (!selectedPath) return; + const project = await addProject.mutateAsync({ + path: selectedPath, + name: projectName.trim() || undefined, + workspaceId: workspaceId ?? undefined, + description: description.trim() || undefined, + repoStructure: repoType as 'single' | 'monorepo' | 'multi-repo', + defaultBranch: detection.data?.defaultBranch ?? undefined, + }); + + if (selectedRepos.size > 0 && detection.data) { + const repos = detection.data.childRepos.filter((r) => selectedRepos.has(r.path)); + await Promise.all( + repos.map((repo) => + createSubProject.mutateAsync({ + projectId: project.id, + name: repo.name, + relativePath: repo.relativePath, + gitUrl: repo.gitUrl, + }), + ), + ); + } + + void setupExisting.mutateAsync({ projectId: project.id }); + + onSetupStarted(project.id); + } + + return { + step, + selectedPath, + repoType, + selectedRepos, + projectName, + workspaceId, + description, + detection, + hasChildRepos, + visibleSteps, + currentVisibleIndex, + isLastStep, + addProject, + createSubProject, + selectDirectory, + workspaces, + setRepoType, + setSelectedRepos, + setProjectName, + setWorkspaceId, + setDescription, + handleSelectFolder, + handleNext, + handleBack, + handleConfirm, + onClose, + STEP_LABELS, + }; +} diff --git a/src/renderer/features/projects/components/ProjectList.tsx b/src/renderer/features/projects/components/ProjectList.tsx index 3eee6461..c6efea7a 100644 --- a/src/renderer/features/projects/components/ProjectList.tsx +++ b/src/renderer/features/projects/components/ProjectList.tsx @@ -1,125 +1 @@ -/** - * ProjectList — Page component for managing projects. - */ - -import { FolderOpen, GitBranch, Plus, Trash2 } from 'lucide-react'; - -import type { Project } from '@shared/types'; - -import { cn } from '@renderer/shared/lib/utils'; -import { useLayoutStore } from '@renderer/shared/stores/layout-store'; - -import { Badge, Button } from '@ui'; - -import { useGitStatus } from '../api/useGit'; -import { - useProjects, - useAddProject, - useRemoveProject, - useSelectDirectory, -} from '../api/useProjects'; - -function GitStatusIndicator({ project }: { project: Project }) { - const { data: status } = useGitStatus(project.path); - - if (!status) { - return null; - } - - return ( - <div className="flex items-center gap-1.5"> - <GitBranch className="text-muted-foreground h-3 w-3" /> - <span className="text-muted-foreground text-xs">{status.branch}</span> - {status.isClean ? ( - <Badge className="px-1.5 py-0 text-[10px]" variant="outline">clean</Badge> - ) : ( - <Badge className="px-1.5 py-0 text-[10px]" variant="secondary"> - {String(status.modified.length + status.staged.length)} changed - </Badge> - )} - </div> - ); -} - -export function ProjectList() { - const { data: projects, isLoading } = useProjects(); - const addProject = useAddProject(); - const removeProject = useRemoveProject(); - const selectDir = useSelectDirectory(); - const activeProjectId = useLayoutStore((s) => s.activeProjectId); - const setActiveProject = useLayoutStore((s) => s.setActiveProject); - - const handleAdd = async () => { - const result = await selectDir.mutateAsync(); - if (result.path) { - const project = await addProject.mutateAsync({ path: result.path }); - setActiveProject(project.id); - } - }; - - if (isLoading) { - return ( - <div className="text-muted-foreground flex h-full items-center justify-center"> - Loading projects… - </div> - ); - } - - return ( - <div className="mx-auto max-w-2xl p-6"> - <div className="mb-6 flex items-center justify-between"> - <h1 className="text-lg font-semibold">Projects</h1> - <Button size="sm" onClick={handleAdd}> - <Plus className="h-4 w-4" /> - Add Project - </Button> - </div> - - {projects && projects.length > 0 ? ( - <div className="space-y-2"> - {projects.map((project) => ( - <div - key={project.id} - role="button" - tabIndex={0} - className={cn( - 'flex cursor-pointer items-center justify-between rounded-lg border px-4 py-3 transition-colors', - activeProjectId === project.id - ? 'border-primary/50 bg-accent' - : 'border-border hover:bg-accent/50', - )} - onClick={() => setActiveProject(project.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') setActiveProject(project.id); - }} - > - <div> - <div className="flex items-center gap-2"> - <p className="text-sm font-medium">{project.name}</p> - <GitStatusIndicator project={project} /> - </div> - <p className="text-muted-foreground text-xs">{project.path}</p> - </div> - <Button - className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-8 w-8" - size="icon" - variant="ghost" - onClick={(e) => { - e.stopPropagation(); - removeProject.mutate(project.id); - }} - > - <Trash2 className="h-4 w-4" /> - </Button> - </div> - ))} - </div> - ) : ( - <div className="text-muted-foreground flex flex-col items-center justify-center py-20"> - <FolderOpen className="mb-3 h-12 w-12 opacity-40" /> - <p className="text-sm">No projects yet. Add a project folder to get started.</p> - </div> - )} - </div> - ); -} +export { ProjectList } from './ProjectList/index'; diff --git a/src/renderer/features/projects/components/ProjectList/ProjectList.tsx b/src/renderer/features/projects/components/ProjectList/ProjectList.tsx new file mode 100644 index 00000000..3dcf593f --- /dev/null +++ b/src/renderer/features/projects/components/ProjectList/ProjectList.tsx @@ -0,0 +1,114 @@ +/** + * ProjectList — Page component for managing projects. + */ + +import { FolderOpen, GitBranch, Plus, Trash2 } from 'lucide-react'; + +import type { Project } from '@shared/types'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { Badge, Button, Heading } from '@ui'; + +import { useGitStatus } from '../../api/useGit'; + +import { useProjectList } from './useProjectList'; + +function GitStatusIndicator({ project }: { project: Project }) { + const { data: status } = useGitStatus(project.path); + + if (!status) { + return null; + } + + return ( + <div className="flex items-center gap-1.5"> + <GitBranch className="text-muted-foreground h-3 w-3" /> + <span className="text-muted-foreground text-xs">{status.branch}</span> + {status.isClean ? ( + <Badge className="px-1.5 py-0 text-[10px]" variant="outline">clean</Badge> + ) : ( + <Badge className="px-1.5 py-0 text-[10px]" variant="secondary"> + {String(status.modified.length + status.staged.length)} changed + </Badge> + )} + </div> + ); +} + +export function ProjectList() { + const { + projects, + isLoading, + activeProjectId, + setActiveProject, + handleAdd, + handleRemove, + } = useProjectList(); + + if (isLoading) { + return ( + <div className="text-muted-foreground flex h-full items-center justify-center"> + Loading projects… + </div> + ); + } + + return ( + <div className="mx-auto max-w-2xl p-6"> + <div className="mb-6 flex items-center justify-between"> + <Heading as="h1" className="text-lg">Projects</Heading> + <Button size="sm" onClick={handleAdd}> + <Plus className="h-4 w-4" /> + Add Project + </Button> + </div> + + {projects && projects.length > 0 ? ( + <div className="space-y-2"> + {projects.map((project) => ( + <div + key={project.id} + role="button" + tabIndex={0} + className={cn( + 'flex cursor-pointer items-center justify-between rounded-lg border px-4 py-3 transition-colors', + activeProjectId === project.id + ? 'border-primary/50 bg-accent' + : 'border-border hover:bg-accent/50', + )} + onClick={() => setActiveProject(project.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') setActiveProject(project.id); + }} + > + <div> + <div className="flex items-center gap-2"> + <p className="text-sm font-medium">{project.name}</p> + <GitStatusIndicator project={project} /> + </div> + <p className="text-muted-foreground text-xs">{project.path}</p> + </div> + <Button + className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-8 w-8" + size="icon" + variant="ghost" + onClick={(e) => { + e.stopPropagation(); + handleRemove(project.id); + }} + > + <Trash2 className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + ) : ( + <div className="text-muted-foreground flex flex-col items-center justify-center py-20"> + <FolderOpen className="mb-3 h-12 w-12 opacity-40" /> + <p className="text-sm">No projects yet. Add a project folder to get started.</p> + </div> + )} + </div> + ); +} diff --git a/src/renderer/features/projects/components/ProjectList/index.ts b/src/renderer/features/projects/components/ProjectList/index.ts new file mode 100644 index 00000000..3e9efe96 --- /dev/null +++ b/src/renderer/features/projects/components/ProjectList/index.ts @@ -0,0 +1,2 @@ +export { ProjectList } from './ProjectList'; +export { useProjectList } from './useProjectList'; diff --git a/src/renderer/features/projects/components/ProjectList/useProjectList.ts b/src/renderer/features/projects/components/ProjectList/useProjectList.ts new file mode 100644 index 00000000..19377640 --- /dev/null +++ b/src/renderer/features/projects/components/ProjectList/useProjectList.ts @@ -0,0 +1,33 @@ +import { useLayoutStore } from '@renderer/shared/stores/layout-store'; + +import { useProjects, useAddProject, useRemoveProject, useSelectDirectory } from '../../api/useProjects'; + +export function useProjectList() { + const { data: projects, isLoading } = useProjects(); + const addProject = useAddProject(); + const removeProject = useRemoveProject(); + const selectDir = useSelectDirectory(); + const activeProjectId = useLayoutStore((s) => s.activeProjectId); + const setActiveProject = useLayoutStore((s) => s.setActiveProject); + + async function handleAdd() { + const result = await selectDir.mutateAsync(); + if (result.path) { + const project = await addProject.mutateAsync({ path: result.path }); + setActiveProject(project.id); + } + } + + function handleRemove(projectId: string) { + removeProject.mutate(projectId); + } + + return { + projects, + isLoading, + activeProjectId, + setActiveProject, + handleAdd, + handleRemove, + }; +} diff --git a/src/renderer/features/projects/components/ProjectListPage.tsx b/src/renderer/features/projects/components/ProjectListPage.tsx index 822ea365..67a3a8f3 100644 --- a/src/renderer/features/projects/components/ProjectListPage.tsx +++ b/src/renderer/features/projects/components/ProjectListPage.tsx @@ -1,347 +1 @@ -/** - * ProjectListPage — Projects dashboard with search, metrics, and rich project cards - */ - -import { useMemo, useState } from 'react'; - -import { useNavigate } from '@tanstack/react-router'; -import { FolderOpen, Layers, Pencil, Search, Sparkles, Trash2, Wand2 } from 'lucide-react'; - -import { PROJECT_VIEWS, projectViewPath } from '@shared/constants'; -import type { Project, RepoType } from '@shared/types'; - -import { formatRelativeTime } from '@renderer/shared/lib/utils'; -import { useLayoutStore, useToastStore } from '@renderer/shared/stores'; - -import { Badge, Button, Card, CardContent, Input, PageHeader, PageLayout, Separator, Spinner } from '@ui'; - -import { useAllTasks } from '@features/tasks'; - -import { useProjects, useRemoveProject, useSubProjects } from '../api/useProjects'; - -import { CreateProjectWizard } from './CreateProjectWizard'; -import { ProjectEditDialog } from './ProjectEditDialog'; -import { ProjectInitWizard } from './ProjectInitWizard'; - -function repoStructureBadgeVariant(structure: RepoType): 'default' | 'secondary' | 'outline' { - if (structure === 'monorepo') return 'default'; - if (structure === 'multi-repo') return 'secondary'; - return 'outline'; -} - -function repoStructureLabel(structure: RepoType): string { - if (structure === 'monorepo') return 'monorepo'; - if (structure === 'multi-repo') return 'multi-repo'; - return 'single'; -} - -interface ProjectCardProps { - project: Project; - taskCount: number; - onEdit: (e: React.MouseEvent | React.KeyboardEvent, project: Project) => void; - onOpen: (projectId: string) => void; - onRemove: (e: React.MouseEvent | React.KeyboardEvent, projectId: string) => void; -} - -function ProjectCard({ project, taskCount, onEdit, onOpen, onRemove }: ProjectCardProps) { - const { data: subProjects } = useSubProjects(project.id); - const subCount = subProjects?.length ?? 0; - - return ( - <Card - className="hover:bg-accent/50 cursor-pointer transition-colors" - role="button" - tabIndex={0} - onClick={() => onOpen(project.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onOpen(project.id); - } - }} - > - <CardContent className="flex items-center justify-between p-4"> - <div className="flex items-center gap-3"> - <FolderOpen className="text-muted-foreground h-5 w-5 shrink-0" /> - <div className="min-w-0"> - <div className="flex items-center gap-2"> - <p className="truncate font-medium">{project.name}</p> - {project.repoStructure ? ( - <Badge variant={repoStructureBadgeVariant(project.repoStructure)}> - {repoStructureLabel(project.repoStructure)} - </Badge> - ) : null} - {subCount > 0 ? ( - <span className="text-muted-foreground flex items-center gap-1 text-xs"> - <Layers className="h-3 w-3" /> - {String(subCount)} sub-project{subCount === 1 ? '' : 's'} - </span> - ) : null} - </div> - <p className="text-muted-foreground truncate text-xs">{project.path}</p> - </div> - </div> - <div className="flex items-center gap-3"> - {taskCount > 0 ? ( - <Badge variant="outline"> - {String(taskCount)} task{taskCount === 1 ? '' : 's'} - </Badge> - ) : null} - <span className="text-muted-foreground text-xs whitespace-nowrap"> - {formatRelativeTime(project.updatedAt)} - </span> - <span - aria-label={`Edit ${project.name}`} - className="text-muted-foreground hover:bg-accent hover:text-foreground rounded p-1" - role="button" - tabIndex={0} - onClick={(e) => { - e.stopPropagation(); - onEdit(e, project); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - onEdit(e, project); - } - }} - > - <Pencil className="h-4 w-4" /> - </span> - <span - aria-label={`Remove ${project.name}`} - className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive rounded p-1" - role="button" - tabIndex={0} - onClick={(e) => { - e.stopPropagation(); - onRemove(e, project.id); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - onRemove(e, project.id); - } - }} - > - <Trash2 className="h-4 w-4" /> - </span> - </div> - </CardContent> - </Card> - ); -} - -export function ProjectListPage() { - const navigate = useNavigate(); - const { data: projects, isLoading } = useProjects(); - const { data: allTasks } = useAllTasks(); - const removeProject = useRemoveProject(); - const { addProjectTab } = useLayoutStore(); - const { addToast } = useToastStore(); - const [editingProject, setEditingProject] = useState<Project | null>(null); - const [wizardOpen, setWizardOpen] = useState(false); - const [createWizardOpen, setCreateWizardOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - - const filteredProjects = useMemo(() => { - if (!projects) return []; - const query = searchQuery.toLowerCase().trim(); - if (query.length === 0) return projects; - return projects.filter( - (p) => p.name.toLowerCase().includes(query) || p.path.toLowerCase().includes(query), - ); - }, [projects, searchQuery]); - - const metrics = useMemo(() => { - const tasks = allTasks ?? []; - const totalProjects = projects?.length ?? 0; - const activeTasks = tasks.filter((t) => ['in_progress', 'running'].includes(t.status)).length; - const activeAgents = tasks.filter( - (t) => - t.status === 'running' && - Boolean((t.metadata as Record<string, unknown> | undefined)?.agentName), - ).length; - return { totalProjects, activeTasks, activeAgents }; - }, [allTasks, projects]); - - const taskCountByProject = useMemo(() => { - const tasks = allTasks ?? []; - const counts = new Map<string, number>(); - for (const task of tasks) { - const pid = task.projectId; - if (pid) { - counts.set(pid, (counts.get(pid) ?? 0) + 1); - } - } - return counts; - }, [allTasks]); - - function handleOpenProject(projectId: string) { - addProjectTab(projectId); - void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); - } - - function handleWizardSetupStarted(projectId: string) { - setWizardOpen(false); - addProjectTab(projectId); - addToast('Project created — setup running in background', 'success'); - void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); - } - - function handleProjectCreated(projectId: string) { - setCreateWizardOpen(false); - addProjectTab(projectId); - addToast('Project created — setup running in background', 'success'); - void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); - } - - function handleEditProject(e: React.MouseEvent | React.KeyboardEvent, project: Project) { - e.stopPropagation(); - setEditingProject(project); - } - - function handleRemoveProject(e: React.MouseEvent | React.KeyboardEvent, projectId: string) { - e.stopPropagation(); - removeProject.mutate(projectId); - } - - if (isLoading) { - return ( - <PageLayout> - <div className="flex h-full items-center justify-center"> - <Spinner className="h-6 w-6" /> - </div> - </PageLayout> - ); - } - - const hasProjects = (projects?.length ?? 0) > 0; - const hasFilteredResults = filteredProjects.length > 0; - - function renderProjectList() { - if (!hasProjects) { - return ( - <div className="border-border rounded-lg border border-dashed p-12 text-center"> - <FolderOpen className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> - <p className="text-lg font-medium">No projects yet</p> - <p className="text-muted-foreground mt-1 text-sm">Add a project folder to get started</p> - </div> - ); - } - - if (!hasFilteredResults) { - return ( - <div className="border-border rounded-lg border border-dashed p-12 text-center"> - <Search className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> - <p className="text-lg font-medium">No matching projects</p> - <p className="text-muted-foreground mt-1 text-sm">Try a different search term</p> - </div> - ); - } - - return ( - <div className="grid grid-cols-1 gap-3"> - {filteredProjects.map((project) => ( - <ProjectCard - key={project.id} - project={project} - taskCount={taskCountByProject.get(project.id) ?? 0} - onEdit={handleEditProject} - onOpen={handleOpenProject} - onRemove={handleRemoveProject} - /> - ))} - </div> - ); - } - - return ( - <PageLayout> - <PageHeader> - <PageHeader.Row> - <PageHeader.Title>Projects</PageHeader.Title> - </PageHeader.Row> - </PageHeader> - - <div className="mx-auto max-w-4xl p-8"> - {/* Header */} - <div className="mb-6 flex items-center justify-between"> - <div /> - <div className="flex items-center gap-2"> - <Button variant="outline" onClick={() => setWizardOpen(true)}> - <Wand2 className="h-4 w-4" /> - Init Wizard - </Button> - <Button variant="outline" onClick={() => setCreateWizardOpen(true)}> - <Sparkles className="h-4 w-4" /> - New Project - </Button> - </div> - </div> - - {/* Search */} - {hasProjects ? ( - <div className="relative mb-4"> - <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> - <Input - className="pl-10" - placeholder="Search projects by name or path..." - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - /> - </div> - ) : null} - - {/* Metrics */} - <div className="mb-6 grid grid-cols-3 gap-3"> - <Card> - <CardContent className="p-4"> - <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> - Total Projects - </p> - <p className="mt-1 text-2xl font-bold">{String(metrics.totalProjects)}</p> - </CardContent> - </Card> - <Card> - <CardContent className="p-4"> - <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> - Active Tasks - </p> - <p className="mt-1 text-2xl font-bold">{String(metrics.activeTasks)}</p> - </CardContent> - </Card> - <Card> - <CardContent className="p-4"> - <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> - Active Agents - </p> - <p className="mt-1 text-2xl font-bold">{String(metrics.activeAgents)}</p> - </CardContent> - </Card> - </div> - - <Separator className="mb-6" /> - - {/* Project list */} - {renderProjectList()} - - {/* Wizards and dialogs */} - {wizardOpen ? ( - <ProjectInitWizard - onClose={() => setWizardOpen(false)} - onSetupStarted={handleWizardSetupStarted} - /> - ) : null} - - <CreateProjectWizard - open={createWizardOpen} - onClose={() => setCreateWizardOpen(false)} - onProjectCreated={handleProjectCreated} - /> - - <ProjectEditDialog project={editingProject} onClose={() => setEditingProject(null)} /> - </div> - </PageLayout> - ); -} +export { ProjectListPage } from './ProjectListPage/index'; diff --git a/src/renderer/features/projects/components/ProjectListPage/ProjectListPage.tsx b/src/renderer/features/projects/components/ProjectListPage/ProjectListPage.tsx new file mode 100644 index 00000000..41fbe2de --- /dev/null +++ b/src/renderer/features/projects/components/ProjectListPage/ProjectListPage.tsx @@ -0,0 +1,289 @@ +/** + * ProjectListPage — Projects dashboard with search, metrics, and rich project cards + */ + +import { FolderOpen, Layers, Pencil, Search, Sparkles, Trash2, Wand2 } from 'lucide-react'; + +import type { Project, RepoType } from '@shared/types'; + +import { formatRelativeTime } from '@renderer/shared/lib/utils'; + +import { Badge, Button, Card, CardContent, Input, PageHeader, PageLayout, Separator, Spinner } from '@ui'; + +import { useSubProjects } from '../../api/useProjects'; +import { CreateProjectWizard } from '../CreateProjectWizard'; +import { ProjectEditDialog } from '../ProjectEditDialog'; +import { ProjectInitWizard } from '../ProjectInitWizard'; + +import { useProjectListPage } from './useProjectListPage'; + +function repoStructureBadgeVariant(structure: RepoType): 'default' | 'secondary' | 'outline' { + if (structure === 'monorepo') return 'default'; + if (structure === 'multi-repo') return 'secondary'; + return 'outline'; +} + +function repoStructureLabel(structure: RepoType): string { + if (structure === 'monorepo') return 'monorepo'; + if (structure === 'multi-repo') return 'multi-repo'; + return 'single'; +} + +interface ProjectCardProps { + project: Project; + taskCount: number; + onEdit: (e: React.MouseEvent | React.KeyboardEvent, project: Project) => void; + onOpen: (projectId: string) => void; + onRemove: (e: React.MouseEvent | React.KeyboardEvent, projectId: string) => void; +} + +function ProjectCard({ project, taskCount, onEdit, onOpen, onRemove }: ProjectCardProps) { + const { data: subProjects } = useSubProjects(project.id); + const subCount = subProjects?.length ?? 0; + + return ( + <Card + className="hover:bg-accent/50 cursor-pointer transition-colors" + role="button" + tabIndex={0} + onClick={() => onOpen(project.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen(project.id); + } + }} + > + <CardContent className="flex items-center justify-between p-4"> + <div className="flex items-center gap-3"> + <FolderOpen className="text-muted-foreground h-5 w-5 shrink-0" /> + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <p className="truncate font-medium">{project.name}</p> + {project.repoStructure ? ( + <Badge variant={repoStructureBadgeVariant(project.repoStructure)}> + {repoStructureLabel(project.repoStructure)} + </Badge> + ) : null} + {subCount > 0 ? ( + <span className="text-muted-foreground flex items-center gap-1 text-xs"> + <Layers className="h-3 w-3" /> + {String(subCount)} sub-project{subCount === 1 ? '' : 's'} + </span> + ) : null} + </div> + <p className="text-muted-foreground truncate text-xs">{project.path}</p> + </div> + </div> + <div className="flex items-center gap-3"> + {taskCount > 0 ? ( + <Badge variant="outline"> + {String(taskCount)} task{taskCount === 1 ? '' : 's'} + </Badge> + ) : null} + <span className="text-muted-foreground text-xs whitespace-nowrap"> + {formatRelativeTime(project.updatedAt)} + </span> + <span + aria-label={`Edit ${project.name}`} + className="text-muted-foreground hover:bg-accent hover:text-foreground rounded p-1" + role="button" + tabIndex={0} + onClick={(e) => { + e.stopPropagation(); + onEdit(e, project); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + onEdit(e, project); + } + }} + > + <Pencil className="h-4 w-4" /> + </span> + <span + aria-label={`Remove ${project.name}`} + className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive rounded p-1" + role="button" + tabIndex={0} + onClick={(e) => { + e.stopPropagation(); + onRemove(e, project.id); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + onRemove(e, project.id); + } + }} + > + <Trash2 className="h-4 w-4" /> + </span> + </div> + </CardContent> + </Card> + ); +} + +export function ProjectListPage() { + const { + projects, + isLoading, + filteredProjects, + metrics, + taskCountByProject, + editingProject, + wizardOpen, + createWizardOpen, + searchQuery, + setEditingProject, + setWizardOpen, + setCreateWizardOpen, + setSearchQuery, + handleOpenProject, + handleWizardSetupStarted, + handleProjectCreated, + handleEditProject, + handleRemoveProject, + } = useProjectListPage(); + + if (isLoading) { + return ( + <PageLayout> + <div className="flex h-full items-center justify-center"> + <Spinner className="h-6 w-6" /> + </div> + </PageLayout> + ); + } + + const hasProjects = (projects?.length ?? 0) > 0; + const hasFilteredResults = filteredProjects.length > 0; + + function renderProjectList() { + if (!hasProjects) { + return ( + <div className="border-border rounded-lg border border-dashed p-12 text-center"> + <FolderOpen className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> + <p className="text-lg font-medium">No projects yet</p> + <p className="text-muted-foreground mt-1 text-sm">Add a project folder to get started</p> + </div> + ); + } + + if (!hasFilteredResults) { + return ( + <div className="border-border rounded-lg border border-dashed p-12 text-center"> + <Search className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> + <p className="text-lg font-medium">No matching projects</p> + <p className="text-muted-foreground mt-1 text-sm">Try a different search term</p> + </div> + ); + } + + return ( + <div className="grid grid-cols-1 gap-3"> + {filteredProjects.map((project) => ( + <ProjectCard + key={project.id} + project={project} + taskCount={taskCountByProject.get(project.id) ?? 0} + onEdit={handleEditProject} + onOpen={handleOpenProject} + onRemove={handleRemoveProject} + /> + ))} + </div> + ); + } + + return ( + <PageLayout> + <PageHeader> + <PageHeader.Row> + <PageHeader.Title>Projects</PageHeader.Title> + </PageHeader.Row> + </PageHeader> + + <div className="mx-auto max-w-4xl p-8"> + {/* Header */} + <div className="mb-6 flex items-center justify-between"> + <div /> + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={() => setWizardOpen(true)}> + <Wand2 className="h-4 w-4" /> + Init Wizard + </Button> + <Button variant="outline" onClick={() => setCreateWizardOpen(true)}> + <Sparkles className="h-4 w-4" /> + New Project + </Button> + </div> + </div> + + {/* Search */} + {hasProjects ? ( + <div className="relative mb-4"> + <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> + <Input + className="pl-10" + placeholder="Search projects by name or path..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + </div> + ) : null} + + {/* Metrics */} + <div className="mb-6 grid grid-cols-3 gap-3"> + <Card> + <CardContent className="p-4"> + <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> + Total Projects + </p> + <p className="mt-1 text-2xl font-bold">{String(metrics.totalProjects)}</p> + </CardContent> + </Card> + <Card> + <CardContent className="p-4"> + <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> + Active Tasks + </p> + <p className="mt-1 text-2xl font-bold">{String(metrics.activeTasks)}</p> + </CardContent> + </Card> + <Card> + <CardContent className="p-4"> + <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> + Active Agents + </p> + <p className="mt-1 text-2xl font-bold">{String(metrics.activeAgents)}</p> + </CardContent> + </Card> + </div> + + <Separator className="mb-6" /> + + {/* Project list */} + {renderProjectList()} + + {/* Wizards and dialogs */} + {wizardOpen ? ( + <ProjectInitWizard + onClose={() => setWizardOpen(false)} + onSetupStarted={handleWizardSetupStarted} + /> + ) : null} + + <CreateProjectWizard + open={createWizardOpen} + onClose={() => setCreateWizardOpen(false)} + onProjectCreated={handleProjectCreated} + /> + + <ProjectEditDialog project={editingProject} onClose={() => setEditingProject(null)} /> + </div> + </PageLayout> + ); +} diff --git a/src/renderer/features/projects/components/ProjectListPage/index.ts b/src/renderer/features/projects/components/ProjectListPage/index.ts new file mode 100644 index 00000000..1e708f8d --- /dev/null +++ b/src/renderer/features/projects/components/ProjectListPage/index.ts @@ -0,0 +1,2 @@ +export { ProjectListPage } from './ProjectListPage'; +export { useProjectListPage } from './useProjectListPage'; diff --git a/src/renderer/features/projects/components/ProjectListPage/useProjectListPage.ts b/src/renderer/features/projects/components/ProjectListPage/useProjectListPage.ts new file mode 100644 index 00000000..a8ab3734 --- /dev/null +++ b/src/renderer/features/projects/components/ProjectListPage/useProjectListPage.ts @@ -0,0 +1,111 @@ +import { useMemo, useState } from 'react'; + +import { useNavigate } from '@tanstack/react-router'; + +import { PROJECT_VIEWS, projectViewPath } from '@shared/constants'; +import type { Project } from '@shared/types'; + +import { useLayoutStore, useToastStore } from '@renderer/shared/stores'; + +import { useAllTasks } from '@features/my-work'; + +import { useProjects, useRemoveProject } from '../../api/useProjects'; + +export function useProjectListPage() { + const navigate = useNavigate(); + const { data: projects, isLoading } = useProjects(); + const { data: allTasks } = useAllTasks(); + const removeProject = useRemoveProject(); + const { addProjectTab } = useLayoutStore(); + const { addToast } = useToastStore(); + const [editingProject, setEditingProject] = useState<Project | null>(null); + const [wizardOpen, setWizardOpen] = useState(false); + const [createWizardOpen, setCreateWizardOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + const filteredProjects = useMemo(() => { + if (!projects) return []; + const query = searchQuery.toLowerCase().trim(); + if (query.length === 0) return projects; + return projects.filter( + (p) => p.name.toLowerCase().includes(query) || p.path.toLowerCase().includes(query), + ); + }, [projects, searchQuery]); + + const metrics = useMemo(() => { + const tasks = allTasks ?? []; + const totalProjects = projects?.length ?? 0; + const activeTasks = tasks.filter((t) => + ['researching', 'planning', 'executing'].includes(t.status), + ).length; + const activeAgents = tasks.filter( + (t) => + ['researching', 'planning', 'executing'].includes(t.status) && + typeof t.lastAgentName === 'string' && + t.lastAgentName.length > 0, + ).length; + return { totalProjects, activeTasks, activeAgents }; + }, [allTasks, projects]); + + const taskCountByProject = useMemo(() => { + const tasks = allTasks ?? []; + const counts = new Map<string, number>(); + for (const task of tasks) { + const pid = task.projectId; + if (pid) { + counts.set(pid, (counts.get(pid) ?? 0) + 1); + } + } + return counts; + }, [allTasks]); + + function handleOpenProject(projectId: string) { + addProjectTab(projectId); + void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); + } + + function handleWizardSetupStarted(projectId: string) { + setWizardOpen(false); + addProjectTab(projectId); + addToast('Project created — setup running in background', 'success'); + void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); + } + + function handleProjectCreated(projectId: string) { + setCreateWizardOpen(false); + addProjectTab(projectId); + addToast('Project created — setup running in background', 'success'); + void navigate({ to: projectViewPath(projectId, PROJECT_VIEWS.TASKS) }); + } + + function handleEditProject(e: React.MouseEvent | React.KeyboardEvent, project: Project) { + e.stopPropagation(); + setEditingProject(project); + } + + function handleRemoveProject(e: React.MouseEvent | React.KeyboardEvent, projectId: string) { + e.stopPropagation(); + removeProject.mutate(projectId); + } + + return { + projects, + isLoading, + filteredProjects, + metrics, + taskCountByProject, + editingProject, + wizardOpen, + createWizardOpen, + searchQuery, + setEditingProject, + setWizardOpen, + setCreateWizardOpen, + setSearchQuery, + handleOpenProject, + handleWizardSetupStarted, + handleProjectCreated, + handleEditProject, + handleRemoveProject, + }; +} diff --git a/src/renderer/features/projects/components/SetupProgressModal.tsx b/src/renderer/features/projects/components/SetupProgressModal.tsx index d179f257..e14f5319 100644 --- a/src/renderer/features/projects/components/SetupProgressModal.tsx +++ b/src/renderer/features/projects/components/SetupProgressModal.tsx @@ -1,157 +1 @@ -/** - * SetupProgressModal — Full overlay showing project setup pipeline progress. - * - * Listens to setup progress events via useSetupProgress and renders - * a vertical list of steps with animated status indicators. - */ - -import { AlertCircle, Check, Loader2, Minus } from 'lucide-react'; - -import type { SetupStep, SetupStepStatus } from '@shared/types/project-setup'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@ui'; - -import { useSetupProgress } from '../hooks/useSetupProgress'; - -interface SetupProgressModalProps { - open: boolean; - projectId: string; - onClose: () => void; - onComplete?: () => void; -} - -function getStepIcon(status: SetupStepStatus): React.ReactNode { - if (status === 'running') { - return <Loader2 className="h-4 w-4 animate-spin text-primary" />; - } - if (status === 'done') { - return <Check className="h-4 w-4 text-success" />; - } - if (status === 'error') { - return <AlertCircle className="h-4 w-4 text-destructive" />; - } - if (status === 'skipped') { - return <Minus className="text-muted-foreground h-4 w-4" />; - } - // pending - return <div className="bg-muted h-4 w-4 rounded-full" />; -} - -function getStepLabelClass(status: SetupStepStatus): string { - if (status === 'running') return 'text-foreground font-medium'; - if (status === 'done') return 'text-muted-foreground'; - if (status === 'error') return 'text-destructive font-medium'; - if (status === 'skipped') return 'text-muted-foreground line-through'; - return 'text-muted-foreground'; -} - -function StepRow({ step }: { step: SetupStep }) { - return ( - <div className="flex items-start gap-3 py-2"> - <div className="mt-0.5 flex shrink-0 items-center justify-center"> - {getStepIcon(step.status)} - </div> - <div className="min-w-0 flex-1"> - <p className={cn('text-sm', getStepLabelClass(step.status))}>{step.label}</p> - {step.error ? ( - <p className="text-destructive mt-1 text-xs">{step.error}</p> - ) : null} - </div> - </div> - ); -} - -export function SetupProgressModal({ - open, - projectId, - onClose, - onComplete, -}: SetupProgressModalProps) { - // 1. Hooks - const { steps, isComplete, hasErrors } = useSetupProgress(projectId); - - // 2. Derived state - const hasSteps = steps.length > 0; - - // 3. Handlers - function handleDone() { - if (onComplete) { - onComplete(); - } - onClose(); - } - - // 4. Render - return ( - <Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose(); }}> - <DialogContent className="max-w-md"> - <DialogHeader> - <DialogTitle> - {isComplete ? 'Setup Complete' : 'Setting Up Project'} - </DialogTitle> - </DialogHeader> - - {/* Body */} - <div className="py-2"> - {hasSteps ? ( - <div className="divide-border divide-y"> - {steps.map((step) => ( - <StepRow key={step.id} step={step} /> - ))} - </div> - ) : ( - <div className="flex flex-col items-center justify-center py-8"> - <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" /> - <span className="text-muted-foreground mt-3 text-sm"> - Preparing setup pipeline... - </span> - <span className="text-muted-foreground mt-1 text-xs"> - Analyzing your project and configuring tools - </span> - </div> - )} - - {/* Completion status */} - {isComplete ? ( - <div - className={cn( - 'mt-4 rounded-md p-3', - hasErrors ? 'bg-destructive/10' : 'bg-success/10', - )} - > - <p - className={cn( - 'text-sm font-medium', - hasErrors ? 'text-destructive' : 'text-success', - )} - > - {hasErrors - ? 'Setup completed with errors. Some steps may need manual attention.' - : 'All setup steps completed successfully.'} - </p> - </div> - ) : null} - </div> - - <DialogFooter> - {isComplete ? ( - <Button onClick={handleDone}>Done</Button> - ) : ( - <Button variant="ghost" onClick={onClose}> - Close - </Button> - )} - </DialogFooter> - </DialogContent> - </Dialog> - ); -} +export { SetupProgressModal } from './SetupProgressModal/index'; diff --git a/src/renderer/features/projects/components/SetupProgressModal/SetupProgressModal.tsx b/src/renderer/features/projects/components/SetupProgressModal/SetupProgressModal.tsx new file mode 100644 index 00000000..2cd94678 --- /dev/null +++ b/src/renderer/features/projects/components/SetupProgressModal/SetupProgressModal.tsx @@ -0,0 +1,152 @@ +/** + * SetupProgressModal — Full overlay showing project setup pipeline progress. + * + * Listens to setup progress events via useSetupProgress and renders + * a vertical list of steps with animated status indicators. + */ + +import { AlertCircle, Check, Loader2, Minus } from 'lucide-react'; + +import type { SetupStep, SetupStepStatus } from '@shared/types/project-setup'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@ui'; + +import { useSetupProgress } from '../../hooks/useSetupProgress'; + +interface SetupProgressModalProps { + open: boolean; + projectId: string; + onClose: () => void; + onComplete?: () => void; +} + +function getStepIcon(status: SetupStepStatus): React.ReactNode { + if (status === 'running') { + return <Loader2 className="h-4 w-4 animate-spin text-primary" />; + } + if (status === 'done') { + return <Check className="h-4 w-4 text-success" />; + } + if (status === 'error') { + return <AlertCircle className="h-4 w-4 text-destructive" />; + } + if (status === 'skipped') { + return <Minus className="text-muted-foreground h-4 w-4" />; + } + return <div className="bg-muted h-4 w-4 rounded-full" />; +} + +function getStepLabelClass(status: SetupStepStatus): string { + if (status === 'running') return 'text-foreground font-medium'; + if (status === 'done') return 'text-muted-foreground'; + if (status === 'error') return 'text-destructive font-medium'; + if (status === 'skipped') return 'text-muted-foreground line-through'; + return 'text-muted-foreground'; +} + +function StepRow({ step }: { step: SetupStep }) { + return ( + <div className="flex items-start gap-3 py-2"> + <div className="mt-0.5 flex shrink-0 items-center justify-center"> + {getStepIcon(step.status)} + </div> + <div className="min-w-0 flex-1"> + <p className={cn('text-sm', getStepLabelClass(step.status))}>{step.label}</p> + {step.error ? ( + <p className="text-destructive mt-1 text-xs">{step.error}</p> + ) : null} + </div> + </div> + ); +} + +export function SetupProgressModal({ + open, + projectId, + onClose, + onComplete, +}: SetupProgressModalProps) { + const { steps, isComplete, hasErrors } = useSetupProgress(projectId); + + const hasSteps = steps.length > 0; + + function handleDone() { + if (onComplete) { + onComplete(); + } + onClose(); + } + + return ( + <Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose(); }}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle> + {isComplete ? 'Setup Complete' : 'Setting Up Project'} + </DialogTitle> + </DialogHeader> + + {/* Body */} + <div className="py-2"> + {hasSteps ? ( + <div className="divide-border divide-y"> + {steps.map((step) => ( + <StepRow key={step.id} step={step} /> + ))} + </div> + ) : ( + <div className="flex flex-col items-center justify-center py-8"> + <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" /> + <span className="text-muted-foreground mt-3 text-sm"> + Preparing setup pipeline... + </span> + <span className="text-muted-foreground mt-1 text-xs"> + Analyzing your project and configuring tools + </span> + </div> + )} + + {/* Completion status */} + {isComplete ? ( + <div + className={cn( + 'mt-4 rounded-md p-3', + hasErrors ? 'bg-destructive/10' : 'bg-success/10', + )} + > + <p + className={cn( + 'text-sm font-medium', + hasErrors ? 'text-destructive' : 'text-success', + )} + > + {hasErrors + ? 'Setup completed with errors. Some steps may need manual attention.' + : 'All setup steps completed successfully.'} + </p> + </div> + ) : null} + </div> + + <DialogFooter> + {isComplete ? ( + <Button onClick={handleDone}>Done</Button> + ) : ( + <Button variant="ghost" onClick={onClose}> + Close + </Button> + )} + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/projects/components/SetupProgressModal/index.ts b/src/renderer/features/projects/components/SetupProgressModal/index.ts new file mode 100644 index 00000000..82d2aad9 --- /dev/null +++ b/src/renderer/features/projects/components/SetupProgressModal/index.ts @@ -0,0 +1 @@ +export { SetupProgressModal } from './SetupProgressModal'; diff --git a/src/renderer/features/projects/components/SubprojectSelector.tsx b/src/renderer/features/projects/components/SubprojectSelector.tsx index 664e8c96..608f24b1 100644 --- a/src/renderer/features/projects/components/SubprojectSelector.tsx +++ b/src/renderer/features/projects/components/SubprojectSelector.tsx @@ -1,156 +1 @@ -/** - * SubprojectSelector — Shows sub-projects for a project with add/remove controls - */ - -import { useState } from 'react'; - -import { FolderGit2, FolderTree, Loader2, Plus, Trash2 } from 'lucide-react'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { Button, Input } from '@ui'; - -import { useRepoStructure } from '../api/useGit'; -import { useSubProjects, useCreateSubProject, useDeleteSubProject } from '../api/useProjects'; - -interface SubprojectSelectorProps { - projectId: string; - repoPath: string; -} - -function structureLabel(structure: string): string { - if (structure === 'monorepo') return 'Monorepo'; - if (structure === 'polyrepo') return 'Polyrepo'; - return 'Single repo'; -} - -function structureBadgeClass(structure: string): string { - if (structure === 'monorepo') return 'bg-info/10 text-info'; - if (structure === 'polyrepo') return 'bg-warning/10 text-warning'; - return 'bg-muted text-muted-foreground'; -} - -export function SubprojectSelector({ projectId, repoPath }: SubprojectSelectorProps) { - const { data: structureData, isLoading: structureLoading } = useRepoStructure(repoPath); - const { data: subProjects, isLoading: subProjectsLoading } = useSubProjects(projectId); - const createSubProject = useCreateSubProject(); - const deleteSubProject = useDeleteSubProject(); - - const [newName, setNewName] = useState(''); - const [newPath, setNewPath] = useState(''); - - function handleAdd() { - if (newName.trim().length === 0 || newPath.trim().length === 0) return; - createSubProject.mutate( - { projectId, name: newName.trim(), relativePath: newPath.trim() }, - { - onSuccess: () => { - setNewName(''); - setNewPath(''); - }, - }, - ); - } - - function handleRemove(subProjectId: string) { - deleteSubProject.mutate({ projectId, subProjectId }); - } - - if (structureLoading || subProjectsLoading) { - return ( - <div className="flex items-center gap-2 text-sm"> - <Loader2 className="h-3.5 w-3.5 animate-spin" /> - <span className="text-muted-foreground">Loading sub-projects...</span> - </div> - ); - } - - return ( - <div className="space-y-4"> - {/* Structure badge */} - {structureData ? ( - <div className="flex items-center gap-2"> - <FolderTree className="text-muted-foreground h-4 w-4" /> - <span - className={cn( - 'rounded-full px-2 py-0.5 text-xs font-medium', - structureBadgeClass(structureData.structure), - )} - > - {structureLabel(structureData.structure)} - </span> - </div> - ) : null} - - {/* Sub-project list */} - {subProjects && subProjects.length > 0 ? ( - <div className="space-y-1"> - {subProjects.map((sub) => ( - <div - key={sub.id} - className="border-border flex items-center gap-2 rounded-md border px-3 py-2 text-sm" - > - <FolderGit2 className="text-muted-foreground h-4 w-4 shrink-0" /> - <div className="min-w-0 flex-1"> - <span className="font-medium">{sub.name}</span> - <span className="text-muted-foreground ml-2 text-xs">{sub.relativePath}</span> - </div> - <Button - aria-label={`Remove ${sub.name}`} - className="text-muted-foreground hover:text-destructive rounded p-1 transition-colors" - disabled={deleteSubProject.isPending} - size="icon" - type="button" - variant="ghost" - onClick={() => handleRemove(sub.id)} - > - <Trash2 className="h-3.5 w-3.5" /> - </Button> - </div> - ))} - </div> - ) : ( - <p className="text-muted-foreground text-sm">No sub-projects configured.</p> - )} - - {/* Add sub-project form */} - <div className="border-border space-y-2 rounded-lg border p-3"> - <p className="text-xs font-medium">Add Sub-project</p> - <div className="flex gap-2"> - <Input - className="flex-1" - placeholder="Name" - type="text" - value={newName} - onChange={(e) => setNewName(e.target.value)} - /> - <Input - className="flex-1" - placeholder="Relative path" - type="text" - value={newPath} - onChange={(e) => setNewPath(e.target.value)} - /> - <Button - type="button" - className={cn( - 'rounded-md px-3 py-1.5 text-sm font-medium', - )} - disabled={ - createSubProject.isPending || - newName.trim().length === 0 || - newPath.trim().length === 0 - } - onClick={handleAdd} - > - {createSubProject.isPending ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <Plus className="h-4 w-4" /> - )} - </Button> - </div> - </div> - </div> - ); -} +export { SubprojectSelector } from './SubprojectSelector/index'; diff --git a/src/renderer/features/projects/components/SubprojectSelector/SubprojectSelector.tsx b/src/renderer/features/projects/components/SubprojectSelector/SubprojectSelector.tsx new file mode 100644 index 00000000..629a394a --- /dev/null +++ b/src/renderer/features/projects/components/SubprojectSelector/SubprojectSelector.tsx @@ -0,0 +1,142 @@ +/** + * SubprojectSelector — Shows sub-projects for a project with add/remove controls + */ + +import { FolderGit2, FolderTree, Loader2, Plus, Trash2 } from 'lucide-react'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { Button, Input } from '@ui'; + +import { useSubprojectSelector } from './useSubprojectSelector'; + +interface SubprojectSelectorProps { + projectId: string; + repoPath: string; +} + +function structureLabel(structure: string): string { + if (structure === 'monorepo') return 'Monorepo'; + if (structure === 'polyrepo') return 'Polyrepo'; + return 'Single repo'; +} + +function structureBadgeClass(structure: string): string { + if (structure === 'monorepo') return 'bg-info/10 text-info'; + if (structure === 'polyrepo') return 'bg-warning/10 text-warning'; + return 'bg-muted text-muted-foreground'; +} + +export function SubprojectSelector({ projectId, repoPath }: SubprojectSelectorProps) { + const { + structureData, + subProjects, + isLoading, + newName, + newPath, + createSubProject, + deleteSubProject, + setNewName, + setNewPath, + handleAdd, + handleRemove, + } = useSubprojectSelector({ projectId, repoPath }); + + if (isLoading) { + return ( + <div className="flex items-center gap-2 text-sm"> + <Loader2 className="h-3.5 w-3.5 animate-spin" /> + <span className="text-muted-foreground">Loading sub-projects...</span> + </div> + ); + } + + return ( + <div className="space-y-4"> + {/* Structure badge */} + {structureData ? ( + <div className="flex items-center gap-2"> + <FolderTree className="text-muted-foreground h-4 w-4" /> + <span + className={cn( + 'rounded-full px-2 py-0.5 text-xs font-medium', + structureBadgeClass(structureData.structure), + )} + > + {structureLabel(structureData.structure)} + </span> + </div> + ) : null} + + {/* Sub-project list */} + {subProjects && subProjects.length > 0 ? ( + <div className="space-y-1"> + {subProjects.map((sub) => ( + <div + key={sub.id} + className="border-border flex items-center gap-2 rounded-md border px-3 py-2 text-sm" + > + <FolderGit2 className="text-muted-foreground h-4 w-4 shrink-0" /> + <div className="min-w-0 flex-1"> + <span className="font-medium">{sub.name}</span> + <span className="text-muted-foreground ml-2 text-xs">{sub.relativePath}</span> + </div> + <Button + aria-label={`Remove ${sub.name}`} + className="text-muted-foreground hover:text-destructive rounded p-1 transition-colors" + disabled={deleteSubProject.isPending} + size="icon" + type="button" + variant="ghost" + onClick={() => handleRemove(sub.id)} + > + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </div> + ))} + </div> + ) : ( + <p className="text-muted-foreground text-sm">No sub-projects configured.</p> + )} + + {/* Add sub-project form */} + <div className="border-border space-y-2 rounded-lg border p-3"> + <p className="text-xs font-medium">Add Sub-project</p> + <div className="flex gap-2"> + <Input + className="flex-1" + placeholder="Name" + type="text" + value={newName} + onChange={(e) => setNewName(e.target.value)} + /> + <Input + className="flex-1" + placeholder="Relative path" + type="text" + value={newPath} + onChange={(e) => setNewPath(e.target.value)} + /> + <Button + type="button" + className={cn( + 'rounded-md px-3 py-1.5 text-sm font-medium', + )} + disabled={ + createSubProject.isPending || + newName.trim().length === 0 || + newPath.trim().length === 0 + } + onClick={handleAdd} + > + {createSubProject.isPending ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Plus className="h-4 w-4" /> + )} + </Button> + </div> + </div> + </div> + ); +} diff --git a/src/renderer/features/projects/components/SubprojectSelector/index.ts b/src/renderer/features/projects/components/SubprojectSelector/index.ts new file mode 100644 index 00000000..15d7a07a --- /dev/null +++ b/src/renderer/features/projects/components/SubprojectSelector/index.ts @@ -0,0 +1,2 @@ +export { SubprojectSelector } from './SubprojectSelector'; +export { useSubprojectSelector } from './useSubprojectSelector'; diff --git a/src/renderer/features/projects/components/SubprojectSelector/useSubprojectSelector.ts b/src/renderer/features/projects/components/SubprojectSelector/useSubprojectSelector.ts new file mode 100644 index 00000000..84dbfad3 --- /dev/null +++ b/src/renderer/features/projects/components/SubprojectSelector/useSubprojectSelector.ts @@ -0,0 +1,52 @@ +import { useState } from 'react'; + +import { useRepoStructure } from '../../api/useGit'; +import { useSubProjects, useCreateSubProject, useDeleteSubProject } from '../../api/useProjects'; + +interface UseSubprojectSelectorProps { + projectId: string; + repoPath: string; +} + +export function useSubprojectSelector({ projectId, repoPath }: UseSubprojectSelectorProps) { + const { data: structureData, isLoading: structureLoading } = useRepoStructure(repoPath); + const { data: subProjects, isLoading: subProjectsLoading } = useSubProjects(projectId); + const createSubProject = useCreateSubProject(); + const deleteSubProject = useDeleteSubProject(); + + const [newName, setNewName] = useState(''); + const [newPath, setNewPath] = useState(''); + + const isLoading = structureLoading || subProjectsLoading; + + function handleAdd() { + if (newName.trim().length === 0 || newPath.trim().length === 0) return; + createSubProject.mutate( + { projectId, name: newName.trim(), relativePath: newPath.trim() }, + { + onSuccess: () => { + setNewName(''); + setNewPath(''); + }, + }, + ); + } + + function handleRemove(subProjectId: string) { + deleteSubProject.mutate({ projectId, subProjectId }); + } + + return { + structureData, + subProjects, + isLoading, + newName, + newPath, + createSubProject, + deleteSubProject, + setNewName, + setNewPath, + handleAdd, + handleRemove, + }; +} diff --git a/src/renderer/features/projects/components/WorktreeManager.tsx b/src/renderer/features/projects/components/WorktreeManager.tsx index a5183a26..339d58c3 100644 --- a/src/renderer/features/projects/components/WorktreeManager.tsx +++ b/src/renderer/features/projects/components/WorktreeManager.tsx @@ -1,167 +1 @@ -/** - * WorktreeManager — Lists and manages git worktrees for a project - */ - -import { useState } from 'react'; - -import { FolderGit2, GitBranch, GitMerge, Plus, Trash2 } from 'lucide-react'; - -import { Button, Input, Spinner } from '@ui'; - -import { MergeConfirmModal } from '@features/merge'; - -import { useGitBranches, useWorktrees, useCreateWorktree, useRemoveWorktree } from '../api/useGit'; - -interface WorktreeManagerProps { - projectId: string; - repoPath: string; -} - -export function WorktreeManager({ projectId, repoPath }: WorktreeManagerProps) { - const { data: worktrees, isLoading } = useWorktrees(projectId); - const { data: branches } = useGitBranches(repoPath); - const createWorktree = useCreateWorktree(); - const removeWorktree = useRemoveWorktree(); - - const [branch, setBranch] = useState(''); - const [worktreePath, setWorktreePath] = useState(''); - const [showForm, setShowForm] = useState(false); - const [mergeModalOpen, setMergeModalOpen] = useState(false); - const [selectedWorktreeBranch, setSelectedWorktreeBranch] = useState<string | null>(null); - - // Determine the main branch (main or master) - const mainBranch = - branches?.find((b) => b.name === 'main' || b.name === 'master')?.name ?? 'main'; - - function handleOpenMerge(wtBranch: string) { - setSelectedWorktreeBranch(wtBranch); - setMergeModalOpen(true); - } - - function handleCloseMerge() { - setMergeModalOpen(false); - setSelectedWorktreeBranch(null); - } - - async function handleCreate() { - if (!branch || !worktreePath) return; - await createWorktree.mutateAsync({ repoPath, worktreePath, branch }); - setBranch(''); - setWorktreePath(''); - setShowForm(false); - } - - function handleRemove(wtPath: string) { - removeWorktree.mutate({ repoPath, worktreePath: wtPath }); - } - - if (isLoading) { - return ( - <div className="flex items-center justify-center p-8"> - <Spinner className="text-muted-foreground" size="md" /> - </div> - ); - } - - return ( - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <h3 className="flex items-center gap-2 text-sm font-medium"> - <FolderGit2 className="h-4 w-4" /> - Worktrees - </h3> - <Button - className="text-muted-foreground hover:text-foreground gap-1 text-xs" - size="sm" - variant="ghost" - onClick={() => setShowForm(!showForm)} - > - <Plus className="h-3 w-3" /> - New - </Button> - </div> - - {showForm ? ( - <div className="border-border space-y-2 rounded-md border p-3"> - <Input - className="text-sm" - placeholder="Branch name" - value={branch} - onChange={(e) => setBranch(e.target.value)} - /> - <Input - className="text-sm" - placeholder="Worktree path" - value={worktreePath} - onChange={(e) => setWorktreePath(e.target.value)} - /> - <div className="flex gap-2"> - <Button - disabled={!branch || !worktreePath || createWorktree.isPending} - size="sm" - onClick={handleCreate} - > - {createWorktree.isPending ? 'Creating...' : 'Create'} - </Button> - <Button - size="sm" - variant="ghost" - onClick={() => setShowForm(false)} - > - Cancel - </Button> - </div> - </div> - ) : null} - - {worktrees && worktrees.length > 0 ? ( - <div className="space-y-1"> - {worktrees.map((wt) => ( - <div - key={wt.id} - className="border-border flex items-center justify-between rounded-md border px-3 py-2 text-sm" - > - <div className="flex items-center gap-2"> - <GitBranch className="text-muted-foreground h-3.5 w-3.5" /> - <span className="font-medium">{wt.branch}</span> - <span className="text-muted-foreground text-xs">{wt.path}</span> - </div> - <div className="flex items-center gap-1"> - <Button - className="text-muted-foreground hover:text-primary h-7 w-7" - size="icon" - title={`Merge ${wt.branch} into ${mainBranch}`} - variant="ghost" - onClick={() => handleOpenMerge(wt.branch)} - > - <GitMerge className="h-3.5 w-3.5" /> - </Button> - <Button - className="text-muted-foreground hover:text-destructive h-7 w-7" - size="icon" - variant="ghost" - onClick={() => handleRemove(wt.path)} - > - <Trash2 className="h-3.5 w-3.5" /> - </Button> - </div> - </div> - ))} - </div> - ) : ( - <p className="text-muted-foreground text-xs">No worktrees created yet.</p> - )} - - {/* Merge modal */} - {selectedWorktreeBranch === null ? null : ( - <MergeConfirmModal - isOpen={mergeModalOpen} - repoPath={repoPath} - sourceBranch={selectedWorktreeBranch} - targetBranch={mainBranch} - onClose={handleCloseMerge} - /> - )} - </div> - ); -} +export { WorktreeManager } from './WorktreeManager/index'; diff --git a/src/renderer/features/projects/components/WorktreeManager/WorktreeManager.tsx b/src/renderer/features/projects/components/WorktreeManager/WorktreeManager.tsx new file mode 100644 index 00000000..84215e7c --- /dev/null +++ b/src/renderer/features/projects/components/WorktreeManager/WorktreeManager.tsx @@ -0,0 +1,147 @@ +/** + * WorktreeManager — Lists and manages git worktrees for a project + */ + +import { FolderGit2, GitBranch, GitMerge, Plus, Trash2 } from 'lucide-react'; + +import { Button, Heading, Input, Spinner } from '@ui'; + +import { MergeConfirmModal } from '@features/merge'; + +import { useWorktreeManager } from './useWorktreeManager'; + +interface WorktreeManagerProps { + projectId: string; + repoPath: string; +} + +export function WorktreeManager({ projectId, repoPath }: WorktreeManagerProps) { + const { + worktrees, + isLoading, + branch, + worktreePath, + showForm, + mergeModalOpen, + selectedWorktreeBranch, + mainBranch, + createWorktree, + setBranch, + setWorktreePath, + setShowForm, + handleOpenMerge, + handleCloseMerge, + handleCreate, + handleRemove, + } = useWorktreeManager({ projectId, repoPath }); + + if (isLoading) { + return ( + <div className="flex items-center justify-center p-8"> + <Spinner className="text-muted-foreground" size="md" /> + </div> + ); + } + + return ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <Heading as="h3" className="flex items-center gap-2 text-sm font-medium"> + <FolderGit2 className="h-4 w-4" /> + Worktrees + </Heading> + <Button + className="text-muted-foreground hover:text-foreground gap-1 text-xs" + size="sm" + variant="ghost" + onClick={() => setShowForm(!showForm)} + > + <Plus className="h-3 w-3" /> + New + </Button> + </div> + + {showForm ? ( + <div className="border-border space-y-2 rounded-md border p-3"> + <Input + className="text-sm" + placeholder="Branch name" + value={branch} + onChange={(e) => setBranch(e.target.value)} + /> + <Input + className="text-sm" + placeholder="Worktree path" + value={worktreePath} + onChange={(e) => setWorktreePath(e.target.value)} + /> + <div className="flex gap-2"> + <Button + disabled={!branch || !worktreePath || createWorktree.isPending} + size="sm" + onClick={handleCreate} + > + {createWorktree.isPending ? 'Creating...' : 'Create'} + </Button> + <Button + size="sm" + variant="ghost" + onClick={() => setShowForm(false)} + > + Cancel + </Button> + </div> + </div> + ) : null} + + {worktrees && worktrees.length > 0 ? ( + <div className="space-y-1"> + {worktrees.map((wt) => ( + <div + key={wt.id} + className="border-border flex items-center justify-between rounded-md border px-3 py-2 text-sm" + > + <div className="flex items-center gap-2"> + <GitBranch className="text-muted-foreground h-3.5 w-3.5" /> + <span className="font-medium">{wt.branch}</span> + <span className="text-muted-foreground text-xs">{wt.path}</span> + </div> + <div className="flex items-center gap-1"> + <Button + className="text-muted-foreground hover:text-primary h-7 w-7" + size="icon" + title={`Merge ${wt.branch} into ${mainBranch}`} + variant="ghost" + onClick={() => handleOpenMerge(wt.branch)} + > + <GitMerge className="h-3.5 w-3.5" /> + </Button> + <Button + className="text-muted-foreground hover:text-destructive h-7 w-7" + size="icon" + variant="ghost" + onClick={() => handleRemove(wt.path)} + > + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </div> + </div> + ))} + </div> + ) : ( + <p className="text-muted-foreground text-xs">No worktrees created yet.</p> + )} + + {/* Merge modal */} + {selectedWorktreeBranch === null ? null : ( + <MergeConfirmModal + isOpen={mergeModalOpen} + repoPath={repoPath} + sourceBranch={selectedWorktreeBranch} + targetBranch={mainBranch} + onClose={handleCloseMerge} + /> + )} + </div> + ); +} diff --git a/src/renderer/features/projects/components/WorktreeManager/index.ts b/src/renderer/features/projects/components/WorktreeManager/index.ts new file mode 100644 index 00000000..90d631e9 --- /dev/null +++ b/src/renderer/features/projects/components/WorktreeManager/index.ts @@ -0,0 +1,2 @@ +export { WorktreeManager } from './WorktreeManager'; +export { useWorktreeManager } from './useWorktreeManager'; diff --git a/src/renderer/features/projects/components/WorktreeManager/useWorktreeManager.ts b/src/renderer/features/projects/components/WorktreeManager/useWorktreeManager.ts new file mode 100644 index 00000000..bd058748 --- /dev/null +++ b/src/renderer/features/projects/components/WorktreeManager/useWorktreeManager.ts @@ -0,0 +1,66 @@ +import { useState } from 'react'; + +import { useGitBranches, useWorktrees, useCreateWorktree, useRemoveWorktree } from '../../api/useGit'; + +interface UseWorktreeManagerProps { + projectId: string; + repoPath: string; +} + +export function useWorktreeManager({ projectId, repoPath }: UseWorktreeManagerProps) { + const { data: worktrees, isLoading } = useWorktrees(projectId); + const { data: branches } = useGitBranches(repoPath); + const createWorktree = useCreateWorktree(); + const removeWorktree = useRemoveWorktree(); + + const [branch, setBranch] = useState(''); + const [worktreePath, setWorktreePath] = useState(''); + const [showForm, setShowForm] = useState(false); + const [mergeModalOpen, setMergeModalOpen] = useState(false); + const [selectedWorktreeBranch, setSelectedWorktreeBranch] = useState<string | null>(null); + + const mainBranch = + branches?.find((b) => b.name === 'main' || b.name === 'master')?.name ?? 'main'; + + function handleOpenMerge(wtBranch: string) { + setSelectedWorktreeBranch(wtBranch); + setMergeModalOpen(true); + } + + function handleCloseMerge() { + setMergeModalOpen(false); + setSelectedWorktreeBranch(null); + } + + async function handleCreate() { + if (!branch || !worktreePath) return; + await createWorktree.mutateAsync({ repoPath, worktreePath, branch }); + setBranch(''); + setWorktreePath(''); + setShowForm(false); + } + + function handleRemove(wtPath: string) { + removeWorktree.mutate({ repoPath, worktreePath: wtPath }); + } + + return { + worktrees, + isLoading, + branch, + worktreePath, + showForm, + mergeModalOpen, + selectedWorktreeBranch, + mainBranch, + createWorktree, + repoPath, + setBranch, + setWorktreePath, + setShowForm, + handleOpenMerge, + handleCloseMerge, + handleCreate, + handleRemove, + }; +} diff --git a/src/renderer/features/projects/components/create-wizard-steps/StepDetails.tsx b/src/renderer/features/projects/components/create-wizard-steps/StepDetails.tsx index d0672a6b..0265f8a0 100644 --- a/src/renderer/features/projects/components/create-wizard-steps/StepDetails.tsx +++ b/src/renderer/features/projects/components/create-wizard-steps/StepDetails.tsx @@ -4,7 +4,7 @@ import { FolderOpen, Loader2 } from 'lucide-react'; -import { Button, Input, Label, Textarea } from '@ui'; +import { Button, Heading, Input, Label, Textarea } from '@ui'; interface StepDetailsProps { name: string; @@ -27,7 +27,7 @@ export function StepDetails({ }: StepDetailsProps) { return ( <div className="space-y-4"> - <h3 className="text-sm font-medium">Project Details</h3> + <Heading as="h3" className="text-sm font-medium">Project Details</Heading> <div> <Label className="text-muted-foreground mb-1 block text-sm" htmlFor="create-wizard-name"> diff --git a/src/renderer/features/projects/components/create-wizard-steps/StepGitHub.tsx b/src/renderer/features/projects/components/create-wizard-steps/StepGitHub.tsx index a9e65578..ae44cec4 100644 --- a/src/renderer/features/projects/components/create-wizard-steps/StepGitHub.tsx +++ b/src/renderer/features/projects/components/create-wizard-steps/StepGitHub.tsx @@ -6,7 +6,7 @@ import { AlertTriangle } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; -import { Input, Label, Switch } from '@ui'; +import { Heading, Input, Label, Switch } from '@ui'; interface StepGitHubProps { createGitHubRepo: boolean; @@ -23,7 +23,7 @@ export function StepGitHub({ }: StepGitHubProps) { return ( <div className="space-y-4"> - <h3 className="text-sm font-medium">GitHub Repository</h3> + <Heading as="h3" className="text-sm font-medium">GitHub Repository</Heading> {/* Toggle: Create GitHub repo */} <div className="flex items-center justify-between"> diff --git a/src/renderer/features/projects/components/create-wizard-steps/StepReview.tsx b/src/renderer/features/projects/components/create-wizard-steps/StepReview.tsx index c48ad23e..39d663e3 100644 --- a/src/renderer/features/projects/components/create-wizard-steps/StepReview.tsx +++ b/src/renderer/features/projects/components/create-wizard-steps/StepReview.tsx @@ -4,6 +4,8 @@ import { Check, FolderOpen, GitBranch, Layers } from 'lucide-react'; +import { Heading } from '@ui'; + interface StepReviewProps { name: string; description: string; @@ -23,7 +25,7 @@ export function StepReview({ }: StepReviewProps) { return ( <div className="space-y-4"> - <h3 className="text-sm font-medium">Review & Create</h3> + <Heading as="h3" className="text-sm font-medium">Review & Create</Heading> <p className="text-muted-foreground text-xs"> Review your project settings before creating. </p> diff --git a/src/renderer/features/projects/components/create-wizard-steps/StepTechStack.tsx b/src/renderer/features/projects/components/create-wizard-steps/StepTechStack.tsx index 86ba2913..1c5c50ac 100644 --- a/src/renderer/features/projects/components/create-wizard-steps/StepTechStack.tsx +++ b/src/renderer/features/projects/components/create-wizard-steps/StepTechStack.tsx @@ -4,7 +4,7 @@ import { cn } from '@renderer/shared/lib/utils'; -import { Button } from '@ui'; +import { Button, Heading } from '@ui'; const TECH_STACK_OPTIONS = [ { category: 'Frontend', items: ['React', 'Vue', 'Next.js', 'Svelte'] }, @@ -30,7 +30,7 @@ export function StepTechStack({ techStack, onTechStackChange }: StepTechStackPro return ( <div className="space-y-4"> <div> - <h3 className="text-sm font-medium">Tech Stack</h3> + <Heading as="h3" className="text-sm font-medium">Tech Stack</Heading> <p className="text-muted-foreground mt-1 text-xs"> Select the technologies this project will use. These hints help generate CLAUDE.md and documentation. diff --git a/src/renderer/features/projects/components/wizard-steps/StepConfigure.tsx b/src/renderer/features/projects/components/wizard-steps/StepConfigure.tsx index e1a462b7..60e1c222 100644 --- a/src/renderer/features/projects/components/wizard-steps/StepConfigure.tsx +++ b/src/renderer/features/projects/components/wizard-steps/StepConfigure.tsx @@ -3,6 +3,7 @@ */ import { + Heading, Input, Label, Select, @@ -47,7 +48,7 @@ export function StepConfigure({ }: StepConfigureProps) { return ( <div className="space-y-4"> - <h3 className="text-sm font-medium">Project Settings</h3> + <Heading as="h3" className="text-sm font-medium">Project Settings</Heading> <div> <Label className="text-muted-foreground mb-1 block text-sm" htmlFor="wizard-name"> Project Name @@ -79,14 +80,14 @@ export function StepConfigure({ Workspace </Label> <Select - value={workspaceId ?? ''} - onValueChange={(val) => onWorkspaceChange(val.length > 0 ? val : null)} + value={workspaceId ?? '__none__'} + onValueChange={(val) => onWorkspaceChange(val === '__none__' ? null : val)} > <SelectTrigger id="wizard-workspace"> <SelectValue placeholder="No workspace" /> </SelectTrigger> <SelectContent> - <SelectItem value="">No workspace</SelectItem> + <SelectItem value="__none__">No workspace</SelectItem> {workspaces.map((ws) => ( <SelectItem key={ws.id} value={ws.id}> {ws.name} diff --git a/src/renderer/features/projects/components/wizard-steps/StepConfirm.tsx b/src/renderer/features/projects/components/wizard-steps/StepConfirm.tsx index ffbb8528..372bb6dc 100644 --- a/src/renderer/features/projects/components/wizard-steps/StepConfirm.tsx +++ b/src/renderer/features/projects/components/wizard-steps/StepConfirm.tsx @@ -2,6 +2,8 @@ * StepConfirm — Wizard step for reviewing and confirming project setup */ +import { Heading } from '@ui'; + interface WorkspaceOption { id: string; name: string; @@ -32,7 +34,7 @@ export function StepConfirm({ }: StepConfirmProps) { return ( <div className="space-y-4"> - <h3 className="text-sm font-medium">Confirm Project Setup</h3> + <Heading as="h3" className="text-sm font-medium">Confirm Project Setup</Heading> <div className="border-border rounded-lg border p-4"> <dl className="space-y-2 text-sm"> <div className="flex justify-between"> diff --git a/src/renderer/features/projects/components/wizard-steps/StepDetection.tsx b/src/renderer/features/projects/components/wizard-steps/StepDetection.tsx index 7825e275..ca9b5dea 100644 --- a/src/renderer/features/projects/components/wizard-steps/StepDetection.tsx +++ b/src/renderer/features/projects/components/wizard-steps/StepDetection.tsx @@ -5,6 +5,8 @@ import type { PROJECTS } from '@shared/ipc/projects/channels'; import type { InvokeOutput } from '@shared/ipc-contract'; +import { Heading } from '@ui'; + import { RepoTypeSelector } from '../RepoTypeSelector'; import { SubRepoDetector } from '../SubRepoDetector'; @@ -29,7 +31,7 @@ export function StepDetection({ }: StepDetectionProps) { return ( <div> - <h3 className="mb-3 text-sm font-medium">Repository Detection</h3> + <Heading as="h3" className="mb-3 text-sm font-medium">Repository Detection</Heading> <p className="text-muted-foreground mb-4 text-xs">{selectedPath}</p> <SubRepoDetector detection={detection} error={error} isLoading={isLoading} /> {detection ? ( diff --git a/src/renderer/features/projects/components/wizard-steps/StepSubRepos.tsx b/src/renderer/features/projects/components/wizard-steps/StepSubRepos.tsx index a14d3f7b..493877ec 100644 --- a/src/renderer/features/projects/components/wizard-steps/StepSubRepos.tsx +++ b/src/renderer/features/projects/components/wizard-steps/StepSubRepos.tsx @@ -5,6 +5,8 @@ import type { PROJECTS } from '@shared/ipc/projects/channels'; import type { InvokeOutput } from '@shared/ipc-contract'; +import { Heading } from '@ui'; + import { SubRepoSelector } from '../SubRepoSelector'; type ChildRepo = InvokeOutput<typeof PROJECTS.DETECT.REPO>['childRepos'][number]; @@ -18,7 +20,7 @@ interface StepSubReposProps { export function StepSubRepos({ repos, selected, onSelectionChange }: StepSubReposProps) { return ( <div> - <h3 className="mb-3 text-sm font-medium">Select Repositories</h3> + <Heading as="h3" className="mb-3 text-sm font-medium">Select Repositories</Heading> <SubRepoSelector repos={repos} selected={selected} onSelectionChange={onSelectionChange} /> </div> ); diff --git a/src/renderer/features/projects/hooks/useProjectEvents.ts b/src/renderer/features/projects/hooks/useProjectEvents.ts index 5ab68830..2b1c413a 100644 --- a/src/renderer/features/projects/hooks/useProjectEvents.ts +++ b/src/renderer/features/projects/hooks/useProjectEvents.ts @@ -5,7 +5,6 @@ import { useQueryClient } from '@tanstack/react-query'; import { GIT_EVENTS } from '@shared/ipc/git/channels'; -import { HUB_EVENTS } from '@shared/ipc/hub/channels'; import { PROJECTS_EVENTS } from '@shared/ipc/projects/channels'; import { useIpcEvent } from '@renderer/shared/hooks'; @@ -25,10 +24,4 @@ export function useProjectEvents() { void queryClient.invalidateQueries({ queryKey: gitKeys.worktrees(projectId) }); void queryClient.invalidateQueries({ queryKey: gitKeys.all }); }); - - useIpcEvent(HUB_EVENTS.PROJECT.UPDATED, ({ projectId }) => { - void queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectId) }); - void queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); - void queryClient.invalidateQueries({ queryKey: projectKeys.subProjects(projectId) }); - }); } diff --git a/src/renderer/features/projects/hooks/useWizardNavigation.ts b/src/renderer/features/projects/hooks/useWizardNavigation.ts new file mode 100644 index 00000000..27d7112a --- /dev/null +++ b/src/renderer/features/projects/hooks/useWizardNavigation.ts @@ -0,0 +1,21 @@ +/** + * useWizardNavigation — step progression with forward/backward navigation. + */ + +import { useCallback, useState } from 'react'; + +export function useWizardNavigation(totalSteps: number) { + const [step, setStep] = useState(0); + + const handleNext = useCallback( + () => setStep((s) => Math.min(s + 1, totalSteps - 1)), + [totalSteps], + ); + + const handleBack = useCallback(() => setStep((s) => Math.max(s - 1, 0)), []); + + const isFirst = step === 0; + const isLast = step === totalSteps - 1; + + return { step, setStep, handleNext, handleBack, isFirst, isLast }; +} diff --git a/src/renderer/features/qa-recorder/api/queryKeys.ts b/src/renderer/features/qa-recorder/api/queryKeys.ts deleted file mode 100644 index 3e3033e5..00000000 --- a/src/renderer/features/qa-recorder/api/queryKeys.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * QA Recorder query keys factory - */ -export const qaRecorderKeys = { - all: ['qa-recorder'] as const, - scripts: () => [...qaRecorderKeys.all, 'scripts'] as const, - script: (id: string) => [...qaRecorderKeys.scripts(), id] as const, - runs: () => [...qaRecorderKeys.all, 'runs'] as const, - runsByScript: (scriptId: string) => [...qaRecorderKeys.runs(), scriptId] as const, - run: (runId: string) => [...qaRecorderKeys.runs(), runId] as const, -}; diff --git a/src/renderer/features/qa-recorder/api/useRuns.ts b/src/renderer/features/qa-recorder/api/useRuns.ts deleted file mode 100644 index 6cea1fae..00000000 --- a/src/renderer/features/qa-recorder/api/useRuns.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * React Query hooks for QA run queries and mutations - */ - -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { QA_RECORDER } from '@shared/ipc/qa-recorder/channels'; -import type { QaRunSchema } from '@shared/ipc/qa-recorder/schemas'; - - -import { useMutationErrorToast } from '@renderer/shared/hooks'; -import { ipc } from '@renderer/shared/lib/ipc'; - -import { qaRecorderKeys } from './queryKeys'; - -import type { z } from 'zod'; - -export type QaRun = z.infer<typeof QaRunSchema>; - -/** Fetch all runs, optionally filtered by scriptId */ -export function useRuns(scriptId?: string) { - return useQuery({ - queryKey: scriptId ? qaRecorderKeys.runsByScript(scriptId) : qaRecorderKeys.runs(), - queryFn: () => ipc(QA_RECORDER.LIST.RUNS, { scriptId }), - staleTime: 10_000, - }); -} - -/** Fetch a single run by id */ -export function useRun(runId: string | null) { - return useQuery({ - queryKey: qaRecorderKeys.run(runId ?? ''), - queryFn: () => ipc(QA_RECORDER.GET.RUN, { runId: runId ?? '' }), - enabled: runId !== null && runId.length > 0, - staleTime: 5_000, - }); -} - -/** Run a QA script */ -export function useRunScript() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: ({ - scriptId, - triggeredBy = 'manual', - }: { - scriptId: string; - triggeredBy?: 'manual' | 'scheduled' | 'ci'; - }) => ipc(QA_RECORDER.RUN.SCRIPT, { scriptId, triggeredBy }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: qaRecorderKeys.runs() }); - }, - onError: onError('run QA script'), - }); -} diff --git a/src/renderer/features/qa-recorder/api/useScriptMutations.ts b/src/renderer/features/qa-recorder/api/useScriptMutations.ts deleted file mode 100644 index 661f8023..00000000 --- a/src/renderer/features/qa-recorder/api/useScriptMutations.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * React Query mutation hooks for QA script operations - */ - -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import { QA_RECORDER } from '@shared/ipc/qa-recorder/channels'; -import type { QaRecorderStepSchema } from '@shared/ipc/qa-recorder/schemas'; - - -import { useMutationErrorToast } from '@renderer/shared/hooks'; -import { ipc } from '@renderer/shared/lib/ipc'; - -import { qaRecorderKeys } from './queryKeys'; - -import type { z } from 'zod'; - -export type QaRecorderStep = z.infer<typeof QaRecorderStepSchema>; - -/** Save (create or update) a QA script */ -export function useSaveScript() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: (data: { - id?: string; - name: string; - description?: string; - steps: QaRecorderStep[]; - }) => ipc(QA_RECORDER.SAVE.SCRIPT, data), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: qaRecorderKeys.scripts() }); - }, - onError: onError('save QA script'), - }); -} - -/** Delete a QA script */ -export function useDeleteScript() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: ({ id }: { id: string }) => ipc(QA_RECORDER.DELETE.SCRIPT, { id }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: qaRecorderKeys.scripts() }); - }, - onError: onError('delete QA script'), - }); -} - -/** Export a run to file */ -export function useExportRun() { - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: ({ runId, format }: { runId: string; format: 'json' | 'html' | 'csv' }) => - ipc(QA_RECORDER.EXPORT.FILE, { runId, format }), - onError: onError('export run'), - }); -} diff --git a/src/renderer/features/qa-recorder/api/useScripts.ts b/src/renderer/features/qa-recorder/api/useScripts.ts deleted file mode 100644 index 151fce47..00000000 --- a/src/renderer/features/qa-recorder/api/useScripts.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * React Query hooks for QA script queries - */ - -import { useQuery } from '@tanstack/react-query'; - -import { QA_RECORDER } from '@shared/ipc/qa-recorder/channels'; -import type { QaScriptSchema } from '@shared/ipc/qa-recorder/schemas'; - - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { qaRecorderKeys } from './queryKeys'; - -import type { z } from 'zod'; - -export type QaScript = z.infer<typeof QaScriptSchema>; - -/** Fetch all saved QA scripts */ -export function useScripts() { - return useQuery({ - queryKey: qaRecorderKeys.scripts(), - queryFn: () => ipc(QA_RECORDER.LIST.SCRIPTS, {}), - staleTime: 30_000, - }); -} - -/** Fetch a single QA script by id */ -export function useScript(id: string | null) { - return useQuery({ - queryKey: qaRecorderKeys.script(id ?? ''), - queryFn: () => ipc(QA_RECORDER.GET.SCRIPT, { id: id ?? '' }), - enabled: id !== null && id.length > 0, - staleTime: 30_000, - }); -} diff --git a/src/renderer/features/qa-recorder/components/QaRecorderPage.tsx b/src/renderer/features/qa-recorder/components/QaRecorderPage.tsx deleted file mode 100644 index 96952a51..00000000 --- a/src/renderer/features/qa-recorder/components/QaRecorderPage.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/** - * QaRecorderPage — Split-panel QA recording interface - * - * Layout: StepPanel (30%) | WebviewPanel (70%) - * Control bar: Record, Stop, Save, Run, Export - * Running state: RunOutputPanel renders below StepPanel in left panel - */ - -import { useState } from 'react'; - -import { Download, Play, Video, VideoOff } from 'lucide-react'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { - Button, - EmptyState, - Flex, - PageContent, - PageHeader, - PageHeaderActions, - PageHeaderRow, - PageHeaderTitle, - PageLayout, - Separator, - Stack, -} from '@ui'; - -import { useRunScript } from '../api/useRuns'; -import { useSaveScript } from '../api/useScriptMutations'; -import { useScripts } from '../api/useScripts'; -import { useRecorderEvents } from '../hooks/useRecorderEvents'; -import { useQaRecorderStore } from '../store'; - -import { RunOutputPanel } from './RunOutputPanel'; -import { ScriptSelector } from './ScriptSelector'; -import { StepPanel } from './StepPanel'; -import { WebviewPanel } from './WebviewPanel'; - -interface QaRecorderPageProps { - /** Path to the qa-recorder preload script; required for webview to function */ - preloadPath?: string; -} - -export function QaRecorderPage({ preloadPath = '' }: QaRecorderPageProps) { - // Wire up IPC event listeners - useRecorderEvents(); - - const isRecording = useQaRecorderStore((s) => s.isRecording); - const isRunning = useQaRecorderStore((s) => s.isRunning); - const recordedSteps = useQaRecorderStore((s) => s.recordedSteps); - const selectedScriptId = useQaRecorderStore((s) => s.selectedScriptId); - const startRecording = useQaRecorderStore((s) => s.startRecording); - const stopRecording = useQaRecorderStore((s) => s.stopRecording); - const clearOutputLines = useQaRecorderStore((s) => s.clearOutputLines); - const setRunning = useQaRecorderStore((s) => s.setRunning); - - const [saveName, setSaveName] = useState(''); - - const { data: _scripts } = useScripts(); - const saveScript = useSaveScript(); - const runScript = useRunScript(); - - const hasSteps = recordedSteps.length > 0; - const noPreload = preloadPath.length === 0; - const canRun = selectedScriptId !== null && !isRunning && !isRecording; - const canSave = hasSteps && !isRecording; - - const handleRecord = () => { - if (isRecording) { - stopRecording(); - } else { - startRecording(); - } - }; - - const handleSave = () => { - const name = saveName.trim() || `Script ${new Date().toLocaleString()}`; - saveScript.mutate({ name, steps: recordedSteps }); - setSaveName(''); - }; - - const handleRun = () => { - if (!selectedScriptId) return; - clearOutputLines(); - setRunning(true); - runScript.mutate({ scriptId: selectedScriptId }); - }; - - return ( - <PageLayout data-testid="qa-recorder-page"> - <PageHeader> - <PageHeaderRow> - <PageHeaderTitle>QA Recorder</PageHeaderTitle> - <PageHeaderActions> - <ScriptSelector /> - <Separator className="h-6" orientation="vertical" /> - <Button - data-testid="btn-record" - disabled={isRunning || noPreload} - size="sm" - variant={isRecording ? 'destructive' : 'primary'} - onClick={handleRecord} - > - {isRecording ? ( - <> - <VideoOff className="mr-1.5 size-3.5" /> - Stop - </> - ) : ( - <> - <Video className="mr-1.5 size-3.5" /> - Record - </> - )} - </Button> - <Button - data-testid="btn-save" - disabled={!canSave || saveScript.isPending} - size="sm" - variant="outline" - onClick={handleSave} - > - Save - </Button> - <Button - data-testid="btn-run" - disabled={!canRun || runScript.isPending} - size="sm" - variant="outline" - onClick={handleRun} - > - <Play className="mr-1.5 size-3.5" /> - Run - </Button> - <Button - data-testid="btn-export" - disabled={selectedScriptId === null || isRunning} - size="sm" - variant="ghost" - > - <Download className="mr-1.5 size-3.5" /> - Export - </Button> - </PageHeaderActions> - </PageHeaderRow> - </PageHeader> - - <PageContent className="overflow-hidden p-0"> - {noPreload ? ( - <EmptyState - data-testid="qa-recorder-no-preload" - description="Pass a preloadPath prop with the recorder preload script path" - icon={Video} - title="Webview preload not configured" - /> - ) : ( - <Flex className="h-full gap-0"> - {/* Left panel — 30% */} - <Stack - className={cn('h-full w-[30%] shrink-0 overflow-hidden border-r p-3')} - gap="sm" - > - <StepPanel /> - {isRunning ? <RunOutputPanel /> : null} - </Stack> - - {/* Right panel — 70% */} - <Flex className="min-w-0 flex-1 p-3"> - <WebviewPanel initialUrl="about:blank" preloadPath={preloadPath} /> - </Flex> - </Flex> - )} - </PageContent> - </PageLayout> - ); -} diff --git a/src/renderer/features/qa-recorder/components/RunOutputPanel.tsx b/src/renderer/features/qa-recorder/components/RunOutputPanel.tsx deleted file mode 100644 index 8e8b5b4b..00000000 --- a/src/renderer/features/qa-recorder/components/RunOutputPanel.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/** - * RunOutputPanel — Streaming run output log panel - * - * Shows live output lines while a script is running, and the - * final report summary once the run completes. - */ - -import { useEffect, useRef } from 'react'; - -import { Terminal } from 'lucide-react'; - -import { - Badge, - Card, - CardContent, - CardHeader, - CardTitle, - ScrollArea, - Spinner, - Stack, - Text, -} from '@ui'; - -import { useRun } from '../api/useRuns'; -import { useQaRecorderStore } from '../store'; - -type BadgeVariant = 'default' | 'destructive' | 'secondary' | 'outline' | 'success'; - -const RUN_STATUS_VARIANT: Record<string, BadgeVariant> = { - passed: 'success', - failed: 'destructive', - running: 'secondary', - cancelled: 'outline', -}; - -export function RunOutputPanel() { - const isRunning = useQaRecorderStore((s) => s.isRunning); - const activeRunId = useQaRecorderStore((s) => s.activeRunId); - const outputLines = useQaRecorderStore((s) => s.outputLines); - - const { data: run } = useRun(activeRunId); - const scrollRef = useRef<HTMLDivElement>(null); - - // Auto-scroll to bottom on new lines - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [outputLines]); - - return ( - <Card className="flex h-full flex-col" data-testid="run-output-panel"> - <CardHeader className="shrink-0 pb-2"> - <CardTitle className="flex items-center gap-2 text-sm"> - <Terminal className="size-4" /> - Run Output - {isRunning ? ( - <Spinner className="ml-auto" size="sm" /> - ) : null} - {run !== undefined && run !== null && !isRunning ? ( - <Badge - className="ml-auto" - variant={RUN_STATUS_VARIANT[run.status] ?? 'secondary'} - > - {run.status} - </Badge> - ) : null} - </CardTitle> - </CardHeader> - <CardContent className="min-h-0 flex-1 p-0"> - <ScrollArea className="h-full font-mono" data-testid="run-output-scroll"> - <Stack - ref={scrollRef} - className="space-y-0.5 p-3" - data-testid="run-output-lines" - gap="none" - > - {outputLines.length === 0 && !isRunning ? ( - <Text className="text-xs text-muted-foreground"> - No output yet. Run a script to see logs. - </Text> - ) : ( - outputLines.map((entry) => ( - <Text - key={entry.id} - className="block text-xs text-foreground/80" - data-testid={`output-line-${entry.id}`} - > - {entry.text} - </Text> - )) - )} - </Stack> - </ScrollArea> - </CardContent> - </Card> - ); -} diff --git a/src/renderer/features/qa-recorder/components/ScriptSelector.tsx b/src/renderer/features/qa-recorder/components/ScriptSelector.tsx deleted file mode 100644 index 2ee12d9e..00000000 --- a/src/renderer/features/qa-recorder/components/ScriptSelector.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** - * ScriptSelector — Dropdown to select a saved QA script - */ - -import { FileSearch } from 'lucide-react'; - -import { - EmptyState, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Spinner, -} from '@ui'; - -import { useScripts } from '../api/useScripts'; -import { useQaRecorderStore } from '../store'; - -export function ScriptSelector() { - const { data: scripts, isLoading } = useScripts(); - const selectedScriptId = useQaRecorderStore((s) => s.selectedScriptId); - const selectScript = useQaRecorderStore((s) => s.selectScript); - - if (isLoading) { - return <Spinner size="sm" />; - } - - if (!scripts || scripts.length === 0) { - return ( - <EmptyState - data-testid="script-selector-empty" - description="Record and save a QA script to get started" - icon={FileSearch} - title="No scripts saved" - /> - ); - } - - return ( - <Select - value={selectedScriptId ?? ''} - onValueChange={(value) => selectScript(value || null)} - > - <SelectTrigger className="w-full max-w-xs" data-testid="script-selector-trigger"> - <SelectValue placeholder="Select a script..." /> - </SelectTrigger> - <SelectContent> - {scripts.map((script) => ( - <SelectItem key={script.id} value={script.id}> - {script.name} - </SelectItem> - ))} - </SelectContent> - </Select> - ); -} diff --git a/src/renderer/features/qa-recorder/components/StepPanel.tsx b/src/renderer/features/qa-recorder/components/StepPanel.tsx deleted file mode 100644 index dfe9168e..00000000 --- a/src/renderer/features/qa-recorder/components/StepPanel.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/** - * StepPanel — Left panel showing recorded steps with type icons - */ - -import { - ArrowRight, - CheckSquare, - Clock, - Keyboard, - ListOrdered, - MousePointer, - Navigation, - PenLine, -} from 'lucide-react'; - -import { - Badge, - Flex, - Stack, - Card, - CardContent, - CardHeader, - CardTitle, - EmptyState, - ScrollArea, - Text, -} from '@ui'; - -import { useQaRecorderStore } from '../store'; - -import type { QaRecorderStep } from '../api/useScriptMutations'; - - -// ── Step icon mapping ──────────────────────────────────────────── - -type StepType = QaRecorderStep['type']; - -const STEP_ICONS: Record<StepType, React.ComponentType<{ className?: string }>> = { - navigate: Navigation, - click: MousePointer, - fill: PenLine, - select: ListOrdered, - press: Keyboard, - wait: Clock, - assert: CheckSquare, -}; - -const STEP_LABELS: Record<StepType, string> = { - navigate: 'Navigate', - click: 'Click', - fill: 'Fill', - select: 'Select', - press: 'Press', - wait: 'Wait', - assert: 'Assert', -}; - -function stepDescription(step: QaRecorderStep): string { - switch (step.type) { - case 'navigate': - return step.url; - case 'click': - return step.selector; - case 'fill': - return `${step.selector} → "${step.value}"`; - case 'select': - return `${step.selector} → "${step.value}"`; - case 'press': - return step.key; - case 'wait': - return `${step.ms}ms`; - case 'assert': - return `${step.selector} = "${step.expected}"`; - } -} - -// ── Component ──────────────────────────────────────────────────── - -interface StepPanelProps { - steps?: QaRecorderStep[]; -} - -export function StepPanel({ steps: propSteps }: StepPanelProps) { - const recordedSteps = useQaRecorderStore((s) => s.recordedSteps); - const isRecording = useQaRecorderStore((s) => s.isRecording); - - const steps = propSteps ?? recordedSteps; - - return ( - <Card className="flex h-full flex-col" data-testid="step-panel"> - <CardHeader className="shrink-0 pb-2"> - <CardTitle className="flex items-center gap-2 text-sm"> - <ArrowRight className="size-4" /> - Steps - {steps.length > 0 ? ( - <Badge className="ml-auto" variant="secondary"> - {steps.length} - </Badge> - ) : null} - {isRecording ? ( - <Badge className="ml-1 animate-pulse" variant="destructive"> - REC - </Badge> - ) : null} - </CardTitle> - </CardHeader> - <CardContent className="min-h-0 flex-1 p-0"> - {steps.length === 0 ? ( - <EmptyState - className="h-full" - data-testid="step-panel-empty" - icon={ListOrdered} - title="No steps recorded" - description={ - isRecording - ? 'Interact with the webview to record steps' - : 'Start recording to capture steps' - } - /> - ) : ( - <ScrollArea className="h-full" data-testid="step-panel-list"> - <Stack className="p-3" gap="sm"> - {steps.map((step, idx) => { - const Icon = STEP_ICONS[step.type]; - const key = `${step.type}-${idx}`; - return ( - <Flex - key={key} - align="start" - className="rounded-md p-2 text-sm hover:bg-muted/50" - data-testid="step-item-" - gap="sm" - > - <Text className="w-5 shrink-0 text-xs text-muted-foreground"> - {idx + 1} - </Text> - <Icon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" /> - <Stack className="min-w-0 flex-1" gap="none"> - <Badge className="mb-0.5 text-xs" variant="outline"> - {STEP_LABELS[step.type]} - </Badge> - <Text className="truncate text-xs text-muted-foreground"> - {stepDescription(step)} - </Text> - </Stack> - </Flex> - ); - })} - </Stack> - </ScrollArea> - )} - </CardContent> - </Card> - ); -} diff --git a/src/renderer/features/qa-recorder/components/WebviewPanel.tsx b/src/renderer/features/qa-recorder/components/WebviewPanel.tsx deleted file mode 100644 index fa162d19..00000000 --- a/src/renderer/features/qa-recorder/components/WebviewPanel.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * WebviewPanel — Right panel hosting the Electron <webview> element - * - * Border turns red while recording. Accepts a preload script path - * from the electron-configured qa-recorder preload. - */ - -import { useEffect, useRef } from 'react'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { Card, CardContent } from '@ui'; - -import { useQaRecorderStore } from '../store'; - -// Electron's webview tag is not in the standard React/HTML JSX namespace. -// We use a loose ref type and cast at call sites. -type WebviewRef = { - addEventListener: (type: string, listener: (event: Electron.IpcMessageEvent) => void) => void; - removeEventListener: (type: string, listener: (event: Electron.IpcMessageEvent) => void) => void; -} | null; - -interface WebviewPanelProps { - initialUrl?: string; - preloadPath: string; -} - -export function WebviewPanel({ initialUrl = 'about:blank', preloadPath }: WebviewPanelProps) { - const isRecording = useQaRecorderStore((s) => s.isRecording); - const appendStep = useQaRecorderStore((s) => s.appendStep); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const webviewRef = useRef<any>(null); - - // Listen for recorded-step messages from the preload - useEffect(() => { - const webview = webviewRef.current as WebviewRef; - if (!webview) return; - - const handleIpcMessage = (event: Electron.IpcMessageEvent) => { - if (event.channel === 'qa-recorder:step') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - appendStep(event.args[0]); - } - }; - - webview.addEventListener('ipc-message', handleIpcMessage); - return () => { - webview.removeEventListener('ipc-message', handleIpcMessage); - }; - }, [appendStep]); - - return ( - <Card - data-testid="webview-panel" - className={cn( - 'flex h-full flex-col overflow-hidden transition-all', - isRecording && 'ring-2 ring-destructive ring-offset-1', - )} - > - <CardContent className="min-h-0 flex-1 p-0"> - {/* - webview is a valid Electron-specific element not in the JSX intrinsic types. - Cast via any to satisfy the type checker. - */} - { } - {(() => { - const Webview = 'webview' as unknown as React.ElementType; - return ( - <Webview - ref={webviewRef} - allowpopups="true" - className="h-full w-full" - data-testid="webview-element" - preload={preloadPath} - src={initialUrl} - /> - ); - })()} - </CardContent> - </Card> - ); -} diff --git a/src/renderer/features/qa-recorder/hooks/useRecorderEvents.ts b/src/renderer/features/qa-recorder/hooks/useRecorderEvents.ts deleted file mode 100644 index 52532003..00000000 --- a/src/renderer/features/qa-recorder/hooks/useRecorderEvents.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * useRecorderEvents — Subscribe to QA Recorder IPC events - * - * Bridges real-time recording/run output events from main process - * to React Query cache and local store state. - */ - -import { useQueryClient } from '@tanstack/react-query'; - -import { QA_RECORDER_EVENTS } from '@shared/ipc/qa-recorder/channels'; - -import { useIpcEvent } from '@renderer/shared/hooks'; -import { useToastStore } from '@renderer/shared/stores'; - -import { qaRecorderKeys } from '../api/queryKeys'; -import { useQaRecorderStore } from '../store'; - -export function useRecorderEvents() { - const queryClient = useQueryClient(); - const addToast = useToastStore((s) => s.addToast); - const appendOutputLine = useQaRecorderStore((s) => s.appendOutputLine); - const setActiveRunId = useQaRecorderStore((s) => s.setActiveRunId); - const setRunning = useQaRecorderStore((s) => s.setRunning); - - // Streaming output line → append to local store - useIpcEvent(QA_RECORDER_EVENTS.OUTPUT.LINE, (data) => { - appendOutputLine(data.line); - }); - - // Screenshot captured during run → invalidate run cache - useIpcEvent(QA_RECORDER_EVENTS.RUN.SCREENSHOT, (data) => { - void queryClient.invalidateQueries({ queryKey: qaRecorderKeys.run(data.runId) }); - }); - - // Run complete → update state, invalidate caches, show toast - useIpcEvent(QA_RECORDER_EVENTS.RUN.COMPLETE, (data) => { - setRunning(false); - setActiveRunId(data.runId); - void queryClient.invalidateQueries({ queryKey: qaRecorderKeys.run(data.runId) }); - void queryClient.invalidateQueries({ queryKey: qaRecorderKeys.runs() }); - - const isPassing = data.status === 'passed'; - addToast( - `QA run ${isPassing ? 'passed' : 'failed'} (${data.report.passedSteps}/${data.report.totalSteps} steps)`, - isPassing ? 'success' : 'error', - ); - }); -} diff --git a/src/renderer/features/qa-recorder/index.ts b/src/renderer/features/qa-recorder/index.ts deleted file mode 100644 index 76cf232c..00000000 --- a/src/renderer/features/qa-recorder/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * QA Recorder feature -- public API - */ - -// API hooks -export { useScripts, useScript } from './api/useScripts'; -export { useSaveScript, useDeleteScript, useExportRun } from './api/useScriptMutations'; -export { useRuns, useRun, useRunScript } from './api/useRuns'; -export { qaRecorderKeys } from './api/queryKeys'; - -// Events -export { useRecorderEvents } from './hooks/useRecorderEvents'; - -// Store -export { useQaRecorderStore } from './store'; - -// Components -export { QaRecorderPage } from './components/QaRecorderPage'; -export { StepPanel } from './components/StepPanel'; -export { WebviewPanel } from './components/WebviewPanel'; -export { RunOutputPanel } from './components/RunOutputPanel'; -export { ScriptSelector } from './components/ScriptSelector'; diff --git a/src/renderer/features/qa-recorder/store.ts b/src/renderer/features/qa-recorder/store.ts deleted file mode 100644 index 4dbcfad1..00000000 --- a/src/renderer/features/qa-recorder/store.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * QA Recorder UI Store — Client-side state only - * - * Recording state, recorded steps, run output lines, active script/run selection. - * No data from main process lives here — use React Query hooks for that. - */ - -import { create } from 'zustand'; - -import type { QaRecorderStep } from './api/useScriptMutations'; - -export interface OutputLine { - id: number; - text: string; -} - -interface QaRecorderState { - // Recording - isRecording: boolean; - recordedSteps: QaRecorderStep[]; - - // Run output - isRunning: boolean; - activeRunId: string | null; - outputLines: OutputLine[]; - _lineCounter: number; - - // Script selection - selectedScriptId: string | null; - - // Actions - startRecording: () => void; - stopRecording: () => void; - clearRecordedSteps: () => void; - appendStep: (step: QaRecorderStep) => void; - - setRunning: (running: boolean) => void; - setActiveRunId: (runId: string | null) => void; - appendOutputLine: (line: string) => void; - clearOutputLines: () => void; - - selectScript: (id: string | null) => void; -} - -export const useQaRecorderStore = create<QaRecorderState>((set) => ({ - isRecording: false, - recordedSteps: [], - isRunning: false, - activeRunId: null, - outputLines: [], - _lineCounter: 0, - selectedScriptId: null, - - startRecording: () => set({ isRecording: true, recordedSteps: [], outputLines: [], _lineCounter: 0 }), - stopRecording: () => set({ isRecording: false }), - clearRecordedSteps: () => set({ recordedSteps: [] }), - appendStep: (step) => - set((s) => ({ recordedSteps: [...s.recordedSteps, step] })), - - setRunning: (running) => set({ isRunning: running }), - setActiveRunId: (runId) => set({ activeRunId: runId }), - appendOutputLine: (line) => - set((s) => ({ - outputLines: [...s.outputLines, { id: s._lineCounter, text: line }], - _lineCounter: s._lineCounter + 1, - })), - clearOutputLines: () => set({ outputLines: [], _lineCounter: 0 }), - - selectScript: (id) => set({ selectedScriptId: id }), -})); diff --git a/src/renderer/features/roadmap/api/queryKeys.ts b/src/renderer/features/roadmap/api/queryKeys.ts deleted file mode 100644 index a43e1f49..00000000 --- a/src/renderer/features/roadmap/api/queryKeys.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Milestone query key factory - */ - -export const milestoneKeys = { - all: ['milestones'] as const, - lists: () => [...milestoneKeys.all, 'list'] as const, - list: (projectId?: string) => [...milestoneKeys.lists(), { projectId }] as const, -}; diff --git a/src/renderer/features/roadmap/api/useMilestones.ts b/src/renderer/features/roadmap/api/useMilestones.ts deleted file mode 100644 index 62923140..00000000 --- a/src/renderer/features/roadmap/api/useMilestones.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * React Query hooks for milestones - */ - -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { MILESTONES } from '@shared/ipc/misc/milestones.channels'; -import type { MilestoneStatus } from '@shared/types'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { milestoneKeys } from './queryKeys'; - -/** Fetch milestones with optional project filter */ -export function useMilestones(projectId?: string) { - return useQuery({ - queryKey: milestoneKeys.list(projectId), - queryFn: () => ipc(MILESTONES.LIST.ALL, { projectId }), - }); -} - -/** Create a new milestone */ -export function useCreateMilestone() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: { - title: string; - description: string; - targetDate: string; - projectId?: string; - id?: string; - }) => { - const id = data.id ?? crypto.randomUUID(); - return ipc(MILESTONES.CREATE.MILESTONE, { ...data, id }); - }, - onSuccess() { - void queryClient.invalidateQueries({ queryKey: milestoneKeys.lists() }); - }, - }); -} - -/** Update a milestone */ -export function useUpdateMilestone() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: { - id: string; - title?: string; - description?: string; - targetDate?: string; - status?: MilestoneStatus; - tasks?: Array<{ id: string; title: string; completed: boolean }>; - }) => ipc(MILESTONES.UPDATE.MILESTONE, data), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: milestoneKeys.lists() }); - }, - }); -} - -/** Delete a milestone */ -export function useDeleteMilestone() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (id: string) => ipc(MILESTONES.DELETE.MILESTONE, { id }), - onSuccess() { - void queryClient.invalidateQueries({ queryKey: milestoneKeys.lists() }); - }, - }); -} - -/** Add a task to a milestone */ -export function useAddMilestoneTask() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: { milestoneId: string; title: string }) => ipc(MILESTONES.ADD.TASK, data), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: milestoneKeys.lists() }); - }, - }); -} - -/** Toggle a milestone task's completion */ -export function useToggleMilestoneTask() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: { milestoneId: string; taskId: string }) => - ipc(MILESTONES.TOGGLE.TASK, data), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: milestoneKeys.lists() }); - }, - }); -} diff --git a/src/renderer/features/roadmap/components/MilestoneEditDialog.tsx b/src/renderer/features/roadmap/components/MilestoneEditDialog.tsx deleted file mode 100644 index 3047307e..00000000 --- a/src/renderer/features/roadmap/components/MilestoneEditDialog.tsx +++ /dev/null @@ -1,201 +0,0 @@ -/** - * MilestoneEditDialog — Edit a milestone's title, description, targetDate, status, - * and task items (rename, delete, toggle). - */ - -import { useEffect, useState } from 'react'; - -import { Pencil } from 'lucide-react'; - -import type { Milestone, MilestoneStatus, MilestoneTask } from '@shared/types'; - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Spinner, - Text, - Textarea, -} from '@ui'; - -import { useUpdateMilestone } from '../api/useMilestones'; - -import { MilestoneTaskListEditor } from './MilestoneTaskListEditor'; - -interface MilestoneEditDialogProps { - milestone: Milestone | null; - onClose: () => void; -} - -export function MilestoneEditDialog({ milestone, onClose }: MilestoneEditDialogProps) { - // 1. Hooks - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [targetDate, setTargetDate] = useState(''); - const [status, setStatus] = useState<MilestoneStatus>('planned'); - const [tasks, setTasks] = useState<MilestoneTask[]>([]); - const [errorMessage, setErrorMessage] = useState<string | null>(null); - - const updateMilestone = useUpdateMilestone(); - - // 2. Effects — initialize form from milestone prop - useEffect(() => { - if (milestone !== null) { - setTitle(milestone.title); - setDescription(milestone.description); - setTargetDate(milestone.targetDate.substring(0, 10)); - setStatus(milestone.status); - setTasks([...milestone.tasks]); - setErrorMessage(null); - } - }, [milestone]); - - // 3. Derived state - const titleIsEmpty = title.trim().length === 0; - const targetDateIsEmpty = targetDate.trim().length === 0; - const isFormValid = !titleIsEmpty && !targetDateIsEmpty; - - // 4. Submit handler - function handleSave(): void { - if (milestone === null || !isFormValid) return; - - setErrorMessage(null); - - updateMilestone.mutate( - { - id: milestone.id, - title: title.trim(), - description: description.trim(), - targetDate: new Date(targetDate).toISOString(), - status, - tasks, - }, - { - onSuccess: () => { - onClose(); - }, - onError: (err) => { - setErrorMessage(err instanceof Error ? err.message : 'Failed to update milestone'); - }, - }, - ); - } - - return ( - <Dialog open={milestone !== null} onOpenChange={(open) => { if (!open) onClose(); }}> - <DialogContent className="max-w-lg"> - <DialogHeader> - <DialogTitle className="flex items-center gap-3"> - <Pencil className="text-primary h-5 w-5" /> - Edit Milestone - </DialogTitle> - </DialogHeader> - - <div className="space-y-4 py-2"> - {/* Title */} - <div> - <Label htmlFor="edit-milestone-title"> - Title <span className="text-destructive">*</span> - </Label> - <Input - aria-required="true" - className="mt-1" - id="edit-milestone-title" - placeholder="Milestone title" - type="text" - value={title} - onChange={(e) => setTitle(e.target.value)} - /> - </div> - - {/* Description */} - <div> - <Label htmlFor="edit-milestone-description">Description</Label> - <Textarea - className="mt-1 resize-none" - id="edit-milestone-description" - placeholder="Optional description" - rows={3} - value={description} - onChange={(e) => setDescription(e.target.value)} - /> - </div> - - {/* Target Date */} - <div> - <Label htmlFor="edit-milestone-date"> - Target Date <span className="text-destructive">*</span> - </Label> - <Input - aria-required="true" - className="mt-1" - id="edit-milestone-date" - type="date" - value={targetDate} - onChange={(e) => setTargetDate(e.target.value)} - /> - </div> - - {/* Status */} - <div> - <Label htmlFor="edit-milestone-status">Status</Label> - <Select value={status} onValueChange={(v) => setStatus(v as MilestoneStatus)}> - <SelectTrigger className="mt-1" id="edit-milestone-status"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="planned">Planned</SelectItem> - <SelectItem value="in-progress">In Progress</SelectItem> - <SelectItem value="completed">Completed</SelectItem> - </SelectContent> - </Select> - </div> - - {/* Tasks */} - <MilestoneTaskListEditor tasks={tasks} onChange={setTasks} /> - </div> - - {/* Error message */} - {errorMessage === null ? null : ( - <div className="rounded-md bg-destructive/10 p-3"> - <Text size="md" variant="error">{errorMessage}</Text> - </div> - )} - - <DialogFooter> - <Button - type="button" - variant="ghost" - onClick={onClose} - > - Cancel - </Button> - <Button - disabled={!isFormValid || updateMilestone.isPending} - type="button" - onClick={handleSave} - > - {updateMilestone.isPending ? ( - <> - <Spinner size="sm" /> - Saving... - </> - ) : ( - 'Save' - )} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/roadmap/components/MilestoneTaskListEditor.tsx b/src/renderer/features/roadmap/components/MilestoneTaskListEditor.tsx deleted file mode 100644 index 65902e1f..00000000 --- a/src/renderer/features/roadmap/components/MilestoneTaskListEditor.tsx +++ /dev/null @@ -1,194 +0,0 @@ -/** - * MilestoneTaskListEditor — Manage the task checklist within a milestone. - * Handles add / rename / toggle / delete operations on MilestoneTask items. - */ - -import { useState } from 'react'; - -import { Pencil, Plus, Square, SquareCheck, Trash2, X } from 'lucide-react'; - -import type { MilestoneTask } from '@shared/types'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { Button, Input, Label } from '@ui'; - -interface MilestoneTaskListEditorProps { - tasks: MilestoneTask[]; - onChange: (tasks: MilestoneTask[]) => void; -} - -export function MilestoneTaskListEditor({ tasks, onChange }: MilestoneTaskListEditorProps) { - const [newTaskTitle, setNewTaskTitle] = useState(''); - const [editingTaskId, setEditingTaskId] = useState<string | null>(null); - const [editingTaskTitle, setEditingTaskTitle] = useState(''); - - function handleAddTask(): void { - if (newTaskTitle.trim().length === 0) return; - const newTask: MilestoneTask = { - id: crypto.randomUUID(), - title: newTaskTitle.trim(), - completed: false, - }; - onChange([...tasks, newTask]); - setNewTaskTitle(''); - } - - function handleDeleteTask(taskId: string): void { - onChange(tasks.filter((t) => t.id !== taskId)); - } - - function handleToggleTask(taskId: string): void { - onChange(tasks.map((t) => (t.id === taskId ? { ...t, completed: !t.completed } : t))); - } - - function handleStartEditTask(task: MilestoneTask): void { - setEditingTaskId(task.id); - setEditingTaskTitle(task.title); - } - - function handleCommitTaskEdit(): void { - if (editingTaskId === null) return; - if (editingTaskTitle.trim().length > 0) { - onChange( - tasks.map((t) => - t.id === editingTaskId ? { ...t, title: editingTaskTitle.trim() } : t, - ), - ); - } - setEditingTaskId(null); - setEditingTaskTitle(''); - } - - function handleCancelTaskEdit(): void { - setEditingTaskId(null); - setEditingTaskTitle(''); - } - - function renderTaskItem(task: MilestoneTask) { - const isEditing = editingTaskId === task.id; - - if (isEditing) { - return ( - <div key={task.id} className="flex items-center gap-2 py-0.5"> - <Input - className="h-7 flex-1 text-sm" - type="text" - value={editingTaskTitle} - onChange={(e) => setEditingTaskTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleCommitTaskEdit(); - if (e.key === 'Escape') handleCancelTaskEdit(); - }} - /> - <Button - aria-label="Confirm task rename" - className="h-7 w-7" - size="icon" - type="button" - variant="ghost" - onClick={handleCommitTaskEdit} - > - <Plus className="h-3 w-3" /> - </Button> - <Button - aria-label="Cancel task rename" - className="h-7 w-7" - size="icon" - type="button" - variant="ghost" - onClick={handleCancelTaskEdit} - > - <X className="h-3 w-3" /> - </Button> - </div> - ); - } - - return ( - <div - key={task.id} - className="flex items-center gap-2 rounded px-1 py-0.5" - > - <Button - aria-label={task.completed ? 'Mark incomplete' : 'Mark complete'} - className="h-6 w-6 shrink-0 p-0" - size="icon" - type="button" - variant="ghost" - onClick={() => handleToggleTask(task.id)} - > - {task.completed ? ( - <SquareCheck className="text-primary h-4 w-4" /> - ) : ( - <Square className="text-muted-foreground h-4 w-4" /> - )} - </Button> - <span - className={cn( - 'flex-1 truncate text-sm', - task.completed && 'text-muted-foreground line-through', - )} - > - {task.title} - </span> - <Button - aria-label="Edit task title" - className="h-6 w-6 shrink-0 p-0 opacity-0 group-hover:opacity-100" - size="icon" - type="button" - variant="ghost" - onClick={() => handleStartEditTask(task)} - > - <Pencil className="h-3 w-3" /> - </Button> - <Button - aria-label="Delete task" - className="text-muted-foreground hover:text-destructive h-6 w-6 shrink-0 p-0" - size="icon" - type="button" - variant="ghost" - onClick={() => handleDeleteTask(task.id)} - > - <Trash2 className="h-3 w-3" /> - </Button> - </div> - ); - } - - return ( - <div> - <Label>Tasks</Label> - <div className="border-border mt-1 space-y-0.5 rounded-md border p-2"> - {(tasks.length > 0) ? ( - <div className="group mb-2 space-y-0.5"> - {tasks.map((task) => renderTaskItem(task))} - </div> - ) : null} - - <div className="flex gap-2"> - <Input - className="h-7 flex-1 text-sm" - placeholder="Add a task..." - type="text" - value={newTaskTitle} - onChange={(e) => setNewTaskTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleAddTask(); - }} - /> - <Button - aria-label="Add task" - className="h-7 w-7" - size="icon" - type="button" - variant="ghost" - onClick={handleAddTask} - > - <Plus className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - ); -} diff --git a/src/renderer/features/roadmap/components/RoadmapPage.tsx b/src/renderer/features/roadmap/components/RoadmapPage.tsx deleted file mode 100644 index 7e76d479..00000000 --- a/src/renderer/features/roadmap/components/RoadmapPage.tsx +++ /dev/null @@ -1,519 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { CheckCircle2, Circle, Clock, Map, Pencil, Plus, Sparkles, Square, SquareCheck, Trash2 } from 'lucide-react'; - -import type { Milestone, MilestoneStatus } from '@shared/types'; - -import { RelativeTime } from '@renderer/shared/components/RelativeTime'; -import { useDebounce } from '@renderer/shared/hooks/useDebounce'; -import { cn } from '@renderer/shared/lib/utils'; -import { useAssistantWidgetStore, useLayoutStore } from '@renderer/shared/stores'; - -import { - Button, - EmptyState, - Heading, - Input, - SearchInput, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Spinner, - Text, - Textarea, -} from '@ui'; - -import { useSendCommand } from '@features/assistant'; - -import { - useAddMilestoneTask, - useCreateMilestone, - useDeleteMilestone, - useMilestones, - useToggleMilestoneTask, - useUpdateMilestone, -} from '../api/useMilestones'; -import { useMilestoneEvents } from '../hooks/useMilestoneEvents'; - -import { MilestoneEditDialog } from './MilestoneEditDialog'; - -const STATUS_CONFIG: Record< - MilestoneStatus, - { - icon: typeof CheckCircle2; - label: string; - barClass: string; - iconClass: string; - } -> = { - completed: { - icon: CheckCircle2, - label: 'Completed', - barClass: 'bg-primary', - iconClass: 'text-primary', - }, - 'in-progress': { - icon: Clock, - label: 'In Progress', - barClass: 'bg-primary', - iconClass: 'text-primary', - }, - planned: { - icon: Circle, - label: 'Planned', - barClass: 'bg-muted-foreground', - iconClass: 'text-muted-foreground', - }, -}; - -function computeProgress(milestone: Milestone): number { - if (milestone.tasks.length === 0) { - if (milestone.status === 'completed') return 100; - if (milestone.status === 'in-progress') return 50; - return 0; - } - const completed = milestone.tasks.filter((t) => t.completed).length; - return Math.round((completed / milestone.tasks.length) * 100); -} - -function MilestoneCard({ - milestone, - onDelete, - onEdit, - onStatusChange, -}: { - milestone: Milestone; - onDelete: (id: string) => void; - onEdit: (milestone: Milestone) => void; - onStatusChange: (id: string, status: MilestoneStatus) => void; -}) { - const [newTaskTitle, setNewTaskTitle] = useState(''); - const addTask = useAddMilestoneTask(); - const toggleTask = useToggleMilestoneTask(); - const config = STATUS_CONFIG[milestone.status]; - const StatusIcon = config.icon; - const progress = computeProgress(milestone); - - function handleAddTask(): void { - if (!newTaskTitle.trim()) return; - addTask.mutate({ milestoneId: milestone.id, title: newTaskTitle.trim() }); - setNewTaskTitle(''); - } - - return ( - <div className="border-border bg-card rounded-lg border p-4"> - <div className="mb-2 flex items-center justify-between"> - <div className="flex items-center gap-2"> - <StatusIcon className={cn('h-5 w-5', config.iconClass)} /> - <Heading as="h3" className="font-medium">{milestone.title}</Heading> - </div> - <div className="flex items-center gap-2"> - <Select - value={milestone.status} - onValueChange={(v) => onStatusChange(milestone.id, v as MilestoneStatus)} - > - <SelectTrigger className="h-7 w-[120px] text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="planned">Planned</SelectItem> - <SelectItem value="in-progress">In Progress</SelectItem> - <SelectItem value="completed">Completed</SelectItem> - </SelectContent> - </Select> - <Button - aria-label="Edit milestone" - className="text-muted-foreground hover:text-foreground" - size="icon" - type="button" - variant="ghost" - onClick={() => onEdit(milestone)} - > - <Pencil className="h-4 w-4" /> - </Button> - <Button - aria-label="Delete milestone" - className="text-muted-foreground hover:text-destructive" - size="icon" - type="button" - variant="ghost" - onClick={() => onDelete(milestone.id)} - > - <Trash2 className="h-4 w-4" /> - </Button> - </div> - </div> - - <Text className="text-muted-foreground mb-2" size="sm">{milestone.description}</Text> - <Text className="text-muted-foreground mb-3 text-xs"> - Target: {new Date(milestone.targetDate).toLocaleDateString()} - </Text> - <div className="mb-3"> - <RelativeTime value={milestone.createdAt} /> - </div> - - {/* Progress Bar */} - <div className="mb-3 flex items-center gap-3"> - <div className="bg-muted h-2 flex-1 overflow-hidden rounded-full"> - <div - className={cn('h-full rounded-full transition-all', config.barClass)} - style={{ width: `${String(progress)}%` }} - /> - </div> - <span className="text-muted-foreground w-10 text-right text-xs font-medium"> - {progress}% - </span> - </div> - - {/* Tasks */} - {milestone.tasks.length > 0 ? ( - <div className="mb-3 space-y-1"> - {milestone.tasks.map((task) => ( - <Button - key={task.id} - className="hover:bg-muted/50 flex h-auto w-full items-center justify-start gap-2 rounded px-1 py-0.5 text-left text-sm" - type="button" - variant="ghost" - onClick={() => toggleTask.mutate({ milestoneId: milestone.id, taskId: task.id })} - > - {task.completed ? ( - <SquareCheck className="text-primary h-4 w-4 shrink-0" /> - ) : ( - <Square className="text-muted-foreground h-4 w-4 shrink-0" /> - )} - <span className={cn(task.completed && 'text-muted-foreground line-through')}> - {task.title} - </span> - </Button> - ))} - </div> - ) : null} - - {/* Add task input */} - <div className="flex gap-2"> - <Input - className="flex-1" - placeholder="Add a task..." - size="sm" - type="text" - value={newTaskTitle} - onChange={(e) => setNewTaskTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleAddTask(); - }} - /> - <Button - size="icon" - type="button" - variant="ghost" - onClick={handleAddTask} - > - <Plus className="h-4 w-4" /> - </Button> - </div> - </div> - ); -} - -const DEFAULT_GENERATE_PROMPT = - 'Create a new milestone for my project roadmap. Include a clear title, description, and target date about 4 weeks from now. Call the create_milestone tool to add it.'; - -export function RoadmapPage() { - useMilestoneEvents(); - const { data: milestones, isLoading } = useMilestones(); - const createMilestone = useCreateMilestone(); - const deleteMilestone = useDeleteMilestone(); - const updateMilestone = useUpdateMilestone(); - - const [showForm, setShowForm] = useState(false); - const [formTitle, setFormTitle] = useState(''); - const [formDescription, setFormDescription] = useState(''); - const [formDate, setFormDate] = useState(''); - const [showGenerate, setShowGenerate] = useState(false); - const [generatePrompt, setGeneratePrompt] = useState(DEFAULT_GENERATE_PROMPT); - const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null); - const [searchInput, setSearchInput] = useState(''); - const [statusFilter, setStatusFilter] = useState<'all' | MilestoneStatus>('all'); - const [sortBy, setSortBy] = useState<'targetDate' | 'progress' | 'createdAt'>('targetDate'); - - const debouncedSearch = useDebounce(searchInput, 250); - - const sendCommand = useSendCommand(); - const openWidget = useAssistantWidgetStore((s) => s.open); - const activeProjectId = useLayoutStore((s) => s.activeProjectId); - - function handleCreate(): void { - if (!formTitle.trim() || !formDate) return; - createMilestone.mutate({ - title: formTitle.trim(), - description: formDescription.trim(), - targetDate: new Date(formDate).toISOString(), - }); - setFormTitle(''); - setFormDescription(''); - setFormDate(''); - setShowForm(false); - } - - function handleGenerate(): void { - if (!generatePrompt.trim() || sendCommand.isPending) return; - openWidget(); - sendCommand.mutate({ - input: generatePrompt.trim(), - context: { activeView: 'roadmap', activeProjectId: activeProjectId ?? undefined }, - }); - setShowGenerate(false); - setGeneratePrompt(DEFAULT_GENERATE_PROMPT); - } - - const items = milestones ?? []; - const completedCount = items.filter((m) => m.status === 'completed').length; - const totalProgress = - items.length > 0 - ? Math.round(items.reduce((sum, m) => sum + computeProgress(m), 0) / items.length) - : 0; - - const filteredItems = useMemo(() => { - const source = milestones ?? []; - const query = debouncedSearch.toLowerCase(); - - let result = source.filter((m) => { - const matchesSearch = - query === '' || - m.title.toLowerCase().includes(query) || - m.description.toLowerCase().includes(query); - const matchesStatus = statusFilter === 'all' || m.status === statusFilter; - return matchesSearch && matchesStatus; - }); - - result = [...result].sort((a, b) => { - if (sortBy === 'targetDate') { - return new Date(a.targetDate).getTime() - new Date(b.targetDate).getTime(); - } - if (sortBy === 'progress') { - return computeProgress(b) - computeProgress(a); - } - // createdAt desc - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - }); - - return result; - }, [milestones, debouncedSearch, statusFilter, sortBy]); - - return ( - <div className="space-y-6 p-6"> - {/* Actions */} - <div className="flex items-center gap-3"> - <SearchInput - className="w-56" - placeholder="Search milestones..." - size="sm" - value={searchInput} - onChange={(e) => setSearchInput(e.target.value)} - onClear={() => setSearchInput('')} - /> - <Select - value={statusFilter} - onValueChange={(v) => setStatusFilter(v as 'all' | MilestoneStatus)} - > - <SelectTrigger className="h-8 w-[130px] text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="all">All statuses</SelectItem> - <SelectItem value="planned">Planned</SelectItem> - <SelectItem value="in-progress">In Progress</SelectItem> - <SelectItem value="completed">Completed</SelectItem> - </SelectContent> - </Select> - <Select - value={sortBy} - onValueChange={(v) => setSortBy(v as 'targetDate' | 'progress' | 'createdAt')} - > - <SelectTrigger className="h-8 w-[130px] text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="targetDate">Target date</SelectItem> - <SelectItem value="progress">Progress</SelectItem> - <SelectItem value="createdAt">Created at</SelectItem> - </SelectContent> - </Select> - <div className="flex-1" /> - <Button - type="button" - variant="outline" - onClick={() => { - setShowGenerate(!showGenerate); - setShowForm(false); - }} - > - <Sparkles className="h-4 w-4" /> - Generate with AI - </Button> - <Button - type="button" - onClick={() => { - setShowForm(!showForm); - setShowGenerate(false); - }} - > - <Plus className="h-4 w-4" /> - New Milestone - </Button> - </div> - {/* Generate with AI Panel */} - {showGenerate ? ( - <div className="border-border bg-card mb-6 space-y-3 rounded-lg border p-4"> - <Text className="text-muted-foreground" size="sm"> - Describe what milestones you want the assistant to generate. It will create them directly on your roadmap. - </Text> - <Textarea - resize="none" - rows={3} - value={generatePrompt} - onChange={(e) => setGeneratePrompt(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && e.metaKey) handleGenerate(); - }} - /> - <div className="flex gap-2"> - <Button - disabled={sendCommand.isPending || !generatePrompt.trim()} - type="button" - onClick={handleGenerate} - > - {sendCommand.isPending ? ( - <> - <Spinner className="h-4 w-4" /> - Generating... - </> - ) : ( - <> - <Sparkles className="h-4 w-4" /> - Generate - </> - )} - </Button> - <Button - type="button" - variant="outline" - onClick={() => setShowGenerate(false)} - > - Cancel - </Button> - </div> - </div> - ) : null} - - {/* Create Form */} - {showForm ? ( - <div className="border-border bg-card mb-6 space-y-3 rounded-lg border p-4"> - <Input - placeholder="Milestone title" - type="text" - value={formTitle} - onChange={(e) => setFormTitle(e.target.value)} - /> - <Textarea - placeholder="Description" - resize="none" - rows={2} - value={formDescription} - onChange={(e) => setFormDescription(e.target.value)} - /> - <Input - type="date" - value={formDate} - onChange={(e) => setFormDate(e.target.value)} - /> - <div className="flex gap-2"> - <Button type="button" onClick={handleCreate}> - Create - </Button> - <Button type="button" variant="outline" onClick={() => setShowForm(false)}> - Cancel - </Button> - </div> - </div> - ) : null} - - {/* Summary Stats */} - <div className="mb-8 grid grid-cols-3 gap-4"> - <div className="border-border bg-card rounded-lg border p-4"> - <div className="text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase"> - Total Milestones - </div> - <Text className="font-semibold" size="lg">{items.length}</Text> - </div> - <div className="border-border bg-card rounded-lg border p-4"> - <div className="text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase"> - Completed - </div> - <Text className="font-semibold" size="lg"> - {completedCount} / {items.length} - </Text> - </div> - <div className="border-border bg-card rounded-lg border p-4"> - <div className="text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase"> - Overall Progress - </div> - <Text className="font-semibold" size="lg">{totalProgress}%</Text> - </div> - </div> - - {/* Timeline */} - {isLoading ? ( - <div className="text-muted-foreground flex items-center justify-center py-12"> - Loading milestones... - </div> - ) : null} - - {!isLoading && items.length > 0 && filteredItems.length > 0 ? ( - <section> - <Heading as="h2" className="text-muted-foreground mb-4 text-sm font-medium tracking-wider uppercase"> - Timeline - </Heading> - <div className="space-y-4"> - {filteredItems.map((milestone) => ( - <MilestoneCard - key={milestone.id} - milestone={milestone} - onDelete={(id) => deleteMilestone.mutate(id)} - onEdit={(m) => setEditingMilestone(m)} - onStatusChange={(id, status) => updateMilestone.mutate({ id, status })} - /> - ))} - </div> - </section> - ) : null} - - {!isLoading && items.length > 0 && filteredItems.length === 0 ? ( - <EmptyState - description="Try adjusting your search or filters" - icon={Map} - size="md" - title="No milestones match" - /> - ) : null} - - {!isLoading && items.length === 0 ? ( - <div className="border-border rounded-lg border border-dashed p-12 text-center"> - <Map className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> - <Text className="font-medium" size="lg">No milestones yet</Text> - <Text className="text-muted-foreground mt-1" size="sm"> - Create your first milestone to start planning - </Text> - </div> - ) : null} - - {/* Edit dialog */} - <MilestoneEditDialog - milestone={editingMilestone} - onClose={() => setEditingMilestone(null)} - /> - </div> - ); -} diff --git a/src/renderer/features/roadmap/hooks/useMilestoneEvents.ts b/src/renderer/features/roadmap/hooks/useMilestoneEvents.ts deleted file mode 100644 index b7e85ca6..00000000 --- a/src/renderer/features/roadmap/hooks/useMilestoneEvents.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Milestone IPC event listeners -> query invalidation - */ - -import { useQueryClient } from '@tanstack/react-query'; - -import { MILESTONES_EVENTS } from '@shared/ipc/misc/milestones.channels'; - -import { useIpcEvent } from '@renderer/shared/hooks'; - -import { milestoneKeys } from '../api/queryKeys'; - -export function useMilestoneEvents() { - const queryClient = useQueryClient(); - - useIpcEvent(MILESTONES_EVENTS.MILESTONE.CHANGED, () => { - void queryClient.invalidateQueries({ queryKey: milestoneKeys.all }); - }); -} diff --git a/src/renderer/features/roadmap/index.ts b/src/renderer/features/roadmap/index.ts deleted file mode 100644 index a639eeb5..00000000 --- a/src/renderer/features/roadmap/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Roadmap feature — milestone timeline and progress tracking - */ - -export { RoadmapPage } from './components/RoadmapPage'; -export { useMilestones } from './api/useMilestones'; -export { milestoneKeys } from './api/queryKeys'; -export { useMilestoneEvents } from './hooks/useMilestoneEvents'; -export { useRoadmapUI } from './store'; diff --git a/src/renderer/features/roadmap/store.ts b/src/renderer/features/roadmap/store.ts deleted file mode 100644 index 0cc92dd3..00000000 --- a/src/renderer/features/roadmap/store.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Roadmap UI state store - */ - -import { create } from 'zustand'; - -interface RoadmapUIState { - selectedMilestoneId: string | null; - isCreating: boolean; - selectMilestone: (id: string | null) => void; - setCreating: (creating: boolean) => void; -} - -export const useRoadmapUI = create<RoadmapUIState>()((set) => ({ - selectedMilestoneId: null, - isCreating: false, - selectMilestone: (id) => set({ selectedMilestoneId: id }), - setCreating: (creating) => set({ isCreating: creating }), -})); diff --git a/src/renderer/features/runners/api/queryKeys.ts b/src/renderer/features/runners/api/queryKeys.ts new file mode 100644 index 00000000..5caa4db2 --- /dev/null +++ b/src/renderer/features/runners/api/queryKeys.ts @@ -0,0 +1,14 @@ +import type { ScopeRef } from '@shared/ipc/runners/schemas'; + +export const runnerKeys = { + all: ['runners'] as const, + profiles: (projectId: string) => ['runners', 'profiles', projectId] as const, + instances: (scope: ScopeRef) => + [ + 'runners', + 'instances', + scope.kind, + scope.projectId, + scope.kind === 'worktree' ? scope.worktreePath : null, + ] as const, +}; diff --git a/src/renderer/features/runners/api/useRunnerInstances.ts b/src/renderer/features/runners/api/useRunnerInstances.ts new file mode 100644 index 00000000..9adea0c2 --- /dev/null +++ b/src/renderer/features/runners/api/useRunnerInstances.ts @@ -0,0 +1,62 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { RUNNERS } from '@shared/ipc/runners/channels'; +import type { RunnerInstance, ScopeRef } from '@shared/ipc/runners/schemas'; + +import { useMutationErrorToast } from '@renderer/shared/hooks/useMutationErrorToast'; +import { ipc } from '@renderer/shared/lib/ipc'; + +import { runnerKeys } from './queryKeys'; + +export function useRunnerInstances(scope: ScopeRef) { + return useQuery({ + queryKey: runnerKeys.instances(scope), + queryFn: () => ipc(RUNNERS.INSTANCE.LIST, { scope }), + }); +} + +export function useStartRunnerInstance(scope: ScopeRef) { + const qc = useQueryClient(); + const { onError } = useMutationErrorToast(); + return useMutation({ + mutationFn: (profileId: string) => ipc(RUNNERS.INSTANCE.START, { profileId, scope }), + onSuccess: (instance) => { + qc.setQueryData<RunnerInstance[]>(runnerKeys.instances(scope), (old) => { + const existing = old ?? []; + if (existing.some((i) => i.id === instance.id)) return existing; + return [...existing, instance]; + }); + void qc.invalidateQueries({ queryKey: runnerKeys.instances(scope) }); + }, + onError: onError('start runner'), + }); +} + +export function useStopRunnerInstance(scope: ScopeRef) { + const qc = useQueryClient(); + const { onError } = useMutationErrorToast(); + return useMutation({ + mutationFn: (instanceId: string) => ipc(RUNNERS.INSTANCE.STOP, { instanceId }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: runnerKeys.instances(scope) }); + }, + onError: onError('stop runner'), + }); +} + +export function useRestartRunnerInstance(scope: ScopeRef) { + const qc = useQueryClient(); + const { onError } = useMutationErrorToast(); + return useMutation({ + mutationFn: (instanceId: string) => ipc(RUNNERS.INSTANCE.RESTART, { instanceId }), + onSuccess: (instance) => { + qc.setQueryData<RunnerInstance[]>(runnerKeys.instances(scope), (old) => { + const existing = old ?? []; + if (existing.some((i) => i.id === instance.id)) return existing; + return [...existing, instance]; + }); + void qc.invalidateQueries({ queryKey: runnerKeys.instances(scope) }); + }, + onError: onError('restart runner'), + }); +} diff --git a/src/renderer/features/runners/api/useRunnerOutput.ts b/src/renderer/features/runners/api/useRunnerOutput.ts new file mode 100644 index 00000000..d3d2825c --- /dev/null +++ b/src/renderer/features/runners/api/useRunnerOutput.ts @@ -0,0 +1,34 @@ +import { useQueryClient } from '@tanstack/react-query'; + +import { RUNNERS_EVENTS } from '@shared/ipc/runners/channels'; +import type { + RunnerHealthEvent, + RunnerOutputEvent, + RunnerStatusEvent, + ScopeRef, +} from '@shared/ipc/runners/schemas'; + +import { useIpcEvent } from '@renderer/shared/hooks'; + +import { useRunnersStore } from '../runners-store'; + +import { runnerKeys } from './queryKeys'; + +/** Subscribe to runner output, health, and status events and patch the query cache. */ +export function useRunnerEvents(scope: ScopeRef): void { + const appendOutput = useRunnersStore((s) => s.appendOutput); + const setHealth = useRunnersStore((s) => s.setHealth); + const qc = useQueryClient(); + + useIpcEvent(RUNNERS_EVENTS.INSTANCE.OUTPUT, (evt: RunnerOutputEvent) => { + appendOutput(evt); + }); + + useIpcEvent(RUNNERS_EVENTS.INSTANCE.HEALTH, (evt: RunnerHealthEvent) => { + setHealth(evt); + }); + + useIpcEvent(RUNNERS_EVENTS.INSTANCE.STATUS, (_evt: RunnerStatusEvent) => { + void qc.invalidateQueries({ queryKey: runnerKeys.instances(scope) }); + }); +} diff --git a/src/renderer/features/runners/api/useRunnerProfiles.ts b/src/renderer/features/runners/api/useRunnerProfiles.ts new file mode 100644 index 00000000..fb772918 --- /dev/null +++ b/src/renderer/features/runners/api/useRunnerProfiles.ts @@ -0,0 +1,57 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { RUNNERS } from '@shared/ipc/runners/channels'; +import type { RunnerProfile } from '@shared/ipc/runners/schemas'; + +import { useMutationErrorToast } from '@renderer/shared/hooks/useMutationErrorToast'; +import { ipc } from '@renderer/shared/lib/ipc'; + +import { runnerKeys } from './queryKeys'; + +export function useRunnerProfiles(projectId: string) { + return useQuery({ + queryKey: runnerKeys.profiles(projectId), + queryFn: () => ipc(RUNNERS.PROFILE.LIST, { projectId }), + enabled: Boolean(projectId), + }); +} + +export function useSaveRunnerProfile(projectId: string) { + const qc = useQueryClient(); + const { onError } = useMutationErrorToast(); + return useMutation({ + mutationFn: (profile: RunnerProfile) => ipc(RUNNERS.PROFILE.SAVE, { profile }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: runnerKeys.profiles(projectId) }); + }, + onError: onError('save runner profile'), + }); +} + +export function useDeleteRunnerProfile(projectId: string) { + const qc = useQueryClient(); + const { onError } = useMutationErrorToast(); + return useMutation({ + mutationFn: (profileId: string) => ipc(RUNNERS.PROFILE.DELETE, { profileId }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: runnerKeys.profiles(projectId) }); + }, + onError: onError('delete runner profile'), + }); +} + +export function newRunnerProfile(projectId: string): RunnerProfile { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + projectId, + name: 'Dev Server', + command: 'npm run dev', + cwdRelative: '.', + env: {}, + healthCheckTimeoutMs: 30_000, + autoRestart: false, + createdAt: now, + updatedAt: now, + }; +} diff --git a/src/renderer/features/runners/components/ProfileEditDialog.tsx b/src/renderer/features/runners/components/ProfileEditDialog.tsx new file mode 100644 index 00000000..ef3f3976 --- /dev/null +++ b/src/renderer/features/runners/components/ProfileEditDialog.tsx @@ -0,0 +1 @@ +export { ProfileEditDialog } from './ProfileEditDialog/index'; diff --git a/src/renderer/features/runners/components/ProfileEditDialog/ProfileEditDialog.tsx b/src/renderer/features/runners/components/ProfileEditDialog/ProfileEditDialog.tsx new file mode 100644 index 00000000..9c01a3e0 --- /dev/null +++ b/src/renderer/features/runners/components/ProfileEditDialog/ProfileEditDialog.tsx @@ -0,0 +1,96 @@ +import type { RunnerProfile } from '@shared/ipc/runners/schemas'; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Switch, +} from '@ui'; + +import { useProfileEditDialog } from './useProfileEditDialog'; + +interface Props { + open: boolean; + projectId: string; + initial?: RunnerProfile; + onOpenChange: (open: boolean) => void; +} + +export function ProfileEditDialog({ open, projectId, initial, onOpenChange }: Props) { + const { draft, save, setDraft, handleSave } = useProfileEditDialog({ + open, + projectId, + initial, + onOpenChange, + }); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>{initial ? 'Edit Runner Profile' : 'New Runner Profile'}</DialogTitle> + </DialogHeader> + + <div className="grid gap-3"> + <div> + <Label htmlFor="r-name">Name</Label> + <Input + id="r-name" + value={draft.name} + onChange={(e) => setDraft({ ...draft, name: e.target.value })} + /> + </div> + <div> + <Label htmlFor="r-cmd">Command</Label> + <Input + id="r-cmd" + placeholder="npm run dev" + value={draft.command} + onChange={(e) => setDraft({ ...draft, command: e.target.value })} + /> + </div> + <div> + <Label htmlFor="r-cwd">Working directory (relative)</Label> + <Input + id="r-cwd" + value={draft.cwdRelative} + onChange={(e) => setDraft({ ...draft, cwdRelative: e.target.value })} + /> + </div> + <div> + <Label htmlFor="r-url">Health check URL (optional)</Label> + <Input + id="r-url" + placeholder="http://localhost:3000" + value={draft.healthCheckUrl ?? ''} + onChange={(e) => + setDraft({ ...draft, healthCheckUrl: e.target.value || undefined }) + } + /> + </div> + <div className="flex items-center gap-2"> + <Switch + checked={draft.autoRestart} + onCheckedChange={(v) => setDraft({ ...draft, autoRestart: v })} + /> + <Label>Auto-restart on failure</Label> + </div> + </div> + + <DialogFooter> + <Button variant="ghost" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button disabled={save.isPending} onClick={handleSave}> + Save + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/runners/components/ProfileEditDialog/index.ts b/src/renderer/features/runners/components/ProfileEditDialog/index.ts new file mode 100644 index 00000000..0e0f7a3c --- /dev/null +++ b/src/renderer/features/runners/components/ProfileEditDialog/index.ts @@ -0,0 +1,2 @@ +export { ProfileEditDialog } from './ProfileEditDialog'; +export { useProfileEditDialog } from './useProfileEditDialog'; diff --git a/src/renderer/features/runners/components/ProfileEditDialog/useProfileEditDialog.ts b/src/renderer/features/runners/components/ProfileEditDialog/useProfileEditDialog.ts new file mode 100644 index 00000000..c00c21c0 --- /dev/null +++ b/src/renderer/features/runners/components/ProfileEditDialog/useProfileEditDialog.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; + +import type { RunnerProfile } from '@shared/ipc/runners/schemas'; + +import { newRunnerProfile, useSaveRunnerProfile } from '../../api/useRunnerProfiles'; + +interface UseProfileEditDialogProps { + open: boolean; + projectId: string; + initial?: RunnerProfile; + onOpenChange: (open: boolean) => void; +} + +export function useProfileEditDialog({ open, projectId, initial, onOpenChange }: UseProfileEditDialogProps) { + const [draft, setDraft] = useState<RunnerProfile>(initial ?? newRunnerProfile(projectId)); + const save = useSaveRunnerProfile(projectId); + + useEffect(() => { + setDraft(initial ?? newRunnerProfile(projectId)); + }, [initial, projectId, open]); + + function handleSave() { + save.mutate( + { ...draft, updatedAt: new Date().toISOString() }, + { + onSuccess: () => onOpenChange(false), + }, + ); + } + + return { + draft, + save, + setDraft, + handleSave, + }; +} diff --git a/src/renderer/features/runners/components/RunnerOutputConsole.tsx b/src/renderer/features/runners/components/RunnerOutputConsole.tsx new file mode 100644 index 00000000..96a086ce --- /dev/null +++ b/src/renderer/features/runners/components/RunnerOutputConsole.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef } from 'react'; + +import { useRunnersStore } from '../runners-store'; + +interface Props { + instanceId: string | undefined; + hint?: string; +} + +export function RunnerOutputConsole({ instanceId, hint }: Props) { + const lines = useRunnersStore((s) => (instanceId ? s.outputs[instanceId] : undefined)) ?? []; + const endRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [lines.length]); + + if (!instanceId) { + return ( + <div className="flex h-40 items-center justify-center text-sm text-text-muted"> + {hint ?? 'No runner selected.'} + </div> + ); + } + + return ( + <div className="h-48 overflow-y-auto bg-surface-subtle font-mono text-xs leading-tight"> + {lines.map((line, i) => ( + // eslint-disable-next-line react/no-array-index-key -- output lines are append-only, index is stable + <div key={i} className="whitespace-pre-wrap px-3 py-0.5"> + {line} + </div> + ))} + <div ref={endRef} /> + </div> + ); +} diff --git a/src/renderer/features/runners/components/RunnerPanel.tsx b/src/renderer/features/runners/components/RunnerPanel.tsx new file mode 100644 index 00000000..dc12d503 --- /dev/null +++ b/src/renderer/features/runners/components/RunnerPanel.tsx @@ -0,0 +1 @@ +export { RunnerPanel } from './RunnerPanel/index'; diff --git a/src/renderer/features/runners/components/RunnerPanel/RunnerPanel.tsx b/src/renderer/features/runners/components/RunnerPanel/RunnerPanel.tsx new file mode 100644 index 00000000..531160e3 --- /dev/null +++ b/src/renderer/features/runners/components/RunnerPanel/RunnerPanel.tsx @@ -0,0 +1,113 @@ +import { Pencil, Play, RotateCw, Square } from 'lucide-react'; + +import type { ScopeRef } from '@shared/ipc/runners/schemas'; + +import { + Button, + Heading, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@ui'; + +import { ProfileEditDialog } from '../ProfileEditDialog'; +import { RunnerOutputConsole } from '../RunnerOutputConsole'; +import { RunnerStatusChip } from '../RunnerStatusChip'; + +import { useRunnerPanel } from './useRunnerPanel'; + +interface Props { + scope: ScopeRef; + heading?: string; +} + +export function RunnerPanel({ scope, heading = 'Dev Server' }: Props) { + const { + profiles, + activeInstance, + selectedProfile, + selectedProfileId, + editing, + scopeLabel, + startDisabledReason, + consoleHint, + start, + stop, + restart, + setSelectedProfileId, + setEditing, + } = useRunnerPanel({ scope }); + + return ( + <div className="flex flex-col rounded-md border border-border bg-surface"> + <div className="flex items-center gap-2 border-b border-border px-3 py-2"> + <Heading as="h3" className="text-sm">{heading}</Heading> + <span className="text-xs text-text-muted">({scopeLabel})</span> + + <Select value={selectedProfileId ?? ''} onValueChange={setSelectedProfileId}> + <SelectTrigger className="h-7 w-48"> + <SelectValue + placeholder={profiles.length === 0 ? 'No profiles' : 'Select profile…'} + /> + </SelectTrigger> + <SelectContent> + {profiles.map((p) => ( + <SelectItem key={p.id} value={p.id}> + {p.name} + </SelectItem> + ))} + </SelectContent> + </Select> + + {activeInstance ? ( + <> + <RunnerStatusChip status={activeInstance.status} /> + <Button size="sm" variant="ghost" onClick={() => restart.mutate(activeInstance.id)}> + <RotateCw className="h-3 w-3" /> Restart + </Button> + <Button + size="sm" + variant="destructive" + onClick={() => stop.mutate(activeInstance.id)} + > + <Square className="h-3 w-3" /> Stop + </Button> + </> + ) : ( + <Button + disabled={!selectedProfileId || start.isPending} + size="sm" + title={startDisabledReason} + onClick={() => { + if (selectedProfileId) start.mutate(selectedProfileId); + }} + > + <Play className="h-3 w-3" /> Start + </Button> + )} + + <div className="ml-auto flex items-center gap-2"> + <Button + size="sm" + variant="ghost" + onClick={() => setEditing({ open: true, id: selectedProfileId })} + > + <Pencil className="h-3 w-3" /> + {selectedProfile ? 'Edit' : 'New'} + </Button> + </div> + </div> + + <RunnerOutputConsole hint={consoleHint} instanceId={activeInstance?.id} /> + + <ProfileEditDialog + initial={profiles.find((p) => p.id === editing.id)} + open={editing.open} + projectId={scope.projectId} + onOpenChange={(open) => setEditing({ open, id: editing.id })} + /> + </div> + ); +} diff --git a/src/renderer/features/runners/components/RunnerPanel/index.ts b/src/renderer/features/runners/components/RunnerPanel/index.ts new file mode 100644 index 00000000..52eaf606 --- /dev/null +++ b/src/renderer/features/runners/components/RunnerPanel/index.ts @@ -0,0 +1,2 @@ +export { RunnerPanel } from './RunnerPanel'; +export { useRunnerPanel } from './useRunnerPanel'; diff --git a/src/renderer/features/runners/components/RunnerPanel/useRunnerPanel.ts b/src/renderer/features/runners/components/RunnerPanel/useRunnerPanel.ts new file mode 100644 index 00000000..fe114f7d --- /dev/null +++ b/src/renderer/features/runners/components/RunnerPanel/useRunnerPanel.ts @@ -0,0 +1,80 @@ +import { useEffect, useMemo, useState } from 'react'; + +import type { ScopeRef } from '@shared/ipc/runners/schemas'; + +import { + useRestartRunnerInstance, + useRunnerInstances, + useStartRunnerInstance, + useStopRunnerInstance, +} from '../../api/useRunnerInstances'; +import { useRunnerEvents } from '../../api/useRunnerOutput'; +import { useRunnerProfiles } from '../../api/useRunnerProfiles'; +import { useRunnersStore } from '../../runners-store'; + +interface UseRunnerPanelProps { + scope: ScopeRef; +} + +export function useRunnerPanel({ scope }: UseRunnerPanelProps) { + useRunnerEvents(scope); + const { data: profiles = [] } = useRunnerProfiles(scope.projectId); + const { data: instances = [] } = useRunnerInstances(scope); + const start = useStartRunnerInstance(scope); + const stop = useStopRunnerInstance(scope); + const restart = useRestartRunnerInstance(scope); + + const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>(profiles[0]?.id); + const [editing, setEditing] = useState<{ open: boolean; id?: string }>({ open: false }); + + const pruneExcept = useRunnersStore((s) => s.pruneExcept); + useEffect(() => { + pruneExcept(instances.map((i) => i.id)); + }, [instances, pruneExcept]); + + const activeInstance = useMemo( + () => + instances.find( + (i) => + i.profileId === selectedProfileId && + (i.status === 'running' || i.status === 'ready' || i.status === 'starting'), + ), + [instances, selectedProfileId], + ); + + const selectedProfile = profiles.find((p) => p.id === selectedProfileId); + + const scopeLabel = scope.kind === 'worktree' ? 'worktree' : 'project'; + + let startDisabledReason: string | undefined; + if (!selectedProfileId) { + startDisabledReason = profiles.length === 0 + ? 'Create a runner profile first' + : 'Select a profile to start'; + } + + let consoleHint: string; + if (profiles.length === 0) { + consoleHint = 'No runner profiles yet — click New to create one.'; + } else if (selectedProfileId) { + consoleHint = 'Click Start to launch this runner.'; + } else { + consoleHint = 'Select a profile to begin.'; + } + + return { + profiles, + activeInstance, + selectedProfile, + selectedProfileId, + editing, + scopeLabel, + startDisabledReason, + consoleHint, + start, + stop, + restart, + setSelectedProfileId, + setEditing, + }; +} diff --git a/src/renderer/features/runners/components/RunnerStatusChip.tsx b/src/renderer/features/runners/components/RunnerStatusChip.tsx new file mode 100644 index 00000000..f32787b2 --- /dev/null +++ b/src/renderer/features/runners/components/RunnerStatusChip.tsx @@ -0,0 +1,27 @@ +import type { RunnerStatus } from '@shared/ipc/runners/schemas'; + +import { Badge } from '@ui'; + +const VARIANT: Record<RunnerStatus, Parameters<typeof Badge>[0]['variant']> = { + idle: 'secondary', + starting: 'secondary', + running: 'default', + ready: 'default', + failed: 'destructive', + stopping: 'secondary', + stopped: 'outline', +}; + +const LABEL: Record<RunnerStatus, string> = { + idle: 'Idle', + starting: 'Starting…', + running: 'Running', + ready: 'Ready', + failed: 'Failed', + stopping: 'Stopping…', + stopped: 'Stopped', +}; + +export function RunnerStatusChip({ status }: { status: RunnerStatus }) { + return <Badge variant={VARIANT[status]}>{LABEL[status]}</Badge>; +} diff --git a/src/renderer/features/runners/index.ts b/src/renderer/features/runners/index.ts new file mode 100644 index 00000000..6059623b --- /dev/null +++ b/src/renderer/features/runners/index.ts @@ -0,0 +1,17 @@ +export { RunnerPanel } from './components/RunnerPanel'; +export { ProfileEditDialog } from './components/ProfileEditDialog'; +export { RunnerStatusChip } from './components/RunnerStatusChip'; +export { RunnerOutputConsole } from './components/RunnerOutputConsole'; +export { + newRunnerProfile, + useDeleteRunnerProfile, + useRunnerProfiles, + useSaveRunnerProfile, +} from './api/useRunnerProfiles'; +export { + useRestartRunnerInstance, + useRunnerInstances, + useStartRunnerInstance, + useStopRunnerInstance, +} from './api/useRunnerInstances'; +export { useRunnerEvents } from './api/useRunnerOutput'; diff --git a/src/renderer/features/runners/runners-store.ts b/src/renderer/features/runners/runners-store.ts new file mode 100644 index 00000000..a97da925 --- /dev/null +++ b/src/renderer/features/runners/runners-store.ts @@ -0,0 +1,49 @@ +import { create } from 'zustand'; + +import type { + RunnerHealthEvent, + RunnerOutputEvent, +} from '@shared/ipc/runners/schemas'; + +const MAX_LINES = 2000; + +interface RunnerUiState { + outputs: Record<string, string[]>; + lastHealth: Record<string, RunnerHealthEvent>; + appendOutput: (evt: RunnerOutputEvent) => void; + setHealth: (evt: RunnerHealthEvent) => void; + clearInstance: (instanceId: string) => void; + pruneExcept: (keepIds: string[]) => void; +} + +export const useRunnersStore = create<RunnerUiState>((set) => ({ + outputs: {}, + lastHealth: {}, + appendOutput: (evt) => + set((s) => { + const prev = s.outputs[evt.instanceId] ?? []; + const nextLines = evt.chunk.split(/\r?\n/).filter(Boolean); + const merged = [...prev, ...nextLines].slice(-MAX_LINES); + return { outputs: { ...s.outputs, [evt.instanceId]: merged } }; + }), + setHealth: (evt) => set((s) => ({ lastHealth: { ...s.lastHealth, [evt.instanceId]: evt } })), + clearInstance: (instanceId) => + set((s) => { + const { [instanceId]: _o, ...outputs } = s.outputs; + const { [instanceId]: _h, ...lastHealth } = s.lastHealth; + return { outputs, lastHealth }; + }), + pruneExcept: (keepIds) => + set((s) => { + const keep = new Set(keepIds); + const outputs: Record<string, string[]> = {}; + for (const [id, lines] of Object.entries(s.outputs)) { + if (keep.has(id)) outputs[id] = lines; + } + const lastHealth: Record<string, RunnerHealthEvent> = {}; + for (const [id, evt] of Object.entries(s.lastHealth)) { + if (keep.has(id)) lastHealth[id] = evt; + } + return { outputs, lastHealth }; + }), +})); diff --git a/src/renderer/features/settings/FEATURE.md b/src/renderer/features/settings/FEATURE.md index e763d0be..34935583 100644 --- a/src/renderer/features/settings/FEATURE.md +++ b/src/renderer/features/settings/FEATURE.md @@ -1,19 +1,17 @@ # Settings -Application settings page with sections for appearance, Hub connection, OAuth providers, webhooks, profiles, workspaces, and devices. +Application settings page with sections for appearance, peers (P2P pairing), OAuth providers, webhooks, profiles, workspaces, and devices. ## API Hooks - **`api/useSettings.ts`** — Query/mutation hooks for app settings and user profiles (CRUD, set default) - **`api/useWebhookConfig.ts`** — Query/mutation hooks for webhook configuration -- **`api/useHub.ts`** — Hub connection status/config queries and connect/disconnect mutations ## Page and Sections - **`components/SettingsPage.tsx`** — Main settings view composing all section components below -- Appearance: **`AppearanceModeSection.tsx`**, **`ColorThemeSection.tsx`**, **`UiScaleSection.tsx`**, **`TypographySection.tsx`**, **`BackgroundSettings.tsx`** +- Appearance: **`AppearanceModeSection.tsx`**, **`LayoutSection.tsx`** (includes color theme selector), **`UiScaleSection.tsx`**, **`TypographySection.tsx`**, **`BackgroundSettings.tsx`** - **`components/HotkeySettings.tsx`** — Keyboard shortcut configuration -- **`components/HubSettings.tsx`** — Hub server connection and status display - **`components/ProfileSection.tsx`** — Profile management with **`ProfileCard.tsx`** and **`ProfileFormModal.tsx`** - **`components/WorkspacesTab.tsx`** — Workspace list with **`WorkspaceCard.tsx`** and **`WorkspaceEditor.tsx`** - **`components/DeviceSelector.tsx`** — Device picker with **`DeviceCard.tsx`** @@ -27,4 +25,4 @@ Application settings page with sections for appearance, Hub connection, OAuth pr ## How It Connects -- IPC channels: `settings.*` for app config, `hub.*` for connection; theme store for live appearance updates +- IPC channels: `settings.*` for app config; theme store for live appearance updates diff --git a/src/renderer/features/settings/api/useDevices.ts b/src/renderer/features/settings/api/useDevices.ts index da6f0ca5..5de1006d 100644 --- a/src/renderer/features/settings/api/useDevices.ts +++ b/src/renderer/features/settings/api/useDevices.ts @@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { DEVICES } from '@shared/ipc/misc/devices.channels'; +import { DEVICES } from '@shared/ipc/devices'; import type { InvokeInput } from '@shared/ipc-contract'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/settings/api/useHub.ts b/src/renderer/features/settings/api/useHub.ts deleted file mode 100644 index 3803f963..00000000 --- a/src/renderer/features/settings/api/useHub.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * React Query hooks for hub connection management - */ - -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { HUB } from '@shared/ipc/hub/channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -export const hubKeys = { - all: ['hub'] as const, - status: () => [...hubKeys.all, 'status'] as const, - config: () => [...hubKeys.all, 'config'] as const, -}; - -/** Fetch current hub connection status. */ -export function useHubStatus() { - return useQuery({ - queryKey: hubKeys.status(), - queryFn: () => ipc(HUB.GET.STATUS, {}), - refetchInterval: 15_000, - }); -} - -/** Fetch hub configuration. */ -export function useHubConfig() { - return useQuery({ - queryKey: hubKeys.config(), - queryFn: () => ipc(HUB.GET.CONFIG, {}), - staleTime: 60_000, - }); -} - -/** Connect to the hub server. */ -export function useHubConnect() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: { url: string; apiKey: string }) => ipc(HUB.CONNECT.SERVER, data), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: hubKeys.status() }); - void queryClient.invalidateQueries({ queryKey: hubKeys.config() }); - }, - }); -} - -/** Disconnect from the hub server. */ -export function useHubDisconnect() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: () => ipc(HUB.DISCONNECT.SERVER, {}), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: hubKeys.status() }); - }, - }); -} - -/** Trigger a manual sync with the hub. */ -export function useHubSync() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: () => ipc(HUB.SYNC.DATA, {}), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: hubKeys.status() }); - }, - }); -} - -/** Remove hub configuration entirely. */ -export function useHubRemoveConfig() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: () => ipc(HUB.REMOVE.CONFIG, {}), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: hubKeys.status() }); - void queryClient.invalidateQueries({ queryKey: hubKeys.config() }); - }, - }); -} diff --git a/src/renderer/features/settings/api/useScreenCapture.ts b/src/renderer/features/settings/api/useScreenCapture.ts index a78440be..4477a861 100644 --- a/src/renderer/features/settings/api/useScreenCapture.ts +++ b/src/renderer/features/settings/api/useScreenCapture.ts @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { SCREEN } from '@shared/ipc/misc/screen.channels'; +import { SCREEN } from '@shared/ipc/screen'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/settings/api/useSettings.ts b/src/renderer/features/settings/api/useSettings.ts index 4cc43d79..653e70c3 100644 --- a/src/renderer/features/settings/api/useSettings.ts +++ b/src/renderer/features/settings/api/useSettings.ts @@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { SETTINGS } from '@shared/ipc/settings/channels'; -import type { SidebarLayoutId } from '@shared/types/layout'; +import type { LayoutPreset } from '@shared/types/layout'; import { ipc } from '@renderer/shared/lib/ipc'; import { useLayoutStore, useThemeStore } from '@renderer/shared/stores'; @@ -21,8 +21,8 @@ export const settingsKeys = { /** Fetch app settings */ export function useSettings() { - const { setMode, setColorTheme, setUiScale, setCustomThemes } = useThemeStore(); - const { setSidebarLayout } = useLayoutStore(); + const { setMode, setColorTheme, setUiScale, setCustomThemes, setLayoutGap } = useThemeStore(); + const { setLayoutPreset } = useLayoutStore(); return useQuery({ queryKey: settingsKeys.app(), @@ -33,9 +33,10 @@ export function useSettings() { setMode(settings.theme); setColorTheme(settings.colorTheme); setUiScale(settings.uiScale); - if (settings.sidebarLayout) { - setSidebarLayout(settings.sidebarLayout as SidebarLayoutId); + if (settings.layoutPreset) { + setLayoutPreset(settings.layoutPreset as LayoutPreset); } + if (settings.layoutGap !== undefined) setLayoutGap(settings.layoutGap); if (settings.fontFamily) { document.documentElement.style.setProperty('--app-font-sans', settings.fontFamily); } diff --git a/src/renderer/features/settings/api/useVoice.ts b/src/renderer/features/settings/api/useVoice.ts index a94beff4..f6465443 100644 --- a/src/renderer/features/settings/api/useVoice.ts +++ b/src/renderer/features/settings/api/useVoice.ts @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { VOICE } from '@shared/ipc/misc/voice.channels'; +import { VOICE } from '@shared/ipc/voice'; import type { VoiceConfig, VoiceInputMode } from '@shared/types'; import { ipc } from '@renderer/shared/lib/ipc'; diff --git a/src/renderer/features/settings/components/AppBehaviorSection.tsx b/src/renderer/features/settings/components/AppBehaviorSection/AppBehaviorSection.tsx similarity index 77% rename from src/renderer/features/settings/components/AppBehaviorSection.tsx rename to src/renderer/features/settings/components/AppBehaviorSection/AppBehaviorSection.tsx index 7eb109de..fdc320d2 100644 --- a/src/renderer/features/settings/components/AppBehaviorSection.tsx +++ b/src/renderer/features/settings/components/AppBehaviorSection/AppBehaviorSection.tsx @@ -4,29 +4,19 @@ import { Heading, Input, Label, Switch, Text } from '@ui'; -import { useSettings, useUpdateSettings, useAgentSettings, useUpdateAgentSettings } from '../api/useSettings'; +import { useAppBehaviorSection } from './useAppBehaviorSection'; export function AppBehaviorSection() { - // 1. Hooks - const { data: settings } = useSettings(); - const updateSettings = useUpdateSettings(); - const { data: agentSettings } = useAgentSettings(); - const updateAgentSettings = useUpdateAgentSettings(); + const { + openAtLogin, + minimizeToTray, + startMinimized, + keepRunning, + maxConcurrentAgents, + updateSettings, + handleMaxConcurrentAgentsChange, + } = useAppBehaviorSection(); - // 2. Derived state - const openAtLogin = settings?.openAtLogin ?? false; - const minimizeToTray = settings?.minimizeToTray ?? false; - const startMinimized = settings?.startMinimized ?? false; - const keepRunning = settings?.keepRunning ?? false; - const maxConcurrentAgents = agentSettings?.maxConcurrentAgents ?? 5; - - // 3. Event handlers - function handleMaxConcurrentAgentsChange(event: React.ChangeEvent<HTMLInputElement>) { - const value = Math.min(20, Math.max(1, Number(event.target.value))); - updateAgentSettings.mutate({ maxConcurrentAgents: value }); - } - - // 4. Render return ( <section className="mb-8"> <Heading className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> diff --git a/src/renderer/features/settings/components/AppBehaviorSection/index.ts b/src/renderer/features/settings/components/AppBehaviorSection/index.ts new file mode 100644 index 00000000..1b660607 --- /dev/null +++ b/src/renderer/features/settings/components/AppBehaviorSection/index.ts @@ -0,0 +1,2 @@ +export { AppBehaviorSection } from './AppBehaviorSection'; +export { useAppBehaviorSection } from './useAppBehaviorSection'; diff --git a/src/renderer/features/settings/components/AppBehaviorSection/useAppBehaviorSection.ts b/src/renderer/features/settings/components/AppBehaviorSection/useAppBehaviorSection.ts new file mode 100644 index 00000000..0f631035 --- /dev/null +++ b/src/renderer/features/settings/components/AppBehaviorSection/useAppBehaviorSection.ts @@ -0,0 +1,33 @@ +/** + * useAppBehaviorSection — logic hook for AppBehaviorSection + */ + +import { useAgentSettings, useSettings, useUpdateAgentSettings, useUpdateSettings } from '../../api/useSettings'; + +export function useAppBehaviorSection() { + const { data: settings } = useSettings(); + const updateSettings = useUpdateSettings(); + const { data: agentSettings } = useAgentSettings(); + const updateAgentSettings = useUpdateAgentSettings(); + + const openAtLogin = settings?.openAtLogin ?? false; + const minimizeToTray = settings?.minimizeToTray ?? false; + const startMinimized = settings?.startMinimized ?? false; + const keepRunning = settings?.keepRunning ?? false; + const maxConcurrentAgents = agentSettings?.maxConcurrentAgents ?? 5; + + function handleMaxConcurrentAgentsChange(event: React.ChangeEvent<HTMLInputElement>) { + const value = Math.min(20, Math.max(1, Number(event.target.value))); + updateAgentSettings.mutate({ maxConcurrentAgents: value }); + } + + return { + openAtLogin, + minimizeToTray, + startMinimized, + keepRunning, + maxConcurrentAgents, + updateSettings, + handleMaxConcurrentAgentsChange, + }; +} diff --git a/src/renderer/features/settings/components/AppearanceModeSection.tsx b/src/renderer/features/settings/components/AppearanceModeSection.tsx index adfe326b..2a179a40 100644 --- a/src/renderer/features/settings/components/AppearanceModeSection.tsx +++ b/src/renderer/features/settings/components/AppearanceModeSection.tsx @@ -8,7 +8,7 @@ import type { ThemeMode } from '@shared/types'; import { cn } from '@renderer/shared/lib/utils'; -import { Button } from '@ui'; +import { Button, Heading } from '@ui'; // ── Constants ─────────────────────────────────────────────── @@ -33,9 +33,9 @@ interface AppearanceModeSectionProps { export function AppearanceModeSection({ currentMode, onModeChange }: AppearanceModeSectionProps) { return ( <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> Appearance - </h2> + </Heading> <div className="flex gap-3"> {THEME_MODE_OPTIONS.map((opt) => ( <Button diff --git a/src/renderer/features/settings/components/BackgroundSettings.tsx b/src/renderer/features/settings/components/BackgroundSettings.tsx deleted file mode 100644 index 2f1dfea1..00000000 --- a/src/renderer/features/settings/components/BackgroundSettings.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/** - * BackgroundSettings -- Tray, startup, and background behavior settings - * - * Toggles for: launch at startup, minimize to tray, start minimized, - * keep running in background. Loads initial values from settings. - */ - -import { useEffect, useState } from 'react'; - -import { APP } from '@shared/ipc/app/channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { Switch } from '@ui'; - - -import { useSettings, useUpdateSettings } from '../api/useSettings'; - -// -- Types -- - -interface ToggleRowProps { - checked: boolean; - description: string; - label: string; - onChange: (checked: boolean) => void; -} - -// -- Toggle Row -- - -function ToggleRow({ label, description, checked, onChange }: ToggleRowProps) { - return ( - <div className="flex items-center justify-between gap-4 py-3"> - <div className="flex-1"> - <p className="text-sm font-medium">{label}</p> - <p className="text-muted-foreground text-xs">{description}</p> - </div> - <Switch - aria-label={label} - checked={checked} - onCheckedChange={onChange} - /> - </div> - ); -} - -// -- Component -- - -export function BackgroundSettings() { - const { data: settings } = useSettings(); - const updateSettings = useUpdateSettings(); - - const [openAtLogin, setOpenAtLogin] = useState(false); - const [minimizeToTray, setMinimizeToTray] = useState(false); - const [startMinimized, setStartMinimized] = useState(false); - const [keepRunning, setKeepRunning] = useState(true); - - // Hydrate from settings when data loads - useEffect(() => { - if (settings) { - setMinimizeToTray(settings.minimizeToTray ?? false); - setStartMinimized(settings.startMinimized ?? false); - setKeepRunning(settings.keepRunning ?? true); - setOpenAtLogin(settings.openAtLogin ?? false); - } - }, [settings]); - - function handleOpenAtLogin(checked: boolean) { - setOpenAtLogin(checked); - void ipc(APP.SET['LOGIN-SETTING'], { enabled: checked }); - updateSettings.mutate({ openAtLogin: checked }); - } - - function handleMinimizeToTray(checked: boolean) { - setMinimizeToTray(checked); - updateSettings.mutate({ minimizeToTray: checked }); - } - - function handleStartMinimized(checked: boolean) { - setStartMinimized(checked); - updateSettings.mutate({ startMinimized: checked }); - } - - function handleKeepRunning(checked: boolean) { - setKeepRunning(checked); - updateSettings.mutate({ keepRunning: checked }); - } - - return ( - <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> - Background & Startup - </h2> - <div className="border-border bg-card divide-border divide-y rounded-lg border px-4"> - <ToggleRow - checked={openAtLogin} - description="Automatically launch the app when you log in to your computer" - label="Launch at system startup" - onChange={handleOpenAtLogin} - /> - <ToggleRow - checked={minimizeToTray} - description="Minimize to system tray instead of closing the window" - label="Minimize to tray on close" - onChange={handleMinimizeToTray} - /> - <ToggleRow - checked={startMinimized} - description="Start the app minimized to the system tray" - label="Start minimized" - onChange={handleStartMinimized} - /> - <ToggleRow - checked={keepRunning} - description="Keep background services running when the window is closed" - label="Keep running in background" - onChange={handleKeepRunning} - /> - </div> - </section> - ); -} diff --git a/src/renderer/features/settings/components/BackgroundSettings/BackgroundSettings.tsx b/src/renderer/features/settings/components/BackgroundSettings/BackgroundSettings.tsx new file mode 100644 index 00000000..19149826 --- /dev/null +++ b/src/renderer/features/settings/components/BackgroundSettings/BackgroundSettings.tsx @@ -0,0 +1,86 @@ +/** + * BackgroundSettings -- Tray, startup, and background behavior settings + * + * Toggles for: launch at startup, minimize to tray, start minimized, + * keep running in background. Loads initial values from settings. + */ + +import { Heading, Switch } from '@ui'; + +import { useBackgroundSettings } from './useBackgroundSettings'; + +// -- Types -- + +interface ToggleRowProps { + checked: boolean; + description: string; + label: string; + onChange: (checked: boolean) => void; +} + +// -- Toggle Row -- + +function ToggleRow({ label, description, checked, onChange }: ToggleRowProps) { + return ( + <div className="flex items-center justify-between gap-4 py-3"> + <div className="flex-1"> + <p className="text-sm font-medium">{label}</p> + <p className="text-muted-foreground text-xs">{description}</p> + </div> + <Switch + aria-label={label} + checked={checked} + onCheckedChange={onChange} + /> + </div> + ); +} + +// -- Component -- + +export function BackgroundSettings() { + const { + openAtLogin, + minimizeToTray, + startMinimized, + keepRunning, + handleOpenAtLogin, + handleMinimizeToTray, + handleStartMinimized, + handleKeepRunning, + } = useBackgroundSettings(); + + return ( + <section className="mb-8"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + Background & Startup + </Heading> + <div className="border-border bg-card divide-border divide-y rounded-lg border px-4"> + <ToggleRow + checked={openAtLogin} + description="Automatically launch the app when you log in to your computer" + label="Launch at system startup" + onChange={handleOpenAtLogin} + /> + <ToggleRow + checked={minimizeToTray} + description="Minimize to system tray instead of closing the window" + label="Minimize to tray on close" + onChange={handleMinimizeToTray} + /> + <ToggleRow + checked={startMinimized} + description="Start the app minimized to the system tray" + label="Start minimized" + onChange={handleStartMinimized} + /> + <ToggleRow + checked={keepRunning} + description="Keep background services running when the window is closed" + label="Keep running in background" + onChange={handleKeepRunning} + /> + </div> + </section> + ); +} diff --git a/src/renderer/features/settings/components/BackgroundSettings/index.ts b/src/renderer/features/settings/components/BackgroundSettings/index.ts new file mode 100644 index 00000000..f1871f36 --- /dev/null +++ b/src/renderer/features/settings/components/BackgroundSettings/index.ts @@ -0,0 +1,2 @@ +export { BackgroundSettings } from './BackgroundSettings'; +export { useBackgroundSettings } from './useBackgroundSettings'; diff --git a/src/renderer/features/settings/components/BackgroundSettings/useBackgroundSettings.ts b/src/renderer/features/settings/components/BackgroundSettings/useBackgroundSettings.ts new file mode 100644 index 00000000..7ca57ade --- /dev/null +++ b/src/renderer/features/settings/components/BackgroundSettings/useBackgroundSettings.ts @@ -0,0 +1,73 @@ +/** + * useBackgroundSettings — logic hook for BackgroundSettings + */ + +import { useMemo } from 'react'; + +import { APP } from '@shared/ipc/app/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { useSettings, useUpdateSettings } from '../../api/useSettings'; +import { useBooleanSettingsSync } from '../../hooks/useBooleanSettingsSync'; + +const FIELDS = ['openAtLogin', 'minimizeToTray', 'startMinimized', 'keepRunning'] as const; +const DEFAULTS: Record<(typeof FIELDS)[number], boolean> = { + openAtLogin: false, + minimizeToTray: false, + startMinimized: false, + keepRunning: true, +}; + +export function useBackgroundSettings() { + const { data: settings } = useSettings(); + const updateSettings = useUpdateSettings(); + + const source = useMemo( + () => + settings + ? { + openAtLogin: settings.openAtLogin ?? undefined, + minimizeToTray: settings.minimizeToTray ?? undefined, + startMinimized: settings.startMinimized ?? undefined, + keepRunning: settings.keepRunning ?? undefined, + } + : undefined, + [settings], + ); + + const { openAtLogin, minimizeToTray, startMinimized, keepRunning, setField } = + useBooleanSettingsSync(FIELDS, source, DEFAULTS); + + function handleOpenAtLogin(checked: boolean) { + setField('openAtLogin', checked); + void ipc(APP.SET['LOGIN-SETTING'], { enabled: checked }); + updateSettings.mutate({ openAtLogin: checked }); + } + + function handleMinimizeToTray(checked: boolean) { + setField('minimizeToTray', checked); + updateSettings.mutate({ minimizeToTray: checked }); + } + + function handleStartMinimized(checked: boolean) { + setField('startMinimized', checked); + updateSettings.mutate({ startMinimized: checked }); + } + + function handleKeepRunning(checked: boolean) { + setField('keepRunning', checked); + updateSettings.mutate({ keepRunning: checked }); + } + + return { + openAtLogin, + minimizeToTray, + startMinimized, + keepRunning, + handleOpenAtLogin, + handleMinimizeToTray, + handleStartMinimized, + handleKeepRunning, + }; +} diff --git a/src/renderer/features/settings/components/ClaudeAuthSettings.tsx b/src/renderer/features/settings/components/ClaudeAuthSettings/ClaudeAuthSettings.tsx similarity index 78% rename from src/renderer/features/settings/components/ClaudeAuthSettings.tsx rename to src/renderer/features/settings/components/ClaudeAuthSettings/ClaudeAuthSettings.tsx index 3056d762..c822e4f0 100644 --- a/src/renderer/features/settings/components/ClaudeAuthSettings.tsx +++ b/src/renderer/features/settings/components/ClaudeAuthSettings/ClaudeAuthSettings.tsx @@ -7,49 +7,23 @@ * and restart paused agents. */ -import { useEffect, useState } from 'react'; - import { CheckCircle2, Terminal, XCircle } from 'lucide-react'; - -import { APP } from '@shared/ipc/app/channels'; - -import { useClaudeAuth } from '@renderer/shared/hooks'; -import { ipc } from '@renderer/shared/lib/ipc'; - import { Button, Spinner } from '@ui'; +import { useClaudeAuthSettings } from './useClaudeAuthSettings'; + // ── Component ─────────────────────────────────────────────── export function ClaudeAuthSettings() { - const { data: auth, isLoading, refetch } = useClaudeAuth(); - const [authorizing, setAuthorizing] = useState(false); - - const isInstalled = auth?.installed ?? false; - const isAuthenticated = auth?.authenticated ?? false; - - // Poll while authorizing - useEffect(() => { - if (!authorizing) return; - const interval = setInterval(() => { - void refetch(); - }, 3000); - return () => { - clearInterval(interval); - }; - }, [authorizing, refetch]); - - // Stop polling once authenticated - useEffect(() => { - if (isAuthenticated && authorizing) { - setAuthorizing(false); - } - }, [isAuthenticated, authorizing]); - - function handleAuthorize() { - setAuthorizing(true); - void ipc(APP.LAUNCH['CLAUDE-AUTH'], {}); - } + const { + auth, + isLoading, + authorizing, + isInstalled, + isAuthenticated, + handleAuthorize, + } = useClaudeAuthSettings(); if (isLoading) { return ( diff --git a/src/renderer/features/settings/components/ClaudeAuthSettings/index.ts b/src/renderer/features/settings/components/ClaudeAuthSettings/index.ts new file mode 100644 index 00000000..1cbfc8a6 --- /dev/null +++ b/src/renderer/features/settings/components/ClaudeAuthSettings/index.ts @@ -0,0 +1,2 @@ +export { ClaudeAuthSettings } from './ClaudeAuthSettings'; +export { useClaudeAuthSettings } from './useClaudeAuthSettings'; diff --git a/src/renderer/features/settings/components/ClaudeAuthSettings/useClaudeAuthSettings.ts b/src/renderer/features/settings/components/ClaudeAuthSettings/useClaudeAuthSettings.ts new file mode 100644 index 00000000..b766ff88 --- /dev/null +++ b/src/renderer/features/settings/components/ClaudeAuthSettings/useClaudeAuthSettings.ts @@ -0,0 +1,35 @@ +/** + * useClaudeAuthSettings — logic hook for ClaudeAuthSettings + */ + +import { APP } from '@shared/ipc/app/channels'; + +import { useClaudeAuth } from '@renderer/shared/hooks'; +import { useAuthPolling } from '@renderer/shared/hooks/useAuthPolling'; +import { ipc } from '@renderer/shared/lib/ipc'; + +export function useClaudeAuthSettings() { + const { data: auth, isLoading, refetch } = useClaudeAuth(); + + const isInstalled = auth?.installed ?? false; + const isAuthenticated = auth?.authenticated ?? false; + + const { authorizing, handleAuthorize: startPolling } = useAuthPolling( + refetch, + isAuthenticated, + ); + + function handleAuthorize() { + startPolling(); + void ipc(APP.LAUNCH['CLAUDE-AUTH'], {}); + } + + return { + auth, + isLoading, + authorizing, + isInstalled, + isAuthenticated, + handleAuthorize, + }; +} diff --git a/src/renderer/features/settings/components/CollapsibleInstructions.tsx b/src/renderer/features/settings/components/CollapsibleInstructions.tsx index 9420e066..c691ad44 100644 --- a/src/renderer/features/settings/components/CollapsibleInstructions.tsx +++ b/src/renderer/features/settings/components/CollapsibleInstructions.tsx @@ -8,7 +8,7 @@ import { ChevronDown } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; -import { Button } from '@ui'; +import { Button, Separator } from '@ui'; interface CollapsibleInstructionsProps { @@ -40,7 +40,12 @@ export function CollapsibleInstructions({ )} /> </Button> - {isOpen ? <div className="border-border border-t px-3 py-3">{children}</div> : null} + {isOpen ? ( + <> + <Separator /> + <div className="px-3 py-3">{children}</div> + </> + ) : null} </div> ); } diff --git a/src/renderer/features/settings/components/ColorThemeSection.tsx b/src/renderer/features/settings/components/ColorThemeSection.tsx deleted file mode 100644 index 79e435f9..00000000 --- a/src/renderer/features/settings/components/ColorThemeSection.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * ColorThemeSection — Shows active theme name and navigates to Theme Editor - */ - -import { useNavigate } from '@tanstack/react-router'; -import { Palette } from 'lucide-react'; - -import { ROUTES } from '@shared/constants'; - -import { Button } from '@ui'; - - -// ── Component ─────────────────────────────────────────────── - -interface ColorThemeSectionProps { - currentTheme: string; -} - -export function ColorThemeSection({ currentTheme }: ColorThemeSectionProps) { - const navigate = useNavigate(); - - const displayName = currentTheme === 'default' ? 'Default' : currentTheme; - - function handleCustomize() { - // Route registered by Task #6 — type assertion needed until route tree includes /settings/themes - void navigate({ to: ROUTES.THEMES as '/' }); - } - - return ( - <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> - Color Theme - </h2> - <div className="border-border bg-card flex items-center justify-between rounded-lg border px-4 py-3"> - <div className="flex flex-col gap-0.5"> - <span className="text-sm font-medium">Active Theme</span> - <span className="text-muted-foreground text-xs">{displayName}</span> - </div> - <Button size="sm" variant="outline" onClick={handleCustomize}> - <Palette className="mr-1.5 h-4 w-4" /> - Customize Theme - </Button> - </div> - </section> - ); -} diff --git a/src/renderer/features/settings/components/DataLocationSection.tsx b/src/renderer/features/settings/components/DataLocationSection.tsx deleted file mode 100644 index 5a749914..00000000 --- a/src/renderer/features/settings/components/DataLocationSection.tsx +++ /dev/null @@ -1,229 +0,0 @@ -/** - * DataLocationSection — Settings section for configuring the ADC data directory. - * - * Shows the current path, lets users pick a new directory, validates it, - * and confirms the change (which requires an app restart). - */ - -import { useState } from 'react'; - -import { useMutation } from '@tanstack/react-query'; -import { CheckCircle, FolderOpen, RotateCcw, XCircle } from 'lucide-react'; - -import { PROJECTS } from '@shared/ipc/projects/channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { Badge, Button, Heading, InlineAlert, Spinner, Text } from '@ui'; - -import { - useConfirmDataDir, - useDataLocation, - useResetDataDir, - useValidateDataDir, -} from '../api/useDataLocation'; - -// ── Types ───────────────────────────────────────────────── - -interface ValidationCheck { - id: string; - label: string; - status: 'pass' | 'warn' | 'fail'; - message: string; -} - -// ── Helpers ─────────────────────────────────────────────── - -function statusToBadgeVariant(status: 'pass' | 'warn' | 'fail') { - if (status === 'pass') return 'success' as const; - if (status === 'warn') return 'warning' as const; - return 'destructive' as const; -} - -function ValidationIcon({ status }: { status: 'pass' | 'warn' | 'fail' }) { - if (status === 'pass') { - return <CheckCircle className="text-success mt-0.5 h-4 w-4 shrink-0" />; - } - if (status === 'warn') { - return <XCircle className="text-warning mt-0.5 h-4 w-4 shrink-0" />; - } - return <XCircle className="text-destructive mt-0.5 h-4 w-4 shrink-0" />; -} - -// ── Component ───────────────────────────────────────────── - -export function DataLocationSection() { - const { data: dirInfo, isLoading } = useDataLocation(); - const validateDir = useValidateDataDir(); - const confirmDir = useConfirmDataDir(); - const resetDir = useResetDataDir(); - - const [selectedPath, setSelectedPath] = useState<string | null>(null); - const [validationChecks, setValidationChecks] = useState<ValidationCheck[]>([]); - const [confirmed, setConfirmed] = useState(false); - - const selectDirectory = useMutation({ - mutationFn: () => ipc(PROJECTS.SELECT.DIRECTORY, {}), - }); - - // ── Derived state ───────────────────────────────────── - - const hasFailures = validationChecks.some((c) => c.status === 'fail'); - const hasExistingDb = validationChecks.some( - (c) => c.id === 'EXISTING_ADC_DB' && c.status === 'warn', - ); - const canApply = selectedPath !== null && validationChecks.length > 0 && !hasFailures; - - // ── Handlers ────────────────────────────────────────── - - async function handlePickDirectory() { - setConfirmed(false); - setValidationChecks([]); - - const result = await selectDirectory.mutateAsync(); - if (!result.path) return; - - setSelectedPath(result.path); - - const validation = await validateDir.mutateAsync(result.path); - setValidationChecks(validation.checks); - } - - async function handleApplyAndRestart(useExisting = false) { - if (!selectedPath) return; - await confirmDir.mutateAsync({ path: selectedPath, useExisting }); - setConfirmed(true); - } - - async function handleReset() { - const result = await resetDir.mutateAsync(); - setSelectedPath(null); - setValidationChecks([]); - if (result.requiresRestart) { - setConfirmed(true); - } - } - - // ── Render ──────────────────────────────────────────── - - if (isLoading) { - return ( - <section className="mb-8"> - <Spinner className="text-muted-foreground" size="sm" /> - </section> - ); - } - - return ( - <section className="mb-8"> - <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> - Data Location - </Heading> - <div className="border-border bg-card space-y-4 rounded-lg border p-4"> - {/* Current path */} - <div className="flex items-center justify-between gap-4"> - <div className="min-w-0 flex-1"> - <Text className="text-sm font-medium">Current directory</Text> - <Text className="text-muted-foreground mt-0.5 truncate text-xs"> - {dirInfo?.current ?? 'Unknown'} - </Text> - {dirInfo?.isCustom ? ( - <Badge className="mt-1" size="sm" variant="info"> - Custom - </Badge> - ) : null} - </div> - <div className="flex shrink-0 gap-2"> - <Button - disabled={selectDirectory.isPending || validateDir.isPending} - size="sm" - variant="outline" - onClick={handlePickDirectory} - > - {selectDirectory.isPending || validateDir.isPending ? ( - <Spinner className="mr-1.5" size="sm" /> - ) : ( - <FolderOpen className="mr-1.5 h-4 w-4" /> - )} - <Text className="text-sm">Change</Text> - </Button> - {dirInfo?.isCustom ? ( - <Button - disabled={resetDir.isPending} - size="sm" - variant="outline" - onClick={handleReset} - > - {resetDir.isPending ? ( - <Spinner className="mr-1.5" size="sm" /> - ) : ( - <RotateCcw className="mr-1.5 h-4 w-4" /> - )} - <Text className="text-sm">Reset to Default</Text> - </Button> - ) : null} - </div> - </div> - - {/* Validation results */} - {validationChecks.length > 0 ? ( - <div className="space-y-2"> - <Text className="text-muted-foreground text-xs font-medium uppercase tracking-wider"> - Validation - </Text> - <ul className="space-y-1.5"> - {validationChecks.map((check) => ( - <li key={check.id} className="flex items-start gap-2"> - <ValidationIcon status={check.status} /> - <div className="min-w-0 flex-1"> - <div className="flex items-center gap-2"> - <Text className="text-sm">{check.label}</Text> - <Badge size="sm" variant={statusToBadgeVariant(check.status)}> - {check.status} - </Badge> - </div> - <Text className="text-muted-foreground text-xs">{check.message}</Text> - </div> - </li> - ))} - </ul> - </div> - ) : null} - - {/* Action buttons after validation */} - {canApply && !confirmed ? ( - <div className="flex gap-2"> - <Button - disabled={confirmDir.isPending} - size="sm" - variant="primary" - onClick={() => handleApplyAndRestart(false)} - > - {confirmDir.isPending ? ( - <Spinner className="mr-1.5" size="sm" /> - ) : null} - <Text className="text-sm">Apply & Restart</Text> - </Button> - {hasExistingDb ? ( - <Button - disabled={confirmDir.isPending} - size="sm" - variant="outline" - onClick={() => handleApplyAndRestart(true)} - > - <Text className="text-sm">Use Existing Data</Text> - </Button> - ) : null} - </div> - ) : null} - - {/* Restart notice */} - {confirmed ? ( - <InlineAlert variant="warning"> - <Text className="text-sm">Restart ADC to apply changes.</Text> - </InlineAlert> - ) : null} - </div> - </section> - ); -} diff --git a/src/renderer/features/settings/components/DataLocationSection/DataLocationSection.tsx b/src/renderer/features/settings/components/DataLocationSection/DataLocationSection.tsx new file mode 100644 index 00000000..1820781d --- /dev/null +++ b/src/renderer/features/settings/components/DataLocationSection/DataLocationSection.tsx @@ -0,0 +1,181 @@ +/** + * DataLocationSection — Settings section for configuring the ADC data directory. + * + * Shows the current path, lets users pick a new directory, validates it, + * and confirms the change (which requires an app restart). + */ + +import { CheckCircle, FolderOpen, RotateCcw, XCircle } from 'lucide-react'; + +import { Badge, Button, Heading, InlineAlert, Spinner, Text } from '@ui'; + +import { useDataLocationSection } from './useDataLocationSection'; + +import type { ValidationCheck } from './useDataLocationSection'; + +// ── Helpers ─────────────────────────────────────────────── + +function statusToBadgeVariant(status: 'pass' | 'warn' | 'fail') { + if (status === 'pass') return 'success' as const; + if (status === 'warn') return 'warning' as const; + return 'destructive' as const; +} + +function ValidationIcon({ status }: { status: ValidationCheck['status'] }) { + if (status === 'pass') { + return <CheckCircle className="text-success mt-0.5 h-4 w-4 shrink-0" />; + } + if (status === 'warn') { + return <XCircle className="text-warning mt-0.5 h-4 w-4 shrink-0" />; + } + return <XCircle className="text-destructive mt-0.5 h-4 w-4 shrink-0" />; +} + +// ── Component ───────────────────────────────────────────── + +export function DataLocationSection() { + const { + dirInfo, + isLoading, + selectDirectory, + validateDir, + confirmDir, + resetDir, + validationChecks, + confirmed, + hasExistingDb, + canApply, + handlePickDirectory, + handleApplyAndRestart, + handleReset, + } = useDataLocationSection(); + + if (isLoading) { + return ( + <section className="mb-8"> + <Spinner className="text-muted-foreground" size="sm" /> + </section> + ); + } + + return ( + <section className="mb-8"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + Data Location + </Heading> + <div className="border-border bg-card space-y-4 rounded-lg border p-4"> + {/* Current path */} + <div className="flex items-center justify-between gap-4"> + <div className="min-w-0 flex-1"> + <Text className="text-sm font-medium">Current directory</Text> + <Text className="text-muted-foreground mt-0.5 truncate text-xs"> + {dirInfo?.current ?? 'Unknown'} + </Text> + {dirInfo?.isCustom ? ( + <Badge className="mt-1" size="sm" variant="info"> + Custom + </Badge> + ) : null} + </div> + <div className="flex shrink-0 gap-2"> + <Button + disabled={selectDirectory.isPending || validateDir.isPending} + size="sm" + variant="outline" + onClick={() => { + void handlePickDirectory(); + }} + > + {selectDirectory.isPending || validateDir.isPending ? ( + <Spinner className="mr-1.5" size="sm" /> + ) : ( + <FolderOpen className="mr-1.5 h-4 w-4" /> + )} + <Text className="text-sm">Change</Text> + </Button> + {dirInfo?.isCustom ? ( + <Button + disabled={resetDir.isPending} + size="sm" + variant="outline" + onClick={() => { + void handleReset(); + }} + > + {resetDir.isPending ? ( + <Spinner className="mr-1.5" size="sm" /> + ) : ( + <RotateCcw className="mr-1.5 h-4 w-4" /> + )} + <Text className="text-sm">Reset to Default</Text> + </Button> + ) : null} + </div> + </div> + + {/* Validation results */} + {validationChecks.length > 0 ? ( + <div className="space-y-2"> + <Text className="text-muted-foreground text-xs font-medium uppercase tracking-wider"> + Validation + </Text> + <ul className="space-y-1.5"> + {validationChecks.map((check) => ( + <li key={check.id} className="flex items-start gap-2"> + <ValidationIcon status={check.status} /> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <Text className="text-sm">{check.label}</Text> + <Badge size="sm" variant={statusToBadgeVariant(check.status)}> + {check.status} + </Badge> + </div> + <Text className="text-muted-foreground text-xs">{check.message}</Text> + </div> + </li> + ))} + </ul> + </div> + ) : null} + + {/* Action buttons after validation */} + {canApply && !confirmed ? ( + <div className="flex gap-2"> + <Button + disabled={confirmDir.isPending} + size="sm" + variant="primary" + onClick={() => { + void handleApplyAndRestart(false); + }} + > + {confirmDir.isPending ? ( + <Spinner className="mr-1.5" size="sm" /> + ) : null} + <Text className="text-sm">Apply & Restart</Text> + </Button> + {hasExistingDb ? ( + <Button + disabled={confirmDir.isPending} + size="sm" + variant="outline" + onClick={() => { + void handleApplyAndRestart(true); + }} + > + <Text className="text-sm">Use Existing Data</Text> + </Button> + ) : null} + </div> + ) : null} + + {/* Restart notice */} + {confirmed ? ( + <InlineAlert variant="warning"> + <Text className="text-sm">Restart ADC to apply changes.</Text> + </InlineAlert> + ) : null} + </div> + </section> + ); +} diff --git a/src/renderer/features/settings/components/DataLocationSection/index.ts b/src/renderer/features/settings/components/DataLocationSection/index.ts new file mode 100644 index 00000000..b98dedb8 --- /dev/null +++ b/src/renderer/features/settings/components/DataLocationSection/index.ts @@ -0,0 +1,2 @@ +export { DataLocationSection } from './DataLocationSection'; +export { useDataLocationSection } from './useDataLocationSection'; diff --git a/src/renderer/features/settings/components/DataLocationSection/useDataLocationSection.ts b/src/renderer/features/settings/components/DataLocationSection/useDataLocationSection.ts new file mode 100644 index 00000000..97804299 --- /dev/null +++ b/src/renderer/features/settings/components/DataLocationSection/useDataLocationSection.ts @@ -0,0 +1,92 @@ +/** + * useDataLocationSection — logic hook for DataLocationSection + */ + +import { useState } from 'react'; + +import { useMutation } from '@tanstack/react-query'; + +import { PROJECTS } from '@shared/ipc/projects/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { + useConfirmDataDir, + useDataLocation, + useResetDataDir, + useValidateDataDir, +} from '../../api/useDataLocation'; + +export interface ValidationCheck { + id: string; + label: string; + status: 'pass' | 'warn' | 'fail'; + message: string; +} + +export function useDataLocationSection() { + const { data: dirInfo, isLoading } = useDataLocation(); + const validateDir = useValidateDataDir(); + const confirmDir = useConfirmDataDir(); + const resetDir = useResetDataDir(); + + const [selectedPath, setSelectedPath] = useState<string | null>(null); + const [validationChecks, setValidationChecks] = useState<ValidationCheck[]>([]); + const [confirmed, setConfirmed] = useState(false); + + const selectDirectory = useMutation({ + mutationFn: () => ipc(PROJECTS.SELECT.DIRECTORY, {}), + }); + + const hasFailures = validationChecks.some((c) => c.status === 'fail'); + const hasExistingDb = validationChecks.some( + (c) => c.id === 'EXISTING_ADC_DB' && c.status === 'warn', + ); + const canApply = selectedPath !== null && validationChecks.length > 0 && !hasFailures; + + async function handlePickDirectory() { + setConfirmed(false); + setValidationChecks([]); + + const result = await selectDirectory.mutateAsync(); + if (!result.path) return; + + setSelectedPath(result.path); + + const validation = await validateDir.mutateAsync(result.path); + setValidationChecks(validation.checks); + } + + async function handleApplyAndRestart(useExisting = false) { + if (!selectedPath) return; + await confirmDir.mutateAsync({ path: selectedPath, useExisting }); + setConfirmed(true); + } + + async function handleReset() { + const result = await resetDir.mutateAsync(); + setSelectedPath(null); + setValidationChecks([]); + if (result.requiresRestart) { + setConfirmed(true); + } + } + + return { + dirInfo, + isLoading, + selectDirectory, + validateDir, + confirmDir, + resetDir, + selectedPath, + validationChecks, + confirmed, + hasFailures, + hasExistingDb, + canApply, + handlePickDirectory, + handleApplyAndRestart, + handleReset, + }; +} diff --git a/src/renderer/features/settings/components/DeviceCard.tsx b/src/renderer/features/settings/components/DeviceCard.tsx index 1d7ca831..d2f933a0 100644 --- a/src/renderer/features/settings/components/DeviceCard.tsx +++ b/src/renderer/features/settings/components/DeviceCard.tsx @@ -8,6 +8,8 @@ import type { Device } from '@shared/types'; import { cn } from '@renderer/shared/lib/utils'; +import { Heading } from '@ui'; + interface DeviceCardProps { device: Device; } @@ -57,7 +59,7 @@ export function DeviceCard({ device }: DeviceCardProps) { {/* Details */} <div className="min-w-0 flex-1"> <div className="flex items-center gap-2"> - <h3 className="truncate text-sm font-semibold">{device.deviceName}</h3> + <Heading as="h3" className="truncate text-sm">{device.deviceName}</Heading> <span className="text-muted-foreground text-xs">({device.deviceType})</span> </div> diff --git a/src/renderer/features/settings/components/GitHubAuthSettings.tsx b/src/renderer/features/settings/components/GitHubAuthSettings/GitHubAuthSettings.tsx similarity index 77% rename from src/renderer/features/settings/components/GitHubAuthSettings.tsx rename to src/renderer/features/settings/components/GitHubAuthSettings/GitHubAuthSettings.tsx index 31ec7679..70d90b59 100644 --- a/src/renderer/features/settings/components/GitHubAuthSettings.tsx +++ b/src/renderer/features/settings/components/GitHubAuthSettings/GitHubAuthSettings.tsx @@ -5,17 +5,11 @@ * which opens the browser device flow. Polls until auth succeeds. */ -import { useEffect, useState } from 'react'; - -import { useQuery } from '@tanstack/react-query'; import { CheckCircle2, XCircle } from 'lucide-react'; -import { APP } from '@shared/ipc/app/channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; - import { Button, Spinner } from '@ui'; +import { useGitHubAuthSettings } from './useGitHubAuthSettings'; // ── Icons ─────────────────────────────────────────────────── @@ -28,48 +22,17 @@ function GitHubIcon({ className }: { className?: string }) { ); } -// ── Hooks ─────────────────────────────────────────────────── - -function useGitHubAuth() { - return useQuery({ - queryKey: ['app', 'githubAuth'], - queryFn: () => ipc(APP.CHECK['GITHUB-AUTH'], {}), - staleTime: 30_000, - refetchInterval: 60_000, - }); -} - // ── Component ─────────────────────────────────────────────── export function GitHubAuthSettings() { - const { data: auth, isLoading, refetch } = useGitHubAuth(); - const [authorizing, setAuthorizing] = useState(false); - - const isInstalled = auth?.installed ?? false; - const isAuthenticated = auth?.authenticated ?? false; - - // Poll while authorizing - useEffect(() => { - if (!authorizing) return; - const interval = setInterval(() => { - void refetch(); - }, 3000); - return () => { - clearInterval(interval); - }; - }, [authorizing, refetch]); - - // Stop polling once authenticated - useEffect(() => { - if (isAuthenticated && authorizing) { - setAuthorizing(false); - } - }, [isAuthenticated, authorizing]); - - function handleConnect() { - setAuthorizing(true); - void ipc(APP.LAUNCH['GITHUB-AUTH'], {}); - } + const { + auth, + isLoading, + authorizing, + isInstalled, + isAuthenticated, + handleConnect, + } = useGitHubAuthSettings(); if (isLoading) { return ( diff --git a/src/renderer/features/settings/components/GitHubAuthSettings/index.ts b/src/renderer/features/settings/components/GitHubAuthSettings/index.ts new file mode 100644 index 00000000..aa93a35b --- /dev/null +++ b/src/renderer/features/settings/components/GitHubAuthSettings/index.ts @@ -0,0 +1,2 @@ +export { GitHubAuthSettings } from './GitHubAuthSettings'; +export { useGitHubAuthSettings } from './useGitHubAuthSettings'; diff --git a/src/renderer/features/settings/components/GitHubAuthSettings/useGitHubAuthSettings.ts b/src/renderer/features/settings/components/GitHubAuthSettings/useGitHubAuthSettings.ts new file mode 100644 index 00000000..9a67d4ce --- /dev/null +++ b/src/renderer/features/settings/components/GitHubAuthSettings/useGitHubAuthSettings.ts @@ -0,0 +1,45 @@ +/** + * useGitHubAuthSettings — logic hook for GitHubAuthSettings + */ + +import { useQuery } from '@tanstack/react-query'; + +import { APP } from '@shared/ipc/app/channels'; + +import { useAuthPolling } from '@renderer/shared/hooks/useAuthPolling'; +import { ipc } from '@renderer/shared/lib/ipc'; + +function useGitHubAuth() { + return useQuery({ + queryKey: ['app', 'githubAuth'], + queryFn: () => ipc(APP.CHECK['GITHUB-AUTH'], {}), + staleTime: 30_000, + refetchInterval: 60_000, + }); +} + +export function useGitHubAuthSettings() { + const { data: auth, isLoading, refetch } = useGitHubAuth(); + + const isInstalled = auth?.installed ?? false; + const isAuthenticated = auth?.authenticated ?? false; + + const { authorizing, handleAuthorize: startPolling } = useAuthPolling( + refetch, + isAuthenticated, + ); + + function handleConnect() { + startPolling(); + void ipc(APP.LAUNCH['GITHUB-AUTH'], {}); + } + + return { + auth, + isLoading, + authorizing, + isInstalled, + isAuthenticated, + handleConnect, + }; +} diff --git a/src/renderer/features/settings/components/HotkeySettings.tsx b/src/renderer/features/settings/components/HotkeySettings.tsx deleted file mode 100644 index 1824cff2..00000000 --- a/src/renderer/features/settings/components/HotkeySettings.tsx +++ /dev/null @@ -1,239 +0,0 @@ -/** - * HotkeySettings -- Global hotkey configuration - * - * Loads hotkey bindings from IPC, allows customization via text input - * for Electron accelerator strings, and supports resetting to defaults. - */ - -import { useCallback, useEffect, useState } from 'react'; - -import { Keyboard, RotateCcw } from 'lucide-react'; - - -import { HOTKEYS } from '@shared/ipc/misc/hotkeys.channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; -import { cn } from '@renderer/shared/lib/utils'; - -import { Button, Input } from '@ui'; - -// -- Types -- - -interface HotkeyBinding { - id: string; - label: string; - description: string; - defaultAccelerator: string; -} - -// -- Constants -- - -const DEFAULT_HOTKEYS: HotkeyBinding[] = [ - { - id: 'quickCommand', - label: 'Quick Command', - description: 'Open the quick command popup', - defaultAccelerator: 'Ctrl+Shift+Space', - }, - { - id: 'quickNote', - label: 'Quick Note', - description: 'Open the app for quick note taking', - defaultAccelerator: 'Ctrl+Shift+N', - }, - { - id: 'quickTask', - label: 'Quick Task', - description: 'Open the app for quick task creation', - defaultAccelerator: 'Ctrl+Shift+T', - }, -]; - -const FEEDBACK_DISPLAY_MS = 2000; - -// -- Hotkey Row -- - -interface HotkeyRowProps { - binding: HotkeyBinding; - currentValue: string; - onSave: (id: string, accelerator: string) => void; -} - -function HotkeyRow({ binding, currentValue, onSave }: HotkeyRowProps) { - const [editing, setEditing] = useState(false); - const [inputValue, setInputValue] = useState(currentValue); - - // Sync input value when external data changes (e.g. after reset) - useEffect(() => { - if (!editing) { - setInputValue(currentValue); - } - }, [currentValue, editing]); - - function handleSave() { - const trimmed = inputValue.trim(); - if (trimmed.length > 0) { - onSave(binding.id, trimmed); - } - setEditing(false); - } - - function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) { - if (event.key === 'Enter') { - handleSave(); - } else if (event.key === 'Escape') { - setInputValue(currentValue); - setEditing(false); - } - } - - function handleEditClick() { - setInputValue(currentValue); - setEditing(true); - } - - return ( - <div className="flex items-center justify-between gap-4 py-3"> - <div className="flex-1"> - <p className="text-sm font-medium">{binding.label}</p> - <p className="text-muted-foreground text-xs">{binding.description}</p> - </div> - {editing ? ( - <div className="flex items-center gap-2"> - <Input - aria-label={`Hotkey for ${binding.label}`} - className="w-44" - placeholder="e.g. Ctrl+Shift+Space" - size="sm" - type="text" - value={inputValue} - onChange={(event) => setInputValue(event.target.value)} - onKeyDown={handleKeyDown} - /> - <Button size="sm" variant="link" onClick={handleSave}> - Save - </Button> - </div> - ) : ( - <Button className="group" variant="ghost" onClick={handleEditClick}> - <kbd - className={cn( - 'bg-muted text-muted-foreground rounded-md px-2.5 py-1 font-mono text-xs', - 'group-hover:bg-accent group-hover:text-foreground transition-colors', - )} - > - {currentValue} - </kbd> - </Button> - )} - </div> - ); -} - -// -- Component -- - -export function HotkeySettings() { - const [hotkeys, setHotkeys] = useState<Record<string, string>>(() => { - const defaults: Record<string, string> = {}; - for (const binding of DEFAULT_HOTKEYS) { - defaults[binding.id] = binding.defaultAccelerator; - } - return defaults; - }); - - const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>( - null, - ); - - // Load hotkeys from IPC on mount - useEffect(() => { - void (async () => { - try { - const loaded = await ipc(HOTKEYS.GET.CONFIG, {}); - if (Object.keys(loaded).length > 0) { - setHotkeys((previous) => ({ ...previous, ...loaded })); - } - } catch { - // Fall back to defaults silently - } - })(); - }, []); - - const showFeedback = useCallback((type: 'success' | 'error', message: string) => { - setFeedback({ type, message }); - window.setTimeout(() => { - setFeedback(null); - }, FEEDBACK_DISPLAY_MS); - }, []); - - function handleSaveHotkey(id: string, accelerator: string) { - const updated = { ...hotkeys, [id]: accelerator }; - setHotkeys(updated); - - void (async () => { - try { - await ipc(HOTKEYS.UPDATE.CONFIG, { hotkeys: updated }); - showFeedback('success', 'Hotkey saved'); - } catch { - showFeedback('error', 'Failed to save hotkey'); - } - })(); - } - - function handleReset() { - void (async () => { - try { - const defaults = await ipc(HOTKEYS.RESET.CONFIG, {}); - setHotkeys(defaults); - showFeedback('success', 'Hotkeys reset to defaults'); - } catch { - showFeedback('error', 'Failed to reset hotkeys'); - } - })(); - } - - return ( - <section className="mb-8"> - <div className="mb-3 flex items-center justify-between"> - <h2 className="text-muted-foreground flex items-center gap-2 text-sm font-medium tracking-wider uppercase"> - <Keyboard className="h-4 w-4" /> - Global Hotkeys - </h2> - <Button - aria-label="Reset hotkeys to defaults" - size="sm" - variant="ghost" - onClick={handleReset} - > - <RotateCcw className="h-3.5 w-3.5" /> - Reset to Defaults - </Button> - </div> - <div className="border-border bg-card divide-border divide-y rounded-lg border px-4"> - {DEFAULT_HOTKEYS.map((binding) => ( - <HotkeyRow - key={binding.id} - binding={binding} - currentValue={hotkeys[binding.id] ?? binding.defaultAccelerator} - onSave={handleSaveHotkey} - /> - ))} - </div> - <div className="mt-2 flex items-center justify-between"> - <p className="text-muted-foreground text-xs"> - Use Electron accelerator format: Ctrl+Shift+Key, CmdOrCtrl+Key, Alt+Key - </p> - {feedback ? ( - <p - className={cn( - 'text-xs font-medium', - feedback.type === 'success' ? 'text-success' : 'text-destructive', - )} - > - {feedback.message} - </p> - ) : null} - </div> - </section> - ); -} diff --git a/src/renderer/features/settings/components/HotkeySettings/HotkeySettings.tsx b/src/renderer/features/settings/components/HotkeySettings/HotkeySettings.tsx new file mode 100644 index 00000000..d4410c07 --- /dev/null +++ b/src/renderer/features/settings/components/HotkeySettings/HotkeySettings.tsx @@ -0,0 +1,123 @@ +/** + * HotkeySettings -- Global hotkey configuration + * + * Loads hotkey bindings from IPC, allows customization via text input + * for Electron accelerator strings, and supports resetting to defaults. + */ + +import { Keyboard, RotateCcw } from 'lucide-react'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { Button, Heading, Input } from '@ui'; + +import { DEFAULT_HOTKEYS, useHotkeyRow, useHotkeySettings } from './useHotkeySettings'; + +import type { HotkeyBinding } from './useHotkeySettings'; + +// -- Hotkey Row -- + +interface HotkeyRowProps { + binding: HotkeyBinding; + currentValue: string; + onSave: (id: string, accelerator: string) => void; +} + +function HotkeyRow({ binding, currentValue, onSave }: HotkeyRowProps) { + const { + editing, + inputValue, + setInputValue, + handleSave, + handleKeyDown, + handleEditClick, + } = useHotkeyRow(binding, currentValue, onSave); + + return ( + <div className="flex items-center justify-between gap-4 py-3"> + <div className="flex-1"> + <p className="text-sm font-medium">{binding.label}</p> + <p className="text-muted-foreground text-xs">{binding.description}</p> + </div> + {editing ? ( + <div className="flex items-center gap-2"> + <Input + aria-label={`Hotkey for ${binding.label}`} + className="w-44" + placeholder="e.g. Ctrl+Shift+Space" + size="sm" + type="text" + value={inputValue} + onChange={(event) => setInputValue(event.target.value)} + onKeyDown={handleKeyDown} + /> + <Button size="sm" variant="link" onClick={handleSave}> + Save + </Button> + </div> + ) : ( + <Button className="group" variant="ghost" onClick={handleEditClick}> + <kbd + className={cn( + 'bg-muted text-muted-foreground rounded-md px-2.5 py-1 font-mono text-xs', + 'group-hover:bg-accent group-hover:text-foreground transition-colors', + )} + > + {currentValue} + </kbd> + </Button> + )} + </div> + ); +} + +// -- Component -- + +export function HotkeySettings() { + const { hotkeys, feedback, handleSaveHotkey, handleReset } = useHotkeySettings(); + + return ( + <section className="mb-8"> + <div className="mb-3 flex items-center justify-between"> + <Heading as="h2" className="text-muted-foreground flex items-center gap-2 text-sm font-medium tracking-wider uppercase"> + <Keyboard className="h-4 w-4" /> + Global Hotkeys + </Heading> + <Button + aria-label="Reset hotkeys to defaults" + size="sm" + variant="ghost" + onClick={handleReset} + > + <RotateCcw className="h-3.5 w-3.5" /> + Reset to Defaults + </Button> + </div> + <div className="border-border bg-card divide-border divide-y rounded-lg border px-4"> + {DEFAULT_HOTKEYS.map((binding) => ( + <HotkeyRow + key={binding.id} + binding={binding} + currentValue={hotkeys[binding.id] ?? binding.defaultAccelerator} + onSave={handleSaveHotkey} + /> + ))} + </div> + <div className="mt-2 flex items-center justify-between"> + <p className="text-muted-foreground text-xs"> + Use Electron accelerator format: Ctrl+Shift+Key, CmdOrCtrl+Key, Alt+Key + </p> + {feedback ? ( + <p + className={cn( + 'text-xs font-medium', + feedback.type === 'success' ? 'text-success' : 'text-destructive', + )} + > + {feedback.message} + </p> + ) : null} + </div> + </section> + ); +} diff --git a/src/renderer/features/settings/components/HotkeySettings/index.ts b/src/renderer/features/settings/components/HotkeySettings/index.ts new file mode 100644 index 00000000..9b79bdb8 --- /dev/null +++ b/src/renderer/features/settings/components/HotkeySettings/index.ts @@ -0,0 +1,2 @@ +export { HotkeySettings } from './HotkeySettings'; +export { useHotkeySettings } from './useHotkeySettings'; diff --git a/src/renderer/features/settings/components/HotkeySettings/useHotkeySettings.ts b/src/renderer/features/settings/components/HotkeySettings/useHotkeySettings.ts new file mode 100644 index 00000000..b37cd76f --- /dev/null +++ b/src/renderer/features/settings/components/HotkeySettings/useHotkeySettings.ts @@ -0,0 +1,167 @@ +/** + * useHotkeySettings — logic hook for HotkeySettings + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { HOTKEYS } from '@shared/ipc/hotkeys'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +export interface HotkeyBinding { + id: string; + label: string; + description: string; + defaultAccelerator: string; +} + +export interface HotkeyRowState { + editing: boolean; + inputValue: string; + setInputValue: (value: string) => void; + handleSave: () => void; + handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void; + handleEditClick: () => void; +} + +export const DEFAULT_HOTKEYS: HotkeyBinding[] = [ + { + id: 'quickCommand', + label: 'Quick Command', + description: 'Open the quick command popup', + defaultAccelerator: 'Ctrl+Shift+Space', + }, + { + id: 'quickNote', + label: 'Quick Note', + description: 'Open the app for quick note taking', + defaultAccelerator: 'Ctrl+Shift+N', + }, + { + id: 'quickTask', + label: 'Quick Task', + description: 'Open the app for quick task creation', + defaultAccelerator: 'Ctrl+Shift+T', + }, +]; + +const FEEDBACK_DISPLAY_MS = 2000; + +export function useHotkeySettings() { + const [hotkeys, setHotkeys] = useState<Record<string, string>>(() => { + const defaults: Record<string, string> = {}; + for (const binding of DEFAULT_HOTKEYS) { + defaults[binding.id] = binding.defaultAccelerator; + } + return defaults; + }); + + const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>( + null, + ); + + useEffect(() => { + void (async () => { + try { + const loaded = await ipc(HOTKEYS.GET.CONFIG, {}); + if (Object.keys(loaded).length > 0) { + setHotkeys((previous) => ({ ...previous, ...loaded })); + } + } catch { + // Fall back to defaults silently + } + })(); + }, []); + + const showFeedback = useCallback((type: 'success' | 'error', message: string) => { + setFeedback({ type, message }); + window.setTimeout(() => { + setFeedback(null); + }, FEEDBACK_DISPLAY_MS); + }, []); + + function handleSaveHotkey(id: string, accelerator: string) { + const updated = { ...hotkeys, [id]: accelerator }; + setHotkeys(updated); + + void (async () => { + try { + await ipc(HOTKEYS.UPDATE.CONFIG, { hotkeys: updated }); + showFeedback('success', 'Hotkey saved'); + } catch { + showFeedback('error', 'Failed to save hotkey'); + } + })(); + } + + function handleReset() { + void (async () => { + try { + const defaults = await ipc(HOTKEYS.RESET.CONFIG, {}); + setHotkeys(defaults); + showFeedback('success', 'Hotkeys reset to defaults'); + } catch { + showFeedback('error', 'Failed to reset hotkeys'); + } + })(); + } + + return { + hotkeys, + feedback, + handleSaveHotkey, + handleReset, + }; +} + +export function useHotkeyRow( + binding: HotkeyBinding, + currentValue: string, + onSave: (id: string, accelerator: string) => void, +): HotkeyRowState { + const [editing, setEditing] = useState(false); + const [inputValue, setInputValue] = useState(currentValue); + const prevCurrentValue = useRef(currentValue); + + // Sync input when currentValue changes externally and not editing + useEffect(() => { + if (!editing && currentValue !== prevCurrentValue.current) { + setInputValue(currentValue); + } + prevCurrentValue.current = currentValue; + }, [currentValue, editing]); + + const handleSave = useCallback(() => { + const trimmed = inputValue.trim(); + if (trimmed.length > 0) { + onSave(binding.id, trimmed); + } + setEditing(false); + }, [inputValue, onSave, binding.id]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLInputElement>) => { + if (event.key === 'Enter') { + handleSave(); + } else if (event.key === 'Escape') { + setInputValue(currentValue); + setEditing(false); + } + }, + [handleSave, currentValue], + ); + + const handleEditClick = useCallback(() => { + setInputValue(currentValue); + setEditing(true); + }, [currentValue]); + + return { + editing, + inputValue, + setInputValue, + handleSave, + handleKeyDown, + handleEditClick, + }; +} diff --git a/src/renderer/features/settings/components/HubSettings.tsx b/src/renderer/features/settings/components/HubSettings.tsx deleted file mode 100644 index d0a4cf81..00000000 --- a/src/renderer/features/settings/components/HubSettings.tsx +++ /dev/null @@ -1,288 +0,0 @@ -/** - * HubSettings — Hub connection configuration UI - * - * Allows users to configure the hub server URL and API key, - * connect/disconnect, view connection status, and trigger syncs. - * - * ConnectionForm uses TanStack Form + Zod validation. - */ - -import { useState } from 'react'; - -import { useForm } from '@tanstack/react-form'; -import { Cloud, CloudOff, RefreshCw, Trash2 } from 'lucide-react'; -import { z } from 'zod'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { Button, Form, FormInput, Spinner } from '@ui'; - -import { validateHubUrl } from '@features/hub-setup/lib/validateHubUrl'; - -import { - useHubConnect, - useHubDisconnect, - useHubRemoveConfig, - useHubStatus, - useHubSync, -} from '../api/useHub'; - -// ── Constants ─────────────────────────────────────────────── - -const STATUS_STYLES: Record<string, string> = { - connected: 'bg-success', - disconnected: 'bg-muted-foreground', - connecting: 'bg-warning', - error: 'bg-destructive', -}; - -const STATUS_LABELS: Record<string, string> = { - connected: 'Connected', - disconnected: 'Disconnected', - connecting: 'Connecting...', - error: 'Error', -}; - -const connectionSchema = z.object({ - hubUrl: z.url('Enter a valid URL'), - apiKey: z.string().min(1, 'API key is required'), -}); - -// ── Sub-components ────────────────────────────────────────── - -interface ConnectionFormProps { - isConnecting: boolean; - connectError: boolean; - onConnect: (url: string, apiKey: string) => void; -} - -function ConnectionForm({ isConnecting, connectError, onConnect }: ConnectionFormProps) { - const [isValidating, setIsValidating] = useState(false); - const [validationError, setValidationError] = useState<string | null>(null); - - const form = useForm({ - defaultValues: { - hubUrl: '', - apiKey: '', - }, - validators: { - onChange: connectionSchema, - }, - onSubmit: async ({ value }) => { - setValidationError(null); - setIsValidating(true); - - const validation = await validateHubUrl(value.hubUrl); - setIsValidating(false); - - if (!validation.reachable) { - setValidationError(validation.error ?? 'Hub server is unreachable'); - return; - } - - onConnect(value.hubUrl, value.apiKey); - }, - }); - - const isPending = isConnecting || isValidating; - - function getButtonLabel(): string { - if (isValidating) { - return 'Validating...'; - } - if (isConnecting) { - return 'Connecting...'; - } - return 'Connect'; - } - - function handleFormSubmit(e: React.SyntheticEvent<HTMLFormElement>) { - e.preventDefault(); - void form.handleSubmit(); - } - - return ( - <Form className="space-y-4" onSubmit={handleFormSubmit}> - <form.Field name="hubUrl"> - {(field) => ( - <FormInput - required - field={field} - label="Hub URL" - placeholder="https://192.168.1.100:3443" - type="url" - /> - )} - </form.Field> - - <form.Field name="apiKey"> - {(field) => ( - <FormInput - required - field={field} - label="API Key" - placeholder="Enter your hub API key" - type="password" - /> - )} - </form.Field> - - <form.Subscribe selector={(state) => [state.canSubmit]}> - {([canSubmit]) => ( - <Button - disabled={!canSubmit || isPending} - type="submit" - variant="primary" - > - {isPending ? <Spinner size="sm" /> : <Cloud className="h-4 w-4" />} - {getButtonLabel()} - </Button> - )} - </form.Subscribe> - - {validationError === null ? null : ( - <p className="text-destructive text-sm"> - Hub server unreachable: {validationError}. Please check the URL and try again. - </p> - )} - - {connectError && validationError === null ? ( - <p className="text-destructive text-sm">Failed to connect. Check your URL and API key.</p> - ) : null} - </Form> - ); -} - -interface ConnectedActionsProps { - syncPending: boolean; - onSync: () => void; - onDisconnect: () => void; - onRemoveConfig: () => void; -} - -function ConnectedActions({ - syncPending, - onSync, - onDisconnect, - onRemoveConfig, -}: ConnectedActionsProps) { - return ( - <div className="flex flex-wrap gap-3"> - <Button disabled={syncPending} variant="secondary" onClick={onSync}> - <RefreshCw className={cn('h-4 w-4', syncPending && 'animate-spin')} /> - Sync Now - </Button> - - <Button variant="secondary" onClick={onDisconnect}> - <CloudOff className="h-4 w-4" /> - Disconnect - </Button> - - <Button variant="destructive" onClick={onRemoveConfig}> - <Trash2 className="h-4 w-4" /> - Remove Config - </Button> - </div> - ); -} - -// ── Main component ────────────────────────────────────────── - -export function HubSettings() { - const { data: hubStatus, isLoading } = useHubStatus(); - const connectMutation = useHubConnect(); - const disconnectMutation = useHubDisconnect(); - const syncMutation = useHubSync(); - const removeConfigMutation = useHubRemoveConfig(); - - const statusValue = hubStatus?.status ?? 'disconnected'; - const isConnected = statusValue === 'connected'; - const pendingCount = hubStatus?.pendingMutations ?? 0; - - if (isLoading) { - return ( - <div className="text-muted-foreground flex items-center gap-2 py-4"> - <Spinner className="text-muted-foreground" size="sm" /> - <span>Loading hub status...</span> - </div> - ); - } - - return ( - <div className="space-y-6"> - <div> - <h3 className="text-foreground text-lg font-semibold">Hub Connection</h3> - <p className="text-muted-foreground text-sm"> - Connect to an ADC Hub server for cross-device sync and centralized data. - </p> - </div> - - {/* Connection status */} - <div className="flex items-center gap-3"> - <div - className={cn( - 'h-3 w-3 rounded-full', - STATUS_STYLES[statusValue] ?? 'bg-muted-foreground', - )} - /> - <span className="text-foreground text-sm font-medium"> - {STATUS_LABELS[statusValue] ?? 'Unknown'} - </span> - {isConnected && hubStatus?.hubUrl ? ( - <span className="text-muted-foreground text-xs">({hubStatus.hubUrl})</span> - ) : null} - </div> - - {/* Pending mutations */} - {pendingCount > 0 ? ( - <div className="border-warning bg-warning-light rounded-md border p-3"> - <p className="text-warning-foreground text-sm"> - {String(pendingCount)} pending mutation{pendingCount === 1 ? '' : 's'} waiting to sync. - </p> - </div> - ) : null} - - {/* Last connected */} - {hubStatus?.lastConnected ? ( - <p className="text-muted-foreground text-xs"> - Last connected: {new Date(hubStatus.lastConnected).toLocaleString()} - </p> - ) : null} - - {/* Form or actions */} - {isConnected ? ( - <ConnectedActions - syncPending={syncMutation.isPending} - onDisconnect={() => { - disconnectMutation.mutate(); - }} - onRemoveConfig={() => { - removeConfigMutation.mutate(); - }} - onSync={() => { - syncMutation.mutate(); - }} - /> - ) : ( - <ConnectionForm - connectError={connectMutation.isError} - isConnecting={connectMutation.isPending} - onConnect={(url, apiKey) => { - connectMutation.mutate({ url, apiKey }); - }} - /> - )} - - {/* Sync result */} - {syncMutation.isSuccess ? ( - <p className="text-success text-sm"> - Synced {String(syncMutation.data.syncedCount)} item - {syncMutation.data.syncedCount === 1 ? '' : 's'}. - {syncMutation.data.pendingCount > 0 - ? ` ${String(syncMutation.data.pendingCount)} still pending.` - : ''} - </p> - ) : null} - </div> - ); -} diff --git a/src/renderer/features/settings/components/LayoutSection.tsx b/src/renderer/features/settings/components/LayoutSection.tsx index 3eee74cb..d16fb3b6 100644 --- a/src/renderer/features/settings/components/LayoutSection.tsx +++ b/src/renderer/features/settings/components/LayoutSection.tsx @@ -1,35 +1,34 @@ /** * LayoutSection — Layout settings for Settings > Display * - * 2x2 bordered grid: + * Simplified preset-based layout: * ┌─────────────────┬─────────────────────┐ - * │ Sidebar config │ Content Area config │ - * ├─────────────────┼─────────────────────┤ - * │ Toolbar config │ Unified SVG preview │ - * └─────────────────┴─────────────────────┘ - * - * The SVG shows all three regions (sidebar, toolbar, content) with - * color-coded tints and a gap between sidebar and content/toolbar - * to match the actual app shell. + * │ Layout preset │ Color theme + edit │ + * ├─────────────────┴─────────────────────┤ + * │ Unified SVG preview │ + * │ + color legend │ + * └───────────────────────────────────────┘ */ import { useNavigate } from '@tanstack/react-router'; import { Check, Settings2 } from 'lucide-react'; import { ROUTES } from '@shared/constants'; -import type { ContentLayoutId, SidebarLayoutId, ToolbarStyleId } from '@shared/types/layout'; -import { CONTENT_LAYOUTS, SIDEBAR_LAYOUTS, TOOLBAR_STYLES } from '@shared/types/layout'; +import type { LayoutPreset } from '@shared/types/layout'; +import { LAYOUT_PRESETS, getPresetConfig } from '@shared/types/layout'; import { useLayoutStore, useThemeStore } from '@renderer/shared/stores'; import { Button, + Heading, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, + Separator, Text, Tooltip, TooltipContent, @@ -38,198 +37,91 @@ import { import { useUpdateSettings } from '../api/useSettings'; -// ── SVG Preview Data ─────────────────────────────────────── - -interface LayoutPreviewConfig { - sidebarSide: 'left' | 'right' | 'both'; - sidebarWidth: number; - hasSecondSidebar: boolean; - variant: 'default' | 'floating' | 'inset'; - collapsible: 'default' | 'icon' | 'offcanvas'; - sections: number; -} - -const LAYOUT_PREVIEWS: Record<SidebarLayoutId, LayoutPreviewConfig> = { - 'sidebar-01': { sidebarSide: 'left', sidebarWidth: 60, hasSecondSidebar: false, variant: 'default', collapsible: 'default', sections: 2 }, - 'sidebar-02': { sidebarSide: 'left', sidebarWidth: 60, hasSecondSidebar: false, variant: 'default', collapsible: 'default', sections: 2 }, - 'sidebar-03': { sidebarSide: 'left', sidebarWidth: 60, hasSecondSidebar: false, variant: 'default', collapsible: 'default', sections: 3 }, - 'sidebar-04': { sidebarSide: 'left', sidebarWidth: 55, hasSecondSidebar: false, variant: 'floating', collapsible: 'default', sections: 2 }, - 'sidebar-05': { sidebarSide: 'left', sidebarWidth: 60, hasSecondSidebar: false, variant: 'default', collapsible: 'default', sections: 3 }, - 'sidebar-06': { sidebarSide: 'left', sidebarWidth: 60, hasSecondSidebar: false, variant: 'default', collapsible: 'default', sections: 2 }, - 'sidebar-07': { sidebarSide: 'left', sidebarWidth: 20, hasSecondSidebar: false, variant: 'default', collapsible: 'icon', sections: 2 }, - 'sidebar-08': { sidebarSide: 'left', sidebarWidth: 60, hasSecondSidebar: false, variant: 'inset', collapsible: 'default', sections: 2 }, - 'sidebar-09': { sidebarSide: 'left', sidebarWidth: 80, hasSecondSidebar: false, variant: 'default', collapsible: 'default', sections: 4 }, - 'sidebar-10': { sidebarSide: 'left', sidebarWidth: 20, hasSecondSidebar: false, variant: 'floating', collapsible: 'icon', sections: 2 }, - 'sidebar-11': { sidebarSide: 'left', sidebarWidth: 60, hasSecondSidebar: false, variant: 'default', collapsible: 'default', sections: 3 }, - 'sidebar-12': { sidebarSide: 'left', sidebarWidth: 60, hasSecondSidebar: false, variant: 'default', collapsible: 'default', sections: 2 }, - 'sidebar-13': { sidebarSide: 'left', sidebarWidth: 0, hasSecondSidebar: false, variant: 'default', collapsible: 'offcanvas', sections: 2 }, - 'sidebar-14': { sidebarSide: 'right', sidebarWidth: 60, hasSecondSidebar: false, variant: 'default', collapsible: 'default', sections: 2 }, - 'sidebar-15': { sidebarSide: 'both', sidebarWidth: 50, hasSecondSidebar: true, variant: 'default', collapsible: 'default', sections: 2 }, - 'sidebar-16': { sidebarSide: 'left', sidebarWidth: 60, hasSecondSidebar: false, variant: 'default', collapsible: 'default', sections: 2 }, -}; - // ── SVG tint colors ──────────────────────────────────────── const SIDEBAR_TINT = 'oklch(0.65 0.15 230 / 0.12)'; const TOOLBAR_TINT = 'oklch(0.70 0.10 50 / 0.12)'; const CONTENT_TINT = 'oklch(0.75 0.12 170 / 0.10)'; -// ── SVG Sub-components ──────────────────────────────────── - -interface PreviewDimensions { - w: number; - h: number; - pad: number; - gap: number; - sidebarW: number; - headerH: number; - rightSidebarW: number; - contentX: number; - contentW: number; -} - -function SidebarSections({ config, dims }: { config: LayoutPreviewConfig; dims: PreviewDimensions }) { - const { pad, sidebarW, h } = dims; - return ( - <> - {Array.from({ length: config.sections }).map((_, i) => { - const sectionH = (h - pad * 2 - 24) / config.sections; - const sy = pad + 24 + i * sectionH; - return ( - <g key={`section-${String(i)}`}> - <rect className="fill-muted-foreground/20" height={3} rx={1} width={sidebarW - pad - 16} x={pad + 8} y={sy} /> - {Array.from({ length: 3 }).map((_unused, j) => ( - <rect key={`item-${String(i)}-${String(j)}`} className="fill-muted-foreground/10" height={3} rx={1} width={sidebarW - pad - 20} x={pad + 10} y={sy + 8 + j * 7} /> - ))} - </g> - ); - })} - </> - ); -} - -function ContentDetail({ layoutId, dims }: { layoutId: ContentLayoutId; dims: PreviewDimensions }) { - const { contentX, contentW, headerH, h } = dims; - const INSET_MAP: Record<ContentLayoutId, number> = { flush: 0, padded: 4, bordered: 5, inset: 8 }; - const inset = INSET_MAP[layoutId]; - const innerX = contentX + inset; - const innerY = headerH + inset; - const innerW = contentW - inset * 2; - const innerH = h - headerH - inset * 2; - - return ( - <g> - {layoutId === 'bordered' ? ( - <rect className="stroke-border" fill="none" height={innerH} rx={4} strokeWidth={0.5} width={innerW} x={innerX} y={innerY} /> - ) : null} - {layoutId === 'inset' ? ( - <rect className="fill-muted/30 stroke-border" height={innerH} rx={6} strokeWidth={0.5} width={innerW} x={innerX} y={innerY} /> - ) : null} - {Array.from({ length: 5 }).map((_, i) => ( - <rect - key={`line-${String(i)}`} - className="fill-muted-foreground/10" - height={5} - rx={2} - width={(innerW - 20) * (i === 4 ? 0.5 : 0.9 - i * 0.08)} - x={innerX + 10} - y={innerY + 10 + i * 12} - /> - ))} - </g> - ); -} - -// ── Unified SVG Preview ─────────────────────────────────── +// ── SVG Preview ──────────────────────────────────────────── -function UnifiedLayoutPreview({ - sidebarConfig, - toolbarStyleId, - contentLayoutId, -}: { - sidebarConfig: LayoutPreviewConfig; - toolbarStyleId: ToolbarStyleId; - contentLayoutId: ContentLayoutId; -}) { +function LayoutPreview({ preset }: { preset: LayoutPreset }) { + const config = getPresetConfig(preset); const w = 320; const h = 160; - const pad = sidebarConfig.variant === 'floating' ? 4 : 0; - const gap = sidebarConfig.variant === 'inset' ? 6 : 3; - const sidebarW = sidebarConfig.sidebarWidth; - const rightSidebarW = sidebarConfig.hasSecondSidebar ? 35 : 0; - - // Toolbar height varies by style - const TOOLBAR_H: Record<ToolbarStyleId, number> = { - default: 16, compact: 12, spacious: 22, floating: 14, - bordered: 16, glass: 16, minimal: 10, inset: 16, - }; - const headerH = TOOLBAR_H[toolbarStyleId]; - - // Content starts after sidebar + gap - const contentX = sidebarConfig.sidebarSide === 'right' ? 0 : sidebarW + gap; - const contentW = w - sidebarW - rightSidebarW - gap; - const dims: PreviewDimensions = { w, h, pad, gap, sidebarW, headerH, rightSidebarW, contentX, contentW }; - - const showLeftSidebar = sidebarConfig.sidebarSide === 'left' || sidebarConfig.sidebarSide === 'both'; - const floatingRx = sidebarConfig.variant === 'floating' ? 4 : 0; + const isFloating = preset === 'floating'; + const pad = isFloating ? 4 : 0; + const gap = 3; + const sidebarW = isFloating ? 55 : 20; + const headerH = isFloating ? 14 : 16; + const contentX = sidebarW + gap; + const contentW = w - sidebarW - gap; + const floatingRx = isFloating ? 4 : 0; + + const isBordered = config.content === 'bordered'; + const contentInset = isBordered ? 5 : 0; + const innerX = contentX + contentInset; + const innerY = headerH + contentInset; + const innerW = contentW - contentInset * 2; + const innerH = h - headerH - contentInset * 2; return ( <svg className="h-full w-full" viewBox={`0 0 ${String(w)} ${String(h)}`}> <rect className="fill-muted/20" height={h} rx={6} width={w} x={0} y={0} /> {/* Sidebar tint */} - {showLeftSidebar && sidebarW > 0 ? ( - <rect fill={SIDEBAR_TINT} height={h - pad * 2} rx={floatingRx} width={sidebarW - pad} x={pad} y={pad} /> - ) : null} - {sidebarConfig.sidebarSide === 'right' ? ( - <rect fill={SIDEBAR_TINT} height={h} width={sidebarW} x={w - sidebarW} y={0} /> - ) : null} - {sidebarConfig.hasSecondSidebar ? ( - <rect fill={SIDEBAR_TINT} height={h} opacity={0.6} width={rightSidebarW} x={w - rightSidebarW} y={0} /> - ) : null} + <rect fill={SIDEBAR_TINT} height={h - pad * 2} rx={floatingRx} width={sidebarW - pad} x={pad} y={pad} /> {/* Toolbar tint */} <rect fill={TOOLBAR_TINT} height={headerH} width={contentW} x={contentX} y={0} /> - {/* Toolbar bottom border for 'bordered' style */} - {toolbarStyleId === 'bordered' ? ( - <line className="stroke-border" strokeWidth={1} x1={contentX} x2={contentX + contentW} y1={headerH} y2={headerH} /> - ) : null} {/* Content tint */} <rect fill={CONTENT_TINT} height={h - headerH} width={contentW} x={contentX} y={headerH} /> - {/* Sidebar detail */} - {showLeftSidebar && sidebarW > 0 ? ( - <g> - <rect className="stroke-border" fill="none" height={h - pad * 2} rx={floatingRx} strokeWidth={0.5} width={sidebarW - pad} x={pad} y={pad} /> - {sidebarConfig.collapsible === 'icon' ? null : <SidebarSections config={sidebarConfig} dims={dims} />} - </g> - ) : null} + {/* Sidebar border */} + <rect className="stroke-border" fill="none" height={h - pad * 2} rx={floatingRx} strokeWidth={0.5} width={sidebarW - pad} x={pad} y={pad} /> - {sidebarConfig.collapsible === 'icon' ? ( - <g> + {/* Sidebar detail */} + {isFloating ? ( + <> + <rect className="fill-muted-foreground/20" height={3} rx={1} width={sidebarW - pad - 16} x={pad + 8} y={pad + 24} /> + {Array.from({ length: 3 }).map((_, j) => ( + <rect key={`s1-${String(j)}`} className="fill-muted-foreground/10" height={3} rx={1} width={sidebarW - pad - 20} x={pad + 10} y={pad + 32 + j * 7} /> + ))} + <rect className="fill-muted-foreground/20" height={3} rx={1} width={sidebarW - pad - 16} x={pad + 8} y={pad + 65} /> + {Array.from({ length: 3 }).map((_, j) => ( + <rect key={`s2-${String(j)}`} className="fill-muted-foreground/10" height={3} rx={1} width={sidebarW - pad - 20} x={pad + 10} y={pad + 73 + j * 7} /> + ))} + </> + ) : ( + <> {Array.from({ length: 5 }).map((_, i) => ( <rect key={`icon-${String(i)}`} className="fill-muted-foreground/25" height={5} rx={1} width={5} x={pad + 7} y={pad + 24 + i * 14} /> ))} - </g> - ) : null} + </> + )} - {sidebarConfig.collapsible === 'offcanvas' ? ( - <rect className="fill-muted-foreground/20" height={8} rx={2} width={14} x={6} y={4} /> - ) : null} + {/* Toolbar detail */} + <rect className="fill-muted-foreground/15" height={Math.max(headerH - 8, 4)} rx={2} width={30} x={contentX + 6} y={4} /> + <rect className="fill-muted-foreground/10" height={Math.max(headerH - 8, 4)} rx={2} width={20} x={contentX + contentW - 30} y={4} /> - {sidebarConfig.sidebarSide === 'right' ? ( - <rect className="stroke-border" fill="none" height={h} strokeWidth={0.5} width={sidebarW} x={w - sidebarW} y={0} /> + {/* Content border (bordered layout only) */} + {isBordered ? ( + <rect className="stroke-border" fill="none" height={innerH} rx={4} strokeWidth={0.5} width={innerW} x={innerX} y={innerY} /> ) : null} - {/* Toolbar detail — small indicator blocks */} - <g> - <rect className="fill-muted-foreground/15" height={Math.max(headerH - 8, 4)} rx={2} width={30} x={contentX + 6} y={4} /> - <rect className="fill-muted-foreground/10" height={Math.max(headerH - 8, 4)} rx={2} width={20} x={contentX + contentW - 30} y={4} /> - </g> - - {/* Content area detail */} - <ContentDetail dims={dims} layoutId={contentLayoutId} /> + {/* Content lines */} + {Array.from({ length: 5 }).map((_, i) => ( + <rect + key={`line-${String(i)}`} + className="fill-muted-foreground/10" + height={5} + rx={2} + width={(innerW - 20) * (i === 4 ? 0.5 : 0.9 - i * 0.08)} + x={innerX + 10} + y={innerY + 10 + i * 12} + /> + ))} <rect className="stroke-border" fill="none" height={h} rx={6} strokeWidth={0.5} width={w} x={0} y={0} /> </svg> @@ -239,32 +131,15 @@ function UnifiedLayoutPreview({ // ── Main Section ─────────────────────────────────────────── export function LayoutSection() { - const { - sidebarLayout, setSidebarLayout, - toolbarStyle, setToolbarStyle, - contentLayout, setContentLayout, - } = useLayoutStore(); + const { layoutPreset, setLayoutPreset } = useLayoutStore(); const { colorTheme, setColorTheme, customThemes } = useThemeStore(); const updateSettings = useUpdateSettings(); const navigate = useNavigate(); - const selectedSidebar = SIDEBAR_LAYOUTS.find((l) => l.id === sidebarLayout); - const selectedToolbar = TOOLBAR_STYLES.find((l) => l.id === toolbarStyle); - const selectedContent = CONTENT_LAYOUTS.find((l) => l.id === contentLayout); - const previewConfig = LAYOUT_PREVIEWS[sidebarLayout]; - - function handleSidebarChange(value: string) { - const layoutId = value as SidebarLayoutId; - setSidebarLayout(layoutId); - updateSettings.mutate({ sidebarLayout: layoutId }); - } - - function handleToolbarChange(value: string) { - setToolbarStyle(value as ToolbarStyleId); - } - - function handleContentChange(value: string) { - setContentLayout(value as ContentLayoutId); + function handlePresetChange(value: string) { + const preset = value as LayoutPreset; + setLayoutPreset(preset); + updateSettings.mutate({ layoutPreset: preset }); } function handleThemeChange(value: string) { @@ -276,129 +151,89 @@ export function LayoutSection() { void navigate({ to: ROUTES.THEMES as '/' }); } + const selectedPreset = LAYOUT_PRESETS.find((p) => p.id === layoutPreset); + return ( <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> Layout - </h2> - - <div className="border-border grid grid-cols-2 grid-rows-2 overflow-hidden rounded-lg border"> - {/* ── Top-left: Sidebar ──────────────────── */} - <div className="border-border space-y-3 border-r border-b p-4"> - <Text className="text-foreground text-sm font-medium">Sidebar</Text> - <div className="flex flex-col gap-3"> - <Label htmlFor="sidebar-layout">Style</Label> - <Select value={sidebarLayout} onValueChange={handleSidebarChange}> - <SelectTrigger id="sidebar-layout"> - <SelectValue placeholder="Select a layout" /> - </SelectTrigger> - <SelectContent> - {SIDEBAR_LAYOUTS.map((layout) => ( - <SelectItem key={layout.id} value={layout.id}> - {layout.label} - </SelectItem> - ))} - </SelectContent> - </Select> - {selectedSidebar ? ( - <Text className="text-xs" variant="muted">{selectedSidebar.description}</Text> - ) : null} - </div> - </div> - - {/* ── Top-right: Content Area ────────────── */} - <div className="border-border space-y-3 border-b p-4"> - <Text className="text-foreground text-sm font-medium">Main Content Area</Text> - <div className="flex flex-col gap-3"> - <Label htmlFor="content-layout">Style</Label> - <Select value={contentLayout} onValueChange={handleContentChange}> - <SelectTrigger id="content-layout"> - <SelectValue placeholder="Select a style" /> - </SelectTrigger> - <SelectContent> - {CONTENT_LAYOUTS.map((layout) => ( - <SelectItem key={layout.id} value={layout.id}> - {layout.label} - </SelectItem> - ))} - </SelectContent> - </Select> - {selectedContent ? ( - <Text className="text-xs" variant="muted">{selectedContent.description}</Text> - ) : null} - </div> - </div> - - {/* ── Bottom-left: Top Toolbar ────────────── */} - <div className="border-border space-y-3 border-r p-4"> - <Text className="text-foreground text-sm font-medium">Top Toolbar</Text> - <div className="flex flex-col gap-3"> - <Label htmlFor="toolbar-style">Style</Label> - <Select value={toolbarStyle} onValueChange={handleToolbarChange}> - <SelectTrigger id="toolbar-style"> - <SelectValue placeholder="Select a style" /> - </SelectTrigger> - <SelectContent> - {TOOLBAR_STYLES.map((style) => ( - <SelectItem key={style.id} value={style.id}> - {style.label} - </SelectItem> - ))} - </SelectContent> - </Select> - {selectedToolbar ? ( - <Text className="text-xs" variant="muted">{selectedToolbar.description}</Text> - ) : null} - </div> - </div> - - {/* ── Bottom-right: Theme + Preview ────────── */} - <div className="bg-card/50 flex flex-col gap-3 p-4"> - {/* Theme selector row */} - <div className="flex items-end gap-2"> - <div className="min-w-0 flex-1 flex flex-col gap-3"> - <Label htmlFor="color-theme">Color Theme</Label> - <Select value={colorTheme} onValueChange={handleThemeChange}> - <SelectTrigger id="color-theme"> - <SelectValue placeholder="Select theme" /> + </Heading> + + <div className="border-border overflow-hidden rounded-lg border"> + {/* Top row: preset + theme */} + <div className="grid grid-cols-2"> + {/* Layout Preset */} + <div className="border-border space-y-3 border-r p-4"> + <Text className="text-foreground text-sm font-medium">Layout</Text> + <div className="flex flex-col gap-3"> + <Label htmlFor="layout-preset">Preset</Label> + <Select value={layoutPreset} onValueChange={handlePresetChange}> + <SelectTrigger id="layout-preset"> + <SelectValue placeholder="Select layout" /> </SelectTrigger> <SelectContent> - <SelectItem value="default"> - <span className="flex items-center gap-2"> - Default - {colorTheme === 'default' ? <Check className="text-success h-3 w-3" /> : null} - </span> - </SelectItem> - {customThemes.map((t) => ( - <SelectItem key={t.id} value={t.id}> - <span className="flex items-center gap-2"> - {t.name} - {colorTheme === t.id ? <Check className="text-success h-3 w-3" /> : null} - </span> + {LAYOUT_PRESETS.map((preset) => ( + <SelectItem key={preset.id} value={preset.id}> + {preset.label} </SelectItem> ))} </SelectContent> </Select> + {selectedPreset ? ( + <Text className="text-xs" variant="muted">{selectedPreset.description}</Text> + ) : null} </div> - <Tooltip> - <TooltipTrigger asChild> - <Button size="icon" variant="outline" onClick={handleCustomizeTheme}> - <Settings2 className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Edit themes</TooltipContent> - </Tooltip> </div> - {/* SVG preview */} + {/* Color Theme */} + <div className="space-y-3 p-4"> + <Text className="text-foreground text-sm font-medium">Theme</Text> + <div className="flex flex-col gap-3"> + <Label htmlFor="color-theme">Color Theme</Label> + <div className="flex items-end gap-2"> + <div className="min-w-0 flex-1"> + <Select value={colorTheme} onValueChange={handleThemeChange}> + <SelectTrigger id="color-theme"> + <SelectValue placeholder="Select theme" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="default"> + <span className="flex items-center gap-2"> + Default + {colorTheme === 'default' ? <Check className="text-success h-3 w-3" /> : null} + </span> + </SelectItem> + {customThemes.map((t) => ( + <SelectItem key={t.id} value={t.id}> + <span className="flex items-center gap-2"> + {t.name} + {colorTheme === t.id ? <Check className="text-success h-3 w-3" /> : null} + </span> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <Tooltip> + <TooltipTrigger asChild> + <Button size="icon" variant="outline" onClick={handleCustomizeTheme}> + <Settings2 className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Edit themes</TooltipContent> + </Tooltip> + </div> + </div> + </div> + </div> + + {/* Bottom: SVG preview */} + <Separator /> + <div className="p-4"> <div className="h-[90px] w-full"> - <UnifiedLayoutPreview - contentLayoutId={contentLayout} - sidebarConfig={previewConfig} - toolbarStyleId={toolbarStyle} - /> + <LayoutPreview preset={layoutPreset} /> </div> - <div className="flex justify-center gap-4"> + <div className="mt-2 flex justify-center gap-4"> <div className="flex items-center gap-1"> <span className="h-2 w-2 rounded-sm" style={{ backgroundColor: SIDEBAR_TINT }} /> <Text className="text-[9px]" variant="muted">Sidebar</Text> diff --git a/src/renderer/features/settings/components/OAuthConnectionStatus.tsx b/src/renderer/features/settings/components/OAuthConnectionStatus.tsx index f6fd58d4..7c710b0b 100644 --- a/src/renderer/features/settings/components/OAuthConnectionStatus.tsx +++ b/src/renderer/features/settings/components/OAuthConnectionStatus.tsx @@ -7,7 +7,7 @@ import { LogIn, LogOut } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; -import { Button, Spinner } from '@ui'; +import { Button, Separator, Spinner } from '@ui'; import { useOAuthAuthorize, useOAuthRevoke, useOAuthStatus } from '../api/useOAuth'; @@ -61,8 +61,10 @@ export function OAuthConnectionStatus({ provider, hasCredentials }: OAuthConnect } return ( - <div className="border-border flex items-center justify-between border-t pt-3"> - {/* Status indicator */} + <> + <Separator /> + <div className="flex items-center justify-between pt-3"> + {/* Status indicator */} <div className="flex items-center gap-2"> <StatusIndicator isAuthenticated={isAuthenticated} isLoading={isStatusLoading} /> </div> @@ -102,6 +104,7 @@ export function OAuthConnectionStatus({ provider, hasCredentials }: OAuthConnect Connect </Button> )} - </div> + </div> + </> ); } diff --git a/src/renderer/features/settings/components/OAuthProviderSettings.tsx b/src/renderer/features/settings/components/OAuthProviderSettings/OAuthProviderSettings.tsx similarity index 75% rename from src/renderer/features/settings/components/OAuthProviderSettings.tsx rename to src/renderer/features/settings/components/OAuthProviderSettings/OAuthProviderSettings.tsx index b1099ac6..5e4ca47b 100644 --- a/src/renderer/features/settings/components/OAuthProviderSettings.tsx +++ b/src/renderer/features/settings/components/OAuthProviderSettings/OAuthProviderSettings.tsx @@ -5,23 +5,27 @@ * Expandable form per provider to enter clientId + clientSecret. */ -import { useState } from 'react'; - import { Check, ChevronDown } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; -import { Button, Spinner } from '@ui'; +import { Button, Separator, Spinner } from '@ui'; +import { OAuthConnectionStatus } from '../OAuthConnectionStatus'; +import { OAuthProviderForm } from '../OAuthProviderForm'; -import { PROVIDER_CONFIG, useOAuthProviders, useSaveOAuthProvider } from './oauth-provider-constants'; -import { OAuthConnectionStatus } from './OAuthConnectionStatus'; -import { OAuthProviderForm } from './OAuthProviderForm'; +import { useOAuthProviderSettings } from './useOAuthProviderSettings'; export function OAuthProviderSettings() { - const { data: providers, isLoading } = useOAuthProviders(); - const saveMutation = useSaveOAuthProvider(); - const [expandedProvider, setExpandedProvider] = useState<string | null>(null); + const { + providers, + isLoading, + saveMutation, + expandedProvider, + setExpandedProvider, + PROVIDER_CONFIG, + handleSave, + } = useOAuthProviderSettings(); if (isLoading) { return ( @@ -78,26 +82,22 @@ export function OAuthProviderSettings() { </Button> {isExpanded ? ( - <div className="border-border border-t px-4 pb-4"> - <OAuthProviderForm + <> + <Separator /> + <div className="px-4 pb-4"> + <OAuthProviderForm isPending={saveMutation.isPending} name={provider.name} onSave={(clientId, clientSecret) => { - saveMutation.mutate( - { name: provider.name, clientId, clientSecret }, - { - onSuccess() { - setExpandedProvider(null); - }, - }, - ); + handleSave(provider.name, clientId, clientSecret); }} /> <OAuthConnectionStatus hasCredentials={provider.hasCredentials} provider={provider.name} /> - </div> + </div> + </> ) : null} </div> ); diff --git a/src/renderer/features/settings/components/OAuthProviderSettings/index.ts b/src/renderer/features/settings/components/OAuthProviderSettings/index.ts new file mode 100644 index 00000000..c75afc76 --- /dev/null +++ b/src/renderer/features/settings/components/OAuthProviderSettings/index.ts @@ -0,0 +1,2 @@ +export { OAuthProviderSettings } from './OAuthProviderSettings'; +export { useOAuthProviderSettings } from './useOAuthProviderSettings'; diff --git a/src/renderer/features/settings/components/OAuthProviderSettings/useOAuthProviderSettings.ts b/src/renderer/features/settings/components/OAuthProviderSettings/useOAuthProviderSettings.ts new file mode 100644 index 00000000..bc7164a0 --- /dev/null +++ b/src/renderer/features/settings/components/OAuthProviderSettings/useOAuthProviderSettings.ts @@ -0,0 +1,34 @@ +/** + * useOAuthProviderSettings — logic hook for OAuthProviderSettings + */ + +import { useState } from 'react'; + +import { PROVIDER_CONFIG, useOAuthProviders, useSaveOAuthProvider } from '../oauth-provider-constants'; + +export function useOAuthProviderSettings() { + const { data: providers, isLoading } = useOAuthProviders(); + const saveMutation = useSaveOAuthProvider(); + const [expandedProvider, setExpandedProvider] = useState<string | null>(null); + + function handleSave(name: string, clientId: string, clientSecret: string) { + saveMutation.mutate( + { name, clientId, clientSecret }, + { + onSuccess() { + setExpandedProvider(null); + }, + }, + ); + } + + return { + providers, + isLoading, + saveMutation, + expandedProvider, + setExpandedProvider, + PROVIDER_CONFIG, + handleSave, + }; +} diff --git a/src/renderer/features/settings/components/ProfileFormModal.tsx b/src/renderer/features/settings/components/ProfileFormModal.tsx deleted file mode 100644 index 65810d08..00000000 --- a/src/renderer/features/settings/components/ProfileFormModal.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/** - * ProfileFormModal — Create or edit a profile - * - * Uses TanStack Form + Zod validation + design system FormInput/FormSelect primitives. - */ - -import { useEffect, useState } from 'react'; - -import { useForm } from '@tanstack/react-form'; -import { Eye, EyeOff, X } from 'lucide-react'; -import { z } from 'zod'; - -import { CLAUDE_MODELS } from '@shared/constants'; -import type { Profile } from '@shared/types'; - -import { - Button, - Dialog, - DialogClose, - DialogContent, - DialogHeader, - DialogOverlay, - DialogPortal, - DialogTitle, - Form, - FormField, - FormInput, - FormSelect, - Input, -} from '@ui'; - -const profileSchema = z.object({ - name: z.string().min(1, 'Name is required'), - apiKey: z.string(), - model: z.string(), -}); - -const MODEL_OPTIONS = [ - { label: 'No model selected', value: '__none__' }, - ...CLAUDE_MODELS.map((m) => ({ label: m.label, value: m.id })), -]; - -interface ProfileFormModalProps { - open: boolean; - profile: Profile | null; - onClose: () => void; - onSave: (data: { name: string; apiKey?: string; model?: string }) => void; -} - -export function ProfileFormModal({ open, profile, onClose, onSave }: ProfileFormModalProps) { - const [showApiKey, setShowApiKey] = useState(false); - - const isEditing = profile !== null; - - const form = useForm({ - defaultValues: { - name: '', - apiKey: '', - model: '', - }, - validators: { - onChange: profileSchema, - }, - onSubmit: ({ value }) => { - const trimmedName = value.name.trim(); - if (trimmedName.length === 0) { - return; - } - onSave({ - name: trimmedName, - apiKey: value.apiKey.length > 0 ? value.apiKey : undefined, - model: value.model.length > 0 && value.model !== '__none__' ? value.model : undefined, - }); - }, - }); - - useEffect(() => { - if (open) { - form.reset(); - form.setFieldValue('name', profile?.name ?? ''); - form.setFieldValue('apiKey', profile?.apiKey ?? ''); - form.setFieldValue('model', profile?.model ?? ''); - setShowApiKey(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- reset form when dialog opens - }, [open, profile]); - - function handleFormSubmit(e: React.SyntheticEvent<HTMLFormElement>) { - e.preventDefault(); - void form.handleSubmit(); - } - - return ( - <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> - <DialogPortal> - <DialogOverlay /> - <DialogContent className="w-full max-w-md"> - {/* Header */} - <DialogHeader> - <DialogTitle> - {isEditing ? 'Edit Profile' : 'New Profile'} - </DialogTitle> - <DialogClose asChild> - <Button aria-label="Close" size="icon" variant="ghost"> - <X className="h-4 w-4" /> - </Button> - </DialogClose> - </DialogHeader> - - {/* Form */} - <Form className="space-y-4 p-4" onSubmit={handleFormSubmit}> - {/* Name */} - <form.Field name="name"> - {(field) => ( - <FormInput - required - field={field} - label="Name" - placeholder="My Profile" - type="text" - /> - )} - </form.Field> - - {/* API Key — custom render for visibility toggle */} - <form.Field name="apiKey"> - {(field) => ( - <FormField label="API Key"> - <div className="relative"> - <Input - className="pr-10" - name="apiKey" - placeholder="sk-ant-..." - type={showApiKey ? 'text' : 'password'} - value={field.state.value} - onBlur={field.handleBlur} - onChange={(e: React.ChangeEvent<HTMLInputElement>) => { - field.handleChange(e.target.value); - }} - /> - <Button - aria-label={showApiKey ? 'Hide API key' : 'Show API key'} - className="absolute top-1/2 right-2 -translate-y-1/2" - size="icon" - variant="ghost" - onClick={() => setShowApiKey((previous) => !previous)} - > - {showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} - </Button> - </div> - </FormField> - )} - </form.Field> - - {/* Model */} - <form.Field name="model"> - {(field) => ( - <FormSelect - field={field} - label="Model" - options={MODEL_OPTIONS} - placeholder="No model selected" - /> - )} - </form.Field> - - {/* Actions */} - <form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}> - {([canSubmit]) => ( - <div className="flex justify-end gap-2 pt-2"> - <Button type="button" variant="outline" onClick={onClose}> - Cancel - </Button> - <Button - disabled={!canSubmit} - type="submit" - variant="primary" - > - {isEditing ? 'Save Changes' : 'Create Profile'} - </Button> - </div> - )} - </form.Subscribe> - </Form> - </DialogContent> - </DialogPortal> - </Dialog> - ); -} diff --git a/src/renderer/features/settings/components/ProfileFormModal/ProfileFormModal.tsx b/src/renderer/features/settings/components/ProfileFormModal/ProfileFormModal.tsx new file mode 100644 index 00000000..a111db82 --- /dev/null +++ b/src/renderer/features/settings/components/ProfileFormModal/ProfileFormModal.tsx @@ -0,0 +1,141 @@ +/** + * ProfileFormModal — Create or edit a profile + * + * Uses TanStack Form + Zod validation + design system FormInput/FormSelect primitives. + */ + +import { Eye, EyeOff, X } from 'lucide-react'; + +import type { Profile } from '@shared/types'; + +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + Form, + FormField, + FormInput, + FormSelect, + Input, +} from '@ui'; + +import { MODEL_OPTIONS, useProfileFormModal } from './useProfileFormModal'; + +interface ProfileFormModalProps { + open: boolean; + profile: Profile | null; + onClose: () => void; + onSave: (data: { name: string; apiKey?: string; model?: string }) => void; +} + +export function ProfileFormModal({ open, profile, onClose, onSave }: ProfileFormModalProps) { + const { + form, + showApiKey, + setShowApiKey, + isEditing, + handleFormSubmit, + } = useProfileFormModal({ open, profile, onClose, onSave }); + + return ( + <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> + <DialogPortal> + <DialogOverlay /> + <DialogContent className="w-full max-w-md"> + {/* Header */} + <DialogHeader> + <DialogTitle> + {isEditing ? 'Edit Profile' : 'New Profile'} + </DialogTitle> + <DialogClose asChild> + <Button aria-label="Close" size="icon" variant="ghost"> + <X className="h-4 w-4" /> + </Button> + </DialogClose> + </DialogHeader> + + {/* Form */} + <Form className="space-y-4 p-4" onSubmit={handleFormSubmit}> + {/* Name */} + <form.Field name="name"> + {(field) => ( + <FormInput + required + field={field} + label="Name" + placeholder="My Profile" + type="text" + /> + )} + </form.Field> + + {/* API Key — custom render for visibility toggle */} + <form.Field name="apiKey"> + {(field) => ( + <FormField label="API Key"> + <div className="relative"> + <Input + className="pr-10" + name="apiKey" + placeholder="sk-ant-..." + type={showApiKey ? 'text' : 'password'} + value={field.state.value} + onBlur={field.handleBlur} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => { + field.handleChange(e.target.value); + }} + /> + <Button + aria-label={showApiKey ? 'Hide API key' : 'Show API key'} + className="absolute top-1/2 right-2 -translate-y-1/2" + size="icon" + variant="ghost" + onClick={() => setShowApiKey((previous) => !previous)} + > + {showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} + </Button> + </div> + </FormField> + )} + </form.Field> + + {/* Model */} + <form.Field name="model"> + {(field) => ( + <FormSelect + field={field} + label="Model" + options={MODEL_OPTIONS} + placeholder="No model selected" + /> + )} + </form.Field> + + {/* Actions */} + <form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}> + {([canSubmit]) => ( + <div className="flex justify-end gap-2 pt-2"> + <Button type="button" variant="outline" onClick={onClose}> + Cancel + </Button> + <Button + disabled={!canSubmit} + type="submit" + variant="primary" + > + {isEditing ? 'Save Changes' : 'Create Profile'} + </Button> + </div> + )} + </form.Subscribe> + </Form> + </DialogContent> + </DialogPortal> + </Dialog> + ); +} diff --git a/src/renderer/features/settings/components/ProfileFormModal/index.ts b/src/renderer/features/settings/components/ProfileFormModal/index.ts new file mode 100644 index 00000000..b9f7c2cd --- /dev/null +++ b/src/renderer/features/settings/components/ProfileFormModal/index.ts @@ -0,0 +1,2 @@ +export { ProfileFormModal } from './ProfileFormModal'; +export { useProfileFormModal } from './useProfileFormModal'; diff --git a/src/renderer/features/settings/components/ProfileFormModal/useProfileFormModal.ts b/src/renderer/features/settings/components/ProfileFormModal/useProfileFormModal.ts new file mode 100644 index 00000000..838de764 --- /dev/null +++ b/src/renderer/features/settings/components/ProfileFormModal/useProfileFormModal.ts @@ -0,0 +1,81 @@ +/** + * useProfileFormModal — logic hook for ProfileFormModal + */ + +import { useEffect, useState } from 'react'; + +import { useForm } from '@tanstack/react-form'; +import { z } from 'zod'; + +import { CLAUDE_MODELS } from '@shared/constants'; +import type { Profile } from '@shared/types'; + +const profileSchema = z.object({ + name: z.string().min(1, 'Name is required'), + apiKey: z.string(), + model: z.string(), +}); + +export const MODEL_OPTIONS = [ + { label: 'No model selected', value: '__none__' }, + ...CLAUDE_MODELS.map((m) => ({ label: m.label, value: m.id })), +]; + +interface UseProfileFormModalProps { + open: boolean; + profile: Profile | null; + onClose: () => void; + onSave: (data: { name: string; apiKey?: string; model?: string }) => void; +} + +export function useProfileFormModal({ open, profile, onClose, onSave }: UseProfileFormModalProps) { + const [showApiKey, setShowApiKey] = useState(false); + const isEditing = profile !== null; + + const form = useForm({ + defaultValues: { + name: '', + apiKey: '', + model: '', + }, + validators: { + onChange: profileSchema, + }, + onSubmit: ({ value }) => { + const trimmedName = value.name.trim(); + if (trimmedName.length === 0) { + return; + } + onSave({ + name: trimmedName, + apiKey: value.apiKey.length > 0 ? value.apiKey : undefined, + model: value.model.length > 0 && value.model !== '__none__' ? value.model : undefined, + }); + }, + }); + + useEffect(() => { + if (open) { + form.reset(); + form.setFieldValue('name', profile?.name ?? ''); + form.setFieldValue('apiKey', profile?.apiKey ?? ''); + form.setFieldValue('model', profile?.model ?? ''); + setShowApiKey(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset form when dialog opens + }, [open, profile]); + + function handleFormSubmit(e: React.SyntheticEvent<HTMLFormElement>) { + e.preventDefault(); + void form.handleSubmit(); + } + + return { + form, + showApiKey, + setShowApiKey, + isEditing, + handleFormSubmit, + onClose, + }; +} diff --git a/src/renderer/features/settings/components/ProfileSection.tsx b/src/renderer/features/settings/components/ProfileSection.tsx deleted file mode 100644 index 672ec5a4..00000000 --- a/src/renderer/features/settings/components/ProfileSection.tsx +++ /dev/null @@ -1,173 +0,0 @@ -/** - * ProfileSection — Profile list with add/edit/delete actions - */ - -import { useState } from 'react'; - -import { Plus } from 'lucide-react'; - -import type { Profile } from '@shared/types'; - -import { Button, Spinner } from '@ui'; - -import { - useProfiles, - useCreateProfile, - useUpdateProfile, - useDeleteProfile, - useSetDefaultProfile, -} from '../api/useSettings'; - -import { ProfileCard } from './ProfileCard'; -import { ProfileFormModal } from './ProfileFormModal'; - -export function ProfileSection() { - const { data: profiles, isLoading } = useProfiles(); - const createProfile = useCreateProfile(); - const updateProfile = useUpdateProfile(); - const deleteProfile = useDeleteProfile(); - const setDefaultProfile = useSetDefaultProfile(); - - const [modalOpen, setModalOpen] = useState(false); - const [editingProfile, setEditingProfile] = useState<Profile | null>(null); - const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); - const [errorMessage, setErrorMessage] = useState<string | null>(null); - - function handleAdd() { - setEditingProfile(null); - setModalOpen(true); - } - - function handleEdit(profile: Profile) { - setEditingProfile(profile); - setModalOpen(true); - } - - function extractErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return 'An unexpected error occurred'; - } - - function handleDeleteRequest(id: string) { - setErrorMessage(null); - setDeleteConfirmId(id); - } - - function handleDeleteConfirm() { - if (deleteConfirmId !== null) { - deleteProfile.mutate(deleteConfirmId, { - onError: (error) => setErrorMessage(extractErrorMessage(error)), - }); - setDeleteConfirmId(null); - } - } - - function handleDeleteCancel() { - setDeleteConfirmId(null); - } - - function handleSetDefault(id: string) { - setDefaultProfile.mutate(id); - } - - function handleSave(data: { name: string; apiKey?: string; model?: string }) { - setErrorMessage(null); - const errorHandler = { - onError: (error: unknown) => setErrorMessage(extractErrorMessage(error)), - }; - if (editingProfile === null) { - createProfile.mutate(data, errorHandler); - } else { - updateProfile.mutate({ id: editingProfile.id, updates: data }, errorHandler); - } - setModalOpen(false); - setEditingProfile(null); - } - - function handleCloseModal() { - setModalOpen(false); - setEditingProfile(null); - } - - if (isLoading) { - return ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground" size="sm" /> - </div> - ); - } - - const deleteTarget = - deleteConfirmId === null ? undefined : profiles?.find((p) => p.id === deleteConfirmId); - - return ( - <section className="mb-8"> - <div className="mb-3 flex items-center justify-between"> - <h2 className="text-muted-foreground text-sm font-medium tracking-wider uppercase"> - Profiles - </h2> - <Button size="sm" variant="ghost" onClick={handleAdd}> - <Plus className="h-3.5 w-3.5" /> - Add Profile - </Button> - </div> - - <div className="space-y-2"> - {profiles?.map((profile) => ( - <ProfileCard - key={profile.id} - profile={profile} - onDelete={handleDeleteRequest} - onEdit={handleEdit} - onSetDefault={handleSetDefault} - /> - ))} - {profiles?.length === 0 ? ( - <p className="text-muted-foreground py-4 text-center text-sm"> - No profiles configured. Add one to get started. - </p> - ) : null} - </div> - - {/* Error message */} - {errorMessage === null ? null : ( - <div className="border-destructive/50 bg-destructive/5 mt-3 flex items-center justify-between rounded-lg border p-3"> - <p className="text-destructive text-sm">{errorMessage}</p> - <Button - size="sm" - variant="ghost" - onClick={() => setErrorMessage(null)} - > - Dismiss - </Button> - </div> - )} - - {/* Delete confirmation */} - {deleteTarget ? ( - <div className="border-destructive/50 bg-destructive/5 mt-3 flex items-center justify-between rounded-lg border p-3"> - <p className="text-sm"> - Delete profile <strong>{deleteTarget.name}</strong>? - </p> - <div className="flex gap-2"> - <Button size="sm" variant="ghost" onClick={handleDeleteCancel}> - Cancel - </Button> - <Button size="sm" variant="destructive" onClick={handleDeleteConfirm}> - Delete - </Button> - </div> - </div> - ) : null} - - <ProfileFormModal - open={modalOpen} - profile={editingProfile} - onClose={handleCloseModal} - onSave={handleSave} - /> - </section> - ); -} diff --git a/src/renderer/features/settings/components/ProfileSection/ProfileSection.tsx b/src/renderer/features/settings/components/ProfileSection/ProfileSection.tsx new file mode 100644 index 00000000..01758e9e --- /dev/null +++ b/src/renderer/features/settings/components/ProfileSection/ProfileSection.tsx @@ -0,0 +1,112 @@ +/** + * ProfileSection — Profile list with add/edit/delete actions + */ + +import { Plus } from 'lucide-react'; + +import { Button, Heading, Spinner } from '@ui'; + +import { ProfileCard } from '../ProfileCard'; +import { ProfileFormModal } from '../ProfileFormModal'; + +import { useProfileSection } from './useProfileSection'; + +export function ProfileSection() { + const { + profiles, + isLoading, + modalOpen, + editingProfile, + deleteConfirmId, + errorMessage, + setErrorMessage, + handleAdd, + handleEdit, + handleDeleteRequest, + handleDeleteConfirm, + handleDeleteCancel, + handleSetDefault, + handleSave, + handleCloseModal, + } = useProfileSection(); + + if (isLoading) { + return ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground" size="sm" /> + </div> + ); + } + + const deleteTarget = + deleteConfirmId === null ? undefined : profiles?.find((p) => p.id === deleteConfirmId); + + return ( + <section className="mb-8"> + <div className="mb-3 flex items-center justify-between"> + <Heading as="h2" className="text-muted-foreground text-sm font-medium tracking-wider uppercase"> + Profiles + </Heading> + <Button size="sm" variant="ghost" onClick={handleAdd}> + <Plus className="h-3.5 w-3.5" /> + Add Profile + </Button> + </div> + + <div className="space-y-2"> + {profiles?.map((profile) => ( + <ProfileCard + key={profile.id} + profile={profile} + onDelete={handleDeleteRequest} + onEdit={handleEdit} + onSetDefault={handleSetDefault} + /> + ))} + {profiles?.length === 0 ? ( + <p className="text-muted-foreground py-4 text-center text-sm"> + No profiles configured. Add one to get started. + </p> + ) : null} + </div> + + {/* Error message */} + {errorMessage === null ? null : ( + <div className="border-destructive/50 bg-destructive/5 mt-3 flex items-center justify-between rounded-lg border p-3"> + <p className="text-destructive text-sm">{errorMessage}</p> + <Button + size="sm" + variant="ghost" + onClick={() => setErrorMessage(null)} + > + Dismiss + </Button> + </div> + )} + + {/* Delete confirmation */} + {deleteTarget ? ( + <div className="border-destructive/50 bg-destructive/5 mt-3 flex items-center justify-between rounded-lg border p-3"> + <p className="text-sm"> + Delete profile <strong>{deleteTarget.name}</strong>? + </p> + <div className="flex gap-2"> + <Button size="sm" variant="ghost" onClick={handleDeleteCancel}> + Cancel + </Button> + <Button size="sm" variant="destructive" onClick={handleDeleteConfirm}> + Delete + </Button> + </div> + </div> + ) : null} + + <ProfileFormModal + open={modalOpen} + profile={editingProfile} + onClose={handleCloseModal} + onSave={handleSave} + /> + </section> + ); +} diff --git a/src/renderer/features/settings/components/ProfileSection/index.ts b/src/renderer/features/settings/components/ProfileSection/index.ts new file mode 100644 index 00000000..4547ecaf --- /dev/null +++ b/src/renderer/features/settings/components/ProfileSection/index.ts @@ -0,0 +1,2 @@ +export { ProfileSection } from './ProfileSection'; +export { useProfileSection } from './useProfileSection'; diff --git a/src/renderer/features/settings/components/ProfileSection/useProfileSection.ts b/src/renderer/features/settings/components/ProfileSection/useProfileSection.ts new file mode 100644 index 00000000..8d6e291f --- /dev/null +++ b/src/renderer/features/settings/components/ProfileSection/useProfileSection.ts @@ -0,0 +1,95 @@ +/** + * useProfileSection — logic hook for ProfileSection + */ + +import { useState } from 'react'; + +import type { Profile } from '@shared/types'; + +import { useModalWithEditState } from '@renderer/shared/hooks/useModalWithEditState'; + +import { + useCreateProfile, + useDeleteProfile, + useProfiles, + useSetDefaultProfile, + useUpdateProfile, +} from '../../api/useSettings'; + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return 'An unexpected error occurred'; +} + +export function useProfileSection() { + const { data: profiles, isLoading } = useProfiles(); + const createProfile = useCreateProfile(); + const updateProfile = useUpdateProfile(); + const deleteProfile = useDeleteProfile(); + const setDefaultProfile = useSetDefaultProfile(); + + const { + open: modalOpen, + editing: editingProfile, + handleAdd, + handleEdit, + handleClose: handleCloseModal, + } = useModalWithEditState<Profile>(); + const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); + const [errorMessage, setErrorMessage] = useState<string | null>(null); + + function handleDeleteRequest(id: string) { + setErrorMessage(null); + setDeleteConfirmId(id); + } + + function handleDeleteConfirm() { + if (deleteConfirmId !== null) { + deleteProfile.mutate(deleteConfirmId, { + onError: (error) => setErrorMessage(extractErrorMessage(error)), + }); + setDeleteConfirmId(null); + } + } + + function handleDeleteCancel() { + setDeleteConfirmId(null); + } + + function handleSetDefault(id: string) { + setDefaultProfile.mutate(id); + } + + function handleSave(data: { name: string; apiKey?: string; model?: string }) { + setErrorMessage(null); + const errorHandler = { + onError: (error: unknown) => setErrorMessage(extractErrorMessage(error)), + }; + if (editingProfile === null) { + createProfile.mutate(data, errorHandler); + } else { + updateProfile.mutate({ id: editingProfile.id, updates: data }, errorHandler); + } + handleCloseModal(); + } + + return { + profiles, + isLoading, + modalOpen, + editingProfile, + deleteConfirmId, + errorMessage, + setErrorMessage, + handleAdd, + handleEdit, + handleDeleteRequest, + handleDeleteConfirm, + handleDeleteCancel, + handleSetDefault, + handleSave, + handleCloseModal, + }; +} diff --git a/src/renderer/features/settings/components/RetentionControl.tsx b/src/renderer/features/settings/components/RetentionControl/RetentionControl.tsx similarity index 80% rename from src/renderer/features/settings/components/RetentionControl.tsx rename to src/renderer/features/settings/components/RetentionControl/RetentionControl.tsx index 90fa4492..11fb1f23 100644 --- a/src/renderer/features/settings/components/RetentionControl.tsx +++ b/src/renderer/features/settings/components/RetentionControl/RetentionControl.tsx @@ -2,24 +2,16 @@ * RetentionControl — Per-store retention editor card */ -import { useState } from 'react'; - import type { DataStoreEntry, DataStoreUsage, RetentionPolicy } from '@shared/types/data-management'; - import { ConfirmDialog } from '@renderer/shared/components/ConfirmDialog'; import { cn } from '@renderer/shared/lib/utils'; -import { Button, Checkbox, Input, Label, Spinner } from '@ui'; +import { Button, Checkbox, Heading, Input, Label, Separator, Spinner } from '@ui'; -interface RetentionControlProps { - entry: DataStoreEntry; - retention?: RetentionPolicy; - onUpdate: (policy: Partial<RetentionPolicy>) => void; - onClear: () => void; - usage?: DataStoreUsage; - clearPending?: boolean; -} +import { useRetentionControl } from './useRetentionControl'; + +// ── Helpers ─────────────────────────────────────────────── function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; @@ -47,42 +39,33 @@ function getLifecycleColor(lifecycle: string): string { } } -export function RetentionControl({ - entry, - retention, - onUpdate, - onClear, - usage, - clearPending = false, -}: RetentionControlProps) { - const [confirmClearOpen, setConfirmClearOpen] = useState(false); - - const isEnabled = retention?.enabled ?? entry.defaultRetention.enabled; - const maxAgeDays = retention?.maxAgeDays ?? entry.defaultRetention.maxAgeDays; - const maxItems = retention?.maxItems ?? entry.defaultRetention.maxItems; - - function handleToggle() { - onUpdate({ enabled: !isEnabled }); - } +// ── Props ───────────────────────────────────────────────── - function handleMaxAgeDaysChange(event: React.ChangeEvent<HTMLInputElement>) { - const value = Number(event.target.value); - if (value >= 0) { - onUpdate({ maxAgeDays: value > 0 ? value : undefined }); - } - } +interface RetentionControlProps { + entry: DataStoreEntry; + retention?: RetentionPolicy; + onUpdate: (policy: Partial<RetentionPolicy>) => void; + onClear: () => void; + usage?: DataStoreUsage; + clearPending?: boolean; +} - function handleMaxItemsChange(event: React.ChangeEvent<HTMLInputElement>) { - const value = Number(event.target.value); - if (value >= 0) { - onUpdate({ maxItems: value > 0 ? value : undefined }); - } - } +// ── Component ───────────────────────────────────────────── - function handleClearConfirm() { - onClear(); - setConfirmClearOpen(false); - } +export function RetentionControl(props: RetentionControlProps) { + const { entry, usage, clearPending = false } = props; + + const { + confirmClearOpen, + setConfirmClearOpen, + isEnabled, + maxAgeDays, + maxItems, + handleToggle, + handleMaxAgeDaysChange, + handleMaxItemsChange, + handleClearConfirm, + } = useRetentionControl(props); return ( <> @@ -91,7 +74,7 @@ export function RetentionControl({ <div className="mb-2 flex items-start justify-between gap-2"> <div className="min-w-0 flex-1"> <div className="flex items-center gap-2"> - <h3 className="text-foreground truncate text-sm font-medium">{entry.label}</h3> + <Heading as="h3" className="truncate text-sm font-medium">{entry.label}</Heading> <span className={cn( 'inline-flex shrink-0 rounded px-1.5 py-0.5 text-xs font-medium', @@ -178,7 +161,9 @@ export function RetentionControl({ {/* Clear button */} {entry.canClear ? ( - <div className="mt-3 border-t border-border pt-3"> + <> + <Separator className="mt-3" /> + <div className="pt-3"> <Button className="text-destructive hover:bg-destructive/10" disabled={clearPending} @@ -191,7 +176,8 @@ export function RetentionControl({ {clearPending ? <Spinner size="sm" /> : null} Clear store data </Button> - </div> + </div> + </> ) : null} </div> diff --git a/src/renderer/features/settings/components/RetentionControl/index.ts b/src/renderer/features/settings/components/RetentionControl/index.ts new file mode 100644 index 00000000..e22046d3 --- /dev/null +++ b/src/renderer/features/settings/components/RetentionControl/index.ts @@ -0,0 +1,2 @@ +export { RetentionControl } from './RetentionControl'; +export { useRetentionControl } from './useRetentionControl'; diff --git a/src/renderer/features/settings/components/RetentionControl/useRetentionControl.ts b/src/renderer/features/settings/components/RetentionControl/useRetentionControl.ts new file mode 100644 index 00000000..10deda36 --- /dev/null +++ b/src/renderer/features/settings/components/RetentionControl/useRetentionControl.ts @@ -0,0 +1,60 @@ +/** + * useRetentionControl — logic hook for RetentionControl + */ + +import { useState } from 'react'; + +import type { DataStoreEntry, DataStoreUsage, RetentionPolicy } from '@shared/types/data-management'; + +export interface RetentionControlProps { + entry: DataStoreEntry; + retention?: RetentionPolicy; + onUpdate: (policy: Partial<RetentionPolicy>) => void; + onClear: () => void; + usage?: DataStoreUsage; + clearPending?: boolean; +} + +export function useRetentionControl(props: RetentionControlProps) { + const { entry, retention, onUpdate } = props; + const [confirmClearOpen, setConfirmClearOpen] = useState(false); + + const isEnabled = retention?.enabled ?? entry.defaultRetention.enabled; + const maxAgeDays = retention?.maxAgeDays ?? entry.defaultRetention.maxAgeDays; + const maxItems = retention?.maxItems ?? entry.defaultRetention.maxItems; + + function handleToggle() { + onUpdate({ enabled: !isEnabled }); + } + + function handleMaxAgeDaysChange(event: React.ChangeEvent<HTMLInputElement>) { + const value = Number(event.target.value); + if (value >= 0) { + onUpdate({ maxAgeDays: value > 0 ? value : undefined }); + } + } + + function handleMaxItemsChange(event: React.ChangeEvent<HTMLInputElement>) { + const value = Number(event.target.value); + if (value >= 0) { + onUpdate({ maxItems: value > 0 ? value : undefined }); + } + } + + function handleClearConfirm() { + props.onClear(); + setConfirmClearOpen(false); + } + + return { + confirmClearOpen, + setConfirmClearOpen, + isEnabled, + maxAgeDays, + maxItems, + handleToggle, + handleMaxAgeDaysChange, + handleMaxItemsChange, + handleClearConfirm, + }; +} diff --git a/src/renderer/features/settings/components/SettingsPage.tsx b/src/renderer/features/settings/components/SettingsPage.tsx index be3c3915..0cd23a20 100644 --- a/src/renderer/features/settings/components/SettingsPage.tsx +++ b/src/renderer/features/settings/components/SettingsPage.tsx @@ -1,16 +1,18 @@ /** * SettingsPage — App settings view with tab bar layout * - * Tabs: Display, Profile, Hub, Integrations, Storage, Advanced + * Tabs: Display, Profile, Peers, Integrations, Storage, Testing, Advanced */ -import { HardDrive, Paintbrush, Plug, Server, User, Wrench } from 'lucide-react'; +import { FlaskConical, HardDrive, Network, Paintbrush, Plug, User, Wrench } from 'lucide-react'; import type { ThemeMode } from '@shared/types'; import { useAssistantWidgetStore, useThemeStore } from '@renderer/shared/stores'; -import { PageContent, PageHeader, PageLayout, Spinner, Switch } from '@ui'; +import { Heading, PageContent, PageHeader, PageLayout, Spinner, Switch } from '@ui'; + +import { PeerListPanel } from '@features/peers'; import { useSettings, useUpdateSettings } from '../api/useSettings'; @@ -21,11 +23,12 @@ import { ClaudeAuthSettings } from './ClaudeAuthSettings'; import { DataLocationSection } from './DataLocationSection'; import { GitHubAuthSettings } from './GitHubAuthSettings'; import { HotkeySettings } from './HotkeySettings'; -import { HubSettings } from './HubSettings'; import { LayoutSection } from './LayoutSection'; import { OAuthProviderSettings } from './OAuthProviderSettings'; import { ProfileSection } from './ProfileSection'; +import { SpacingSection } from './SpacingSection'; import { StorageManagementSection } from './StorageManagementSection'; +import { TestingSettingsTab } from './TestingSettingsTab'; import { TypographySection } from './TypographySection'; import { UiScaleSection } from './UiScaleSection'; import { VoiceSettings } from './voice/VoiceSettings'; @@ -37,9 +40,10 @@ import { WorkspacesTab } from './WorkspacesTab'; const SETTINGS_TABS = [ { id: 'display' as const, label: 'Display', icon: Paintbrush }, { id: 'profile' as const, label: 'Profile', icon: User }, - { id: 'hub' as const, label: 'Hub', icon: Server }, + { id: 'peers' as const, label: 'Peers', icon: Network }, { id: 'integrations' as const, label: 'Integrations', icon: Plug }, { id: 'storage' as const, label: 'Storage', icon: HardDrive }, + { id: 'testing' as const, label: 'Testing', icon: FlaskConical }, { id: 'advanced' as const, label: 'Advanced', icon: Wrench }, ]; @@ -48,7 +52,7 @@ const SETTINGS_TABS = [ export function SettingsPage() { const { data: settings, isLoading } = useSettings(); const updateSettings = useUpdateSettings(); - const { mode, uiScale, setMode, setUiScale } = useThemeStore(); + const { mode, uiScale, layoutGap, setMode, setUiScale, setLayoutGap } = useThemeStore(); const currentFontFamily = settings?.fontFamily ?? 'system-ui'; const currentFontSize = settings?.fontSize ?? 14; @@ -64,6 +68,12 @@ export function SettingsPage() { updateSettings.mutate({ uiScale: scale }); } + function handleLayoutGapChange(event: React.ChangeEvent<HTMLInputElement>) { + const gap = Number(event.target.value); + setLayoutGap(gap); + updateSettings.mutate({ layoutGap: gap }); + } + function handleFontFamilyChange(fontFamily: string) { document.documentElement.style.setProperty('--app-font-sans', fontFamily); updateSettings.mutate({ fontFamily }); @@ -104,6 +114,7 @@ export function SettingsPage() { <LayoutSection /> <AppearanceModeSection currentMode={mode} onModeChange={handleThemeChange} /> <UiScaleSection currentScale={uiScale} onScaleChange={handleUiScaleChange} /> + <SpacingSection currentGap={layoutGap} onGapChange={handleLayoutGapChange} /> <TypographySection currentFontFamily={currentFontFamily} currentFontSize={currentFontSize} @@ -111,9 +122,9 @@ export function SettingsPage() { onFontSizeChange={handleFontSizeChange} /> <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> Language - </h2> + </Heading> <div className="border-border bg-card flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-sm"> <span>English</span> <span className="text-muted-foreground text-xs">Only language available</span> @@ -128,32 +139,27 @@ export function SettingsPage() { </section> </PageHeader.TabContent> - <PageHeader.TabContent value="hub"> - <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> - Hub Connection - </h2> - <HubSettings /> - </section> + <PageHeader.TabContent value="peers"> + <PeerListPanel /> </PageHeader.TabContent> <PageHeader.TabContent value="integrations"> <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> Claude Code - </h2> + </Heading> <ClaudeAuthSettings /> </section> <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> GitHub - </h2> + </Heading> <GitHubAuthSettings /> </section> <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> OAuth Providers - </h2> + </Heading> <OAuthProviderSettings /> </section> </PageHeader.TabContent> @@ -161,13 +167,17 @@ export function SettingsPage() { <PageHeader.TabContent value="storage"> <DataLocationSection /> <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> Storage Management - </h2> + </Heading> <StorageManagementSection /> </section> </PageHeader.TabContent> + <PageHeader.TabContent value="testing"> + <TestingSettingsTab /> + </PageHeader.TabContent> + <PageHeader.TabContent value="advanced"> <AdvancedTab settings={settings} updateSettings={updateSettings} /> </PageHeader.TabContent> @@ -193,9 +203,9 @@ function AdvancedTab({ settings, updateSettings }: AdvancedTabProps) { <> <BackgroundSettings /> <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> AI Assistant - </h2> + </Heading> <div className="border-border bg-card space-y-4 rounded-lg border p-4"> <div className="flex items-center justify-between"> <div> @@ -232,24 +242,24 @@ function AdvancedTab({ settings, updateSettings }: AdvancedTabProps) { </section> <AppBehaviorSection /> <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> Assistant & Webhooks - </h2> + </Heading> <WebhookSettings /> </section> <HotkeySettings /> <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> Voice - </h2> + </Heading> <div className="border-border bg-card rounded-lg border p-4"> <VoiceSettings /> </div> </section> <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> About - </h2> + </Heading> <p className="text-muted-foreground text-sm">ADC v0.1.0</p> </section> </> diff --git a/src/renderer/features/settings/components/SpacingSection.tsx b/src/renderer/features/settings/components/SpacingSection.tsx new file mode 100644 index 00000000..1557ac20 --- /dev/null +++ b/src/renderer/features/settings/components/SpacingSection.tsx @@ -0,0 +1,45 @@ +/** + * SpacingSection — Layout spacing density slider + */ + +import { Heading, Input, Label } from '@ui'; + +// ── Constants ─────────────────────────────────────────────── + +const SPACING_MIN = 0; +const SPACING_MAX = 16; +const SPACING_STEP = 2; + +// ── Component ─────────────────────────────────────────────── + +interface SpacingSectionProps { + currentGap: number; + onGapChange: (event: React.ChangeEvent<HTMLInputElement>) => void; +} + +export function SpacingSection({ currentGap, onGapChange }: SpacingSectionProps) { + return ( + <section className="mb-8"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + Spacing + </Heading> + <div className="flex items-center gap-4"> + <Label className="text-muted-foreground w-16 shrink-0 text-sm">Compact</Label> + <Input + aria-label="Layout spacing density" + className="bg-muted accent-primary h-2 flex-1 cursor-pointer appearance-none rounded-full border-0" + max={SPACING_MAX} + min={SPACING_MIN} + step={SPACING_STEP} + type="range" + value={currentGap} + onChange={onGapChange} + /> + <Label className="text-muted-foreground w-16 shrink-0 text-right text-sm">Spacious</Label> + </div> + <p className="text-muted-foreground mt-2 text-center text-sm"> + Current: <span className="text-foreground font-medium">{currentGap}</span> + </p> + </section> + ); +} diff --git a/src/renderer/features/settings/components/StorageManagementSection.tsx b/src/renderer/features/settings/components/StorageManagementSection.tsx deleted file mode 100644 index c39de1e0..00000000 --- a/src/renderer/features/settings/components/StorageManagementSection.tsx +++ /dev/null @@ -1,467 +0,0 @@ -/** - * StorageManagementSection — TanStack Table with inline editing for data stores - */ - -import { useMemo, useState } from 'react'; - -import { - flexRender, - getCoreRowModel, - getFilteredRowModel, - getSortedRowModel, - useReactTable, -} from '@tanstack/react-table'; -import { ArrowUpDown, Download, RefreshCw, Trash2, Upload } from 'lucide-react'; - -import type { DataStoreEntry, DataStoreUsage, RetentionPolicy } from '@shared/types/data-management'; - -import { ConfirmDialog } from '@renderer/shared/components/ConfirmDialog'; -import { cn } from '@renderer/shared/lib/utils'; -import { useToastStore } from '@renderer/shared/stores'; - -import { - Badge, - Button, - Checkbox, - Input, - SearchInput, - Spinner, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@ui'; - -import { - useClearStore, - useDataRegistry, - useDataRetention, - useDataUsage, - useExportData, - useImportData, - useRunCleanup, - useUpdateRetention, -} from '../api/useDataManagement'; -import { useDataManagementEvents } from '../hooks/useDataManagementEvents'; - -import { StorageUsageBar } from './StorageUsageBar'; - -import type { ColumnDef, SortingState } from '@tanstack/react-table'; - -// ── Helpers ──────────────────────────────────────────────── - -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - const base = 1024; - const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(base)), units.length - 1); - const value = bytes / base ** exponent; - return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`; -} - -function getLifecycleVariant(lifecycle: string): 'default' | 'secondary' | 'destructive' | 'outline' { - switch (lifecycle) { - case 'transient': return 'destructive'; - case 'persistent': return 'default'; - default: return 'secondary'; - } -} - -// ── Row type ─────────────────────────────────────────────── - -interface StoreRow { - entry: DataStoreEntry; - usage?: DataStoreUsage; - retention: RetentionPolicy; -} - -// ── Component ────────────────────────────────────────────── - -export function StorageManagementSection() { - const registry = useDataRegistry(); - const usage = useDataUsage(); - const retentionQuery = useDataRetention(); - const updateRetention = useUpdateRetention(); - const clearStore = useClearStore(); - const runCleanup = useRunCleanup(); - const exportData = useExportData(); - const importData = useImportData(); - const addToast = useToastStore((s) => s.addToast); - - const [clearingStoreId, setClearingStoreId] = useState<string | null>(null); - const [confirmClearEntry, setConfirmClearEntry] = useState<DataStoreEntry | null>(null); - const [globalFilter, setGlobalFilter] = useState(''); - const [sorting, setSorting] = useState<SortingState>([]); - - useDataManagementEvents(); - - const isLoading = registry.isLoading || usage.isLoading || retentionQuery.isLoading; - const isError = registry.isError || usage.isError || retentionQuery.isError; - - // Build row data - const usageMap = useMemo( - () => new Map((usage.data ?? []).map((item) => [item.id, item])), - [usage.data], - ); - - const rows = useMemo<StoreRow[]>(() => { - if (!registry.data || !retentionQuery.data) return []; - return registry.data.map((entry) => ({ - entry, - usage: usageMap.get(entry.id), - retention: retentionQuery.data.overrides[entry.id] ?? entry.defaultRetention, - })); - }, [registry.data, retentionQuery.data, usageMap]); - - // Handlers - function handleRetentionToggle(storeId: string, currentEnabled: boolean) { - if (!retentionQuery.data) return; - const current = retentionQuery.data.overrides[storeId] ?? { enabled: currentEnabled }; - updateRetention.mutate({ - overrides: { - ...retentionQuery.data.overrides, - [storeId]: { ...current, enabled: !currentEnabled } as RetentionPolicy, - }, - }); - } - - function handleFieldChange(storeId: string, field: 'maxAgeDays' | 'maxItems', value: string) { - if (!retentionQuery.data) return; - const num = Number(value); - const current = retentionQuery.data.overrides[storeId] ?? { enabled: true }; - updateRetention.mutate({ - overrides: { - ...retentionQuery.data.overrides, - [storeId]: { ...current, [field]: num > 0 ? num : undefined } as RetentionPolicy, - }, - }); - } - - function handleClearConfirm() { - if (!confirmClearEntry) return; - setClearingStoreId(confirmClearEntry.id); - clearStore.mutate(confirmClearEntry.id, { - onSuccess: () => { - addToast('Store cleared successfully', 'success'); - setClearingStoreId(null); - setConfirmClearEntry(null); - }, - onError: () => { - setClearingStoreId(null); - setConfirmClearEntry(null); - }, - }); - } - - function handleExport() { - exportData.mutate(undefined, { - onSuccess: (result) => { - addToast(`Data exported to ${result.filePath}`, 'success'); - }, - }); - } - - function handleImport() { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json,.zip'; - input.addEventListener('change', () => { - const file = input.files?.[0]; - if (file) { - const filePath = (file as unknown as { path: string }).path; - importData.mutate(filePath, { - onSuccess: (result) => { - addToast(`Imported ${String(result.imported)} stores successfully`, 'success'); - }, - }); - } - }); - input.click(); - } - - /* eslint-disable react/no-unstable-nested-components -- TanStack Table cell renderers are render functions, not components */ - const columns = useMemo<Array<ColumnDef<StoreRow>>>( - () => [ - { - id: 'enabled', - header: 'Active', - size: 60, - cell: ({ row }) => ( - <Checkbox - checked={row.original.retention.enabled} - onCheckedChange={() => - handleRetentionToggle(row.original.entry.id, row.original.retention.enabled) - } - /> - ), - }, - { - id: 'label', - accessorFn: (row) => row.entry.label, - header: ({ column }) => ( - <Button - variant="ghost" - onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} - > - Store - <ArrowUpDown /> - </Button> - ), - size: 200, - cell: ({ row }) => ( - <div> - <span className="text-foreground text-sm font-medium">{row.original.entry.label}</span> - <span className="text-muted-foreground ml-2 text-xs">{row.original.entry.description}</span> - </div> - ), - }, - { - id: 'lifecycle', - accessorFn: (row) => row.entry.lifecycle, - header: 'Lifecycle', - size: 100, - cell: ({ row }) => ( - <Badge variant={getLifecycleVariant(row.original.entry.lifecycle)}> - {row.original.entry.lifecycle} - </Badge> - ), - }, - { - id: 'size', - accessorFn: (row) => row.usage?.sizeBytes ?? 0, - header: ({ column }) => ( - <Button - variant="ghost" - onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} - > - Size - <ArrowUpDown /> - </Button> - ), - size: 90, - cell: ({ row }) => ( - <span className="text-muted-foreground text-xs"> - {row.original.usage ? formatBytes(row.original.usage.sizeBytes) : '—'} - </span> - ), - }, - { - id: 'items', - accessorFn: (row) => row.usage?.itemCount ?? 0, - header: 'Items', - size: 70, - cell: ({ row }) => ( - <span className="text-muted-foreground text-xs"> - {row.original.usage ? String(row.original.usage.itemCount) : '—'} - </span> - ), - }, - { - id: 'maxAgeDays', - header: 'Max Age (days)', - size: 120, - cell: ({ row }) => - row.original.retention.enabled ? ( - <Input - className="h-7 w-20 text-xs" - min={0} - placeholder="∞" - type="number" - value={row.original.retention.maxAgeDays ?? ''} - onChange={(e) => - handleFieldChange(row.original.entry.id, 'maxAgeDays', e.target.value) - } - /> - ) : ( - <span className="text-muted-foreground text-xs">—</span> - ), - }, - { - id: 'maxItems', - header: 'Max Items', - size: 100, - cell: ({ row }) => - row.original.retention.enabled ? ( - <Input - className="h-7 w-20 text-xs" - min={0} - placeholder="∞" - type="number" - value={row.original.retention.maxItems ?? ''} - onChange={(e) => - handleFieldChange(row.original.entry.id, 'maxItems', e.target.value) - } - /> - ) : ( - <span className="text-muted-foreground text-xs">—</span> - ), - }, - { - id: 'actions', - header: '', - size: 50, - cell: ({ row }) => - row.original.entry.canClear ? ( - <Button - disabled={clearingStoreId === row.original.entry.id} - size="icon-xs" - variant="ghost-destructive" - onClick={() => setConfirmClearEntry(row.original.entry)} - > - {clearingStoreId === row.original.entry.id ? ( - <Spinner size="sm" /> - ) : ( - <Trash2 /> - )} - </Button> - ) : null, - }, - ], - // eslint-disable-next-line react-hooks/exhaustive-deps -- handlers use retentionQuery.data which changes - [clearingStoreId, retentionQuery.data], - ); - /* eslint-enable react/no-unstable-nested-components */ - - const table = useReactTable({ - data: rows, - columns, - state: { sorting, globalFilter }, - onSortingChange: setSorting, - onGlobalFilterChange: setGlobalFilter, - globalFilterFn: (row, _columnId, filterValue: string) => { - const label = row.original.entry.label.toLowerCase(); - const desc = row.original.entry.description.toLowerCase(); - const filter = filterValue.toLowerCase(); - return label.includes(filter) || desc.includes(filter); - }, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getRowId: (row) => row.entry.id, - }); - - if (isLoading) { - return ( - <div className="flex items-center justify-center py-8"> - <Spinner size="md" /> - </div> - ); - } - - if (isError) { - return ( - <div className="border-destructive/30 bg-destructive/5 rounded-lg border p-4"> - <span className="text-destructive text-sm"> - {registry.error?.message ?? usage.error?.message ?? 'Failed to load storage data'} - </span> - </div> - ); - } - - return ( - <div className="space-y-6"> - {/* Storage overview bar */} - {registry.data && usage.data ? ( - <StorageUsageBar registry={registry.data} usage={usage.data} /> - ) : null} - - {/* Search + Actions */} - <div className="flex items-center justify-between gap-4"> - <SearchInput - className="max-w-xs" - placeholder="Search stores..." - value={globalFilter} - onChange={(e) => setGlobalFilter(e.target.value)} - /> - <div className="flex gap-2"> - <Button - disabled={runCleanup.isPending} - size="sm" - onClick={() => - runCleanup.mutate(undefined, { - onSuccess: (result) => { - addToast( - `Cleanup: removed ${String(result.cleaned)} items, freed ${formatBytes(result.freedBytes)}`, - 'success', - ); - }, - }) - } - > - {runCleanup.isPending ? <Spinner size="sm" /> : <RefreshCw />} - Cleanup - </Button> - <Button disabled={exportData.isPending} size="sm" variant="outline" onClick={handleExport}> - {exportData.isPending ? <Spinner size="sm" /> : <Download />} - Export - </Button> - <Button disabled={importData.isPending} size="sm" variant="outline" onClick={handleImport}> - {importData.isPending ? <Spinner size="sm" /> : <Upload />} - Import - </Button> - </div> - </div> - - {/* Data stores table */} - <div className="border-border overflow-hidden rounded-lg border"> - <Table> - <TableHeader> - {table.getHeaderGroups().map((headerGroup) => ( - <TableRow key={headerGroup.id}> - {headerGroup.headers.map((header) => ( - <TableHead key={header.id} style={{ width: header.getSize() }}> - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - </TableHead> - ))} - </TableRow> - ))} - </TableHeader> - <TableBody> - {table.getRowModel().rows.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - <TableRow - key={row.id} - className={cn(!row.original.retention.enabled && 'opacity-50')} - > - {row.getVisibleCells().map((cell) => ( - <TableCell key={cell.id}> - {flexRender(cell.column.columnDef.cell, cell.getContext())} - </TableCell> - ))} - </TableRow> - )) - ) : ( - <TableRow> - <TableCell className="text-muted-foreground h-24 text-center" colSpan={columns.length}> - No data stores found. - </TableCell> - </TableRow> - )} - </TableBody> - </Table> - </div> - - {/* Clear confirmation */} - <ConfirmDialog - confirmLabel="Clear" - loading={clearingStoreId !== null} - open={confirmClearEntry !== null} - title={confirmClearEntry ? `Clear ${confirmClearEntry.label}?` : ''} - variant="destructive" - description={ - confirmClearEntry - ? `This will remove all data from "${confirmClearEntry.label}". This action cannot be undone.` - : '' - } - onConfirm={handleClearConfirm} - onOpenChange={(open) => { - if (!open) setConfirmClearEntry(null); - }} - /> - </div> - ); -} diff --git a/src/renderer/features/settings/components/StorageManagementSection/StorageManagementSection.tsx b/src/renderer/features/settings/components/StorageManagementSection/StorageManagementSection.tsx new file mode 100644 index 00000000..99dd493d --- /dev/null +++ b/src/renderer/features/settings/components/StorageManagementSection/StorageManagementSection.tsx @@ -0,0 +1,172 @@ +/** + * StorageManagementSection — TanStack Table with inline editing for data stores + */ + +import { flexRender } from '@tanstack/react-table'; +import { Download, RefreshCw, Upload } from 'lucide-react'; + +import { ConfirmDialog } from '@renderer/shared/components/ConfirmDialog'; +import { cn } from '@renderer/shared/lib/utils'; + +import { + Button, + SearchInput, + Spinner, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@ui'; + +import { StorageUsageBar } from '../StorageUsageBar'; + +import { useStorageManagementSection } from './useStorageManagementSection'; + +export function StorageManagementSection() { + const { + registry, + usage, + runCleanup, + exportData, + importData, + confirmClearEntry, + setConfirmClearEntry, + globalFilter, + setGlobalFilter, + isLoading, + isError, + columns, + table, + clearingStoreId, + handleClearConfirm, + handleExport, + handleImport, + addToast, + formatBytes, + } = useStorageManagementSection(); + + if (isLoading) { + return ( + <div className="flex items-center justify-center py-8"> + <Spinner size="md" /> + </div> + ); + } + + if (isError) { + return ( + <div className="border-destructive/30 bg-destructive/5 rounded-lg border p-4"> + <span className="text-destructive text-sm"> + {registry.error?.message ?? usage.error?.message ?? 'Failed to load storage data'} + </span> + </div> + ); + } + + return ( + <div className="space-y-6"> + {/* Storage overview bar */} + {registry.data && usage.data ? ( + <StorageUsageBar registry={registry.data} usage={usage.data} /> + ) : null} + + {/* Search + Actions */} + <div className="flex items-center justify-between gap-4"> + <SearchInput + className="max-w-xs" + placeholder="Search stores..." + value={globalFilter} + onChange={(e) => setGlobalFilter(e.target.value)} + /> + <div className="flex gap-2"> + <Button + disabled={runCleanup.isPending} + size="sm" + onClick={() => + runCleanup.mutate(undefined, { + onSuccess: (result) => { + addToast( + `Cleanup: removed ${String(result.cleaned)} items, freed ${formatBytes(result.freedBytes)}`, + 'success', + ); + }, + }) + } + > + {runCleanup.isPending ? <Spinner size="sm" /> : <RefreshCw />} + Cleanup + </Button> + <Button disabled={exportData.isPending} size="sm" variant="outline" onClick={handleExport}> + {exportData.isPending ? <Spinner size="sm" /> : <Download />} + Export + </Button> + <Button disabled={importData.isPending} size="sm" variant="outline" onClick={handleImport}> + {importData.isPending ? <Spinner size="sm" /> : <Upload />} + Import + </Button> + </div> + </div> + + {/* Data stores table */} + <div className="border-border overflow-hidden rounded-lg border"> + <Table> + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <TableHead key={header.id} style={{ width: header.getSize() }}> + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + </TableHead> + ))} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + <TableRow + key={row.id} + className={cn(!row.original.retention.enabled && 'opacity-50')} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id}> + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell className="text-muted-foreground h-24 text-center" colSpan={columns.length}> + No data stores found. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + + {/* Clear confirmation */} + <ConfirmDialog + confirmLabel="Clear" + loading={clearingStoreId !== null} + open={confirmClearEntry !== null} + title={confirmClearEntry ? `Clear ${confirmClearEntry.label}?` : ''} + variant="destructive" + description={ + confirmClearEntry + ? `This will remove all data from "${confirmClearEntry.label}". This action cannot be undone.` + : '' + } + onConfirm={handleClearConfirm} + onOpenChange={(open) => { + if (!open) setConfirmClearEntry(null); + }} + /> + </div> + ); +} diff --git a/src/renderer/features/settings/components/StorageManagementSection/index.ts b/src/renderer/features/settings/components/StorageManagementSection/index.ts new file mode 100644 index 00000000..0f9a7207 --- /dev/null +++ b/src/renderer/features/settings/components/StorageManagementSection/index.ts @@ -0,0 +1,2 @@ +export { StorageManagementSection } from './StorageManagementSection'; +export { useStorageManagementSection } from './useStorageManagementSection'; diff --git a/src/renderer/features/settings/components/StorageManagementSection/useStorageManagementSection.tsx b/src/renderer/features/settings/components/StorageManagementSection/useStorageManagementSection.tsx new file mode 100644 index 00000000..051c3a1c --- /dev/null +++ b/src/renderer/features/settings/components/StorageManagementSection/useStorageManagementSection.tsx @@ -0,0 +1,346 @@ +/** + * useStorageManagementSection — data + mutation + table logic for StorageManagementSection + */ + +import { useMemo, useState } from 'react'; + +import { + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { ArrowUpDown, Trash2 } from 'lucide-react'; + +import type { DataStoreEntry, DataStoreUsage, RetentionPolicy } from '@shared/types/data-management'; + +import { useToastStore } from '@renderer/shared/stores'; + +import { Badge, Button, Checkbox, Input, Spinner } from '@ui'; + +import { + useClearStore, + useDataRegistry, + useDataRetention, + useDataUsage, + useExportData, + useImportData, + useRunCleanup, + useUpdateRetention, +} from '../../api/useDataManagement'; +import { useDataManagementEvents } from '../../hooks/useDataManagementEvents'; + +import type { ColumnDef, SortingState, Table } from '@tanstack/react-table'; + +export interface StoreRow { + entry: DataStoreEntry; + usage?: DataStoreUsage; + retention: RetentionPolicy; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const base = 1024; + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(base)), units.length - 1); + const value = bytes / base ** exponent; + return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`; +} + +function getLifecycleVariant(lifecycle: string): 'default' | 'secondary' | 'destructive' | 'outline' { + switch (lifecycle) { + case 'transient': return 'destructive'; + case 'persistent': return 'default'; + default: return 'secondary'; + } +} + +export { formatBytes }; + +export function useStorageManagementSection() { + const registry = useDataRegistry(); + const usage = useDataUsage(); + const retentionQuery = useDataRetention(); + const updateRetention = useUpdateRetention(); + const clearStore = useClearStore(); + const runCleanup = useRunCleanup(); + const exportData = useExportData(); + const importData = useImportData(); + const addToast = useToastStore((s) => s.addToast); + + const [clearingStoreId, setClearingStoreId] = useState<string | null>(null); + const [confirmClearEntry, setConfirmClearEntry] = useState<DataStoreEntry | null>(null); + const [globalFilter, setGlobalFilter] = useState(''); + const [sorting, setSorting] = useState<SortingState>([]); + + useDataManagementEvents(); + + const isLoading = registry.isLoading || usage.isLoading || retentionQuery.isLoading; + const isError = registry.isError || usage.isError || retentionQuery.isError; + + const usageMap = useMemo( + () => new Map((usage.data ?? []).map((item) => [item.id, item])), + [usage.data], + ); + + const rows = useMemo<StoreRow[]>(() => { + if (!registry.data || !retentionQuery.data) return []; + return registry.data.map((entry) => ({ + entry, + usage: usageMap.get(entry.id), + retention: retentionQuery.data.overrides[entry.id] ?? entry.defaultRetention, + })); + }, [registry.data, retentionQuery.data, usageMap]); + + function handleRetentionToggle(storeId: string, currentEnabled: boolean) { + if (!retentionQuery.data) return; + const current = retentionQuery.data.overrides[storeId] ?? { enabled: currentEnabled }; + updateRetention.mutate({ + overrides: { + ...retentionQuery.data.overrides, + [storeId]: { ...current, enabled: !currentEnabled } as RetentionPolicy, + }, + }); + } + + function handleFieldChange(storeId: string, field: 'maxAgeDays' | 'maxItems', value: string) { + if (!retentionQuery.data) return; + const num = Number(value); + const current = retentionQuery.data.overrides[storeId] ?? { enabled: true }; + updateRetention.mutate({ + overrides: { + ...retentionQuery.data.overrides, + [storeId]: { ...current, [field]: num > 0 ? num : undefined } as RetentionPolicy, + }, + }); + } + + function handleClearConfirm() { + if (!confirmClearEntry) return; + setClearingStoreId(confirmClearEntry.id); + clearStore.mutate(confirmClearEntry.id, { + onSuccess: () => { + addToast('Store cleared successfully', 'success'); + setClearingStoreId(null); + setConfirmClearEntry(null); + }, + onError: () => { + setClearingStoreId(null); + setConfirmClearEntry(null); + }, + }); + } + + function handleExport() { + exportData.mutate(undefined, { + onSuccess: (result) => { + addToast(`Data exported to ${result.filePath}`, 'success'); + }, + }); + } + + function handleImport() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,.zip'; + input.addEventListener('change', () => { + const file = input.files?.[0]; + if (file) { + const filePath = (file as unknown as { path: string }).path; + importData.mutate(filePath, { + onSuccess: (result) => { + addToast(`Imported ${String(result.imported)} stores successfully`, 'success'); + }, + }); + } + }); + input.click(); + } + + // ─── Table columns + instance ──────────────────────────── + + const columns = useMemo<Array<ColumnDef<StoreRow>>>( + () => [ + { + id: 'enabled', + header: 'Active', + size: 60, + cell: ({ row }) => ( + <Checkbox + checked={row.original.retention.enabled} + onCheckedChange={() => + handleRetentionToggle(row.original.entry.id, row.original.retention.enabled) + } + /> + ), + }, + { + id: 'label', + accessorFn: (row) => row.entry.label, + header: ({ column }) => ( + <Button + variant="ghost" + onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} + > + Store + <ArrowUpDown /> + </Button> + ), + size: 200, + cell: ({ row }) => ( + <div> + <span className="text-foreground text-sm font-medium">{row.original.entry.label}</span> + <span className="text-muted-foreground ml-2 text-xs">{row.original.entry.description}</span> + </div> + ), + }, + { + id: 'lifecycle', + accessorFn: (row) => row.entry.lifecycle, + header: 'Lifecycle', + size: 100, + cell: ({ row }) => ( + <Badge variant={getLifecycleVariant(row.original.entry.lifecycle)}> + {row.original.entry.lifecycle} + </Badge> + ), + }, + { + id: 'size', + accessorFn: (row) => row.usage?.sizeBytes ?? 0, + header: ({ column }) => ( + <Button + variant="ghost" + onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} + > + Size + <ArrowUpDown /> + </Button> + ), + size: 90, + cell: ({ row }) => ( + <span className="text-muted-foreground text-xs"> + {row.original.usage ? formatBytes(row.original.usage.sizeBytes) : '—'} + </span> + ), + }, + { + id: 'items', + accessorFn: (row) => row.usage?.itemCount ?? 0, + header: 'Items', + size: 70, + cell: ({ row }) => ( + <span className="text-muted-foreground text-xs"> + {row.original.usage ? String(row.original.usage.itemCount) : '—'} + </span> + ), + }, + { + id: 'maxAgeDays', + header: 'Max Age (days)', + size: 120, + cell: ({ row }) => + row.original.retention.enabled ? ( + <Input + className="h-7 w-20 text-xs" + min={0} + placeholder="∞" + type="number" + value={row.original.retention.maxAgeDays ?? ''} + onChange={(e) => + handleFieldChange(row.original.entry.id, 'maxAgeDays', e.target.value) + } + /> + ) : ( + <span className="text-muted-foreground text-xs">—</span> + ), + }, + { + id: 'maxItems', + header: 'Max Items', + size: 100, + cell: ({ row }) => + row.original.retention.enabled ? ( + <Input + className="h-7 w-20 text-xs" + min={0} + placeholder="∞" + type="number" + value={row.original.retention.maxItems ?? ''} + onChange={(e) => + handleFieldChange(row.original.entry.id, 'maxItems', e.target.value) + } + /> + ) : ( + <span className="text-muted-foreground text-xs">—</span> + ), + }, + { + id: 'actions', + header: '', + size: 50, + cell: ({ row }) => + row.original.entry.canClear ? ( + <Button + disabled={clearingStoreId === row.original.entry.id} + size="icon-xs" + variant="ghost-destructive" + onClick={() => setConfirmClearEntry(row.original.entry)} + > + {clearingStoreId === row.original.entry.id ? ( + <Spinner size="sm" /> + ) : ( + <Trash2 /> + )} + </Button> + ) : null, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps -- handlers use retentionQuery.data which changes + [clearingStoreId, retentionQuery.data], + ); + + const table: Table<StoreRow> = useReactTable({ + data: rows, + columns, + state: { sorting, globalFilter }, + onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: (row, _columnId, filterValue: string) => { + const label = row.original.entry.label.toLowerCase(); + const desc = row.original.entry.description.toLowerCase(); + const filter = filterValue.toLowerCase(); + return label.includes(filter) || desc.includes(filter); + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getRowId: (row) => row.entry.id, + }); + + return { + registry, + usage, + retentionQuery, + runCleanup, + exportData, + importData, + clearingStoreId, + confirmClearEntry, + setConfirmClearEntry, + globalFilter, + setGlobalFilter, + isLoading, + isError, + rows, + columns, + table, + handleRetentionToggle, + handleFieldChange, + handleClearConfirm, + handleExport, + handleImport, + addToast, + formatBytes, + }; +} diff --git a/src/renderer/features/settings/components/TestingSettingsTab/TestingSettingsTab.tsx b/src/renderer/features/settings/components/TestingSettingsTab/TestingSettingsTab.tsx new file mode 100644 index 00000000..00c7aaf4 --- /dev/null +++ b/src/renderer/features/settings/components/TestingSettingsTab/TestingSettingsTab.tsx @@ -0,0 +1,265 @@ +/** + * TestingSettingsTab — Test Suite configuration surface. + * + * Three stacked sections bound to the active project's TestSuiteConfig: + * 1. Test Environment — target URL + test directory + * 2. BrowserView Dimensions — viewport width/height + * 3. Screenshot Capture — capture mode + temp-folder toggle + * + * Writes echo the FULL config object (contract requires a complete shape) + * and are debounced 300ms via the renderer-local useDebounce hook. + */ + +import { Trash2 } from 'lucide-react'; + +import { ConfigEditDialog } from '@renderer/features/test-suite/components/ConfigEditDialog'; +import { cn } from '@renderer/shared/lib/utils'; + +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + Heading, + Input, + Label, + PageContent, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from '@ui'; + +import { useTestingSettingsTab } from './useTestingSettingsTab'; + +import type { ScreenshotMode } from './useTestingSettingsTab'; + +const SCREENSHOT_MODE_OPTIONS: Array<{ value: ScreenshotMode; label: string }> = [ + { value: 'smart', label: 'Smart (nav + forms + assertions)' }, + { value: 'per-click', label: 'Every click' }, + { value: 'per-nav', label: 'Every navigation' }, + { value: 'per-form', label: 'Every form submit' }, + { value: 'per-assertion', label: 'Every assertion' }, + { value: 'manual', label: 'Manual only' }, +]; + +export function TestingSettingsTab() { + const { + activeProjectId, + config, + buffer, + configs, + setActive, + del, + editingId, + setEditingId, + update, + } = useTestingSettingsTab(); + + if (!activeProjectId) { + return ( + <PageContent> + <div className="p-6 text-text-muted">No project selected.</div> + </PageContent> + ); + } + + if (!config || !buffer) { + return ( + <PageContent> + <div className="p-6 text-text-muted"> + No test suite configuration yet. Run setup first in Test Suite. + </div> + </PageContent> + ); + } + + return ( + <PageContent> + <div className="mx-auto max-w-[720px] space-y-6 p-6"> + {/* ── Section 1: Test Environment ───────────────────────────── */} + <Card> + <CardHeader> + <Heading as="h2" className="text-sm uppercase text-text-muted"> + Test Environment + </Heading> + </CardHeader> + <CardContent className="flex flex-col gap-4"> + <div className="flex flex-col gap-2"> + <Label htmlFor="testing-target-url">Default Target URL</Label> + <Input + id="testing-target-url" + placeholder="http://localhost:3000" + type="url" + value={buffer.targetUrl} + onChange={(event) => update('targetUrl', event.target.value)} + /> + </div> + <div className="flex flex-col gap-2"> + <Label htmlFor="testing-test-directory">Test Directory</Label> + <Input + id="testing-test-directory" + type="text" + value={buffer.testDirectory} + onChange={(event) => update('testDirectory', event.target.value)} + /> + </div> + </CardContent> + </Card> + + {/* ── Section 2: BrowserView Dimensions ─────────────────────── */} + <Card> + <CardHeader> + <Heading as="h2" className="text-sm uppercase text-text-muted"> + BrowserView Dimensions + </Heading> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="flex flex-col gap-2"> + <Label htmlFor="testing-viewport-width">Width</Label> + <Input + id="testing-viewport-width" + min={320} + type="number" + value={buffer.viewportWidth} + onChange={(event) => + update('viewportWidth', Number(event.target.value)) + } + /> + </div> + <div className="flex flex-col gap-2"> + <Label htmlFor="testing-viewport-height">Height</Label> + <Input + id="testing-viewport-height" + min={240} + type="number" + value={buffer.viewportHeight} + onChange={(event) => + update('viewportHeight', Number(event.target.value)) + } + /> + </div> + </div> + </CardContent> + </Card> + + {/* ── Section 3: Screenshot Capture ─────────────────────────── */} + <Card> + <CardHeader> + <Heading as="h2" className="text-sm uppercase text-text-muted"> + Screenshot Capture + </Heading> + </CardHeader> + <CardContent className="flex flex-col gap-4"> + <div className="flex flex-col gap-2"> + <Label htmlFor="testing-screenshot-mode">Default Mode</Label> + <Select + value={buffer.screenshotMode} + onValueChange={(value) => + update('screenshotMode', value as ScreenshotMode) + } + > + <SelectTrigger aria-label="Screenshot mode" id="testing-screenshot-mode"> + <SelectValue placeholder="Select a mode" /> + </SelectTrigger> + <SelectContent> + {SCREENSHOT_MODE_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="flex items-center justify-between"> + <Label htmlFor="testing-save-temp">Save to temp folder</Label> + <Switch + checked={buffer.saveScreenshotsToTemp} + id="testing-save-temp" + onCheckedChange={(checked) => update('saveScreenshotsToTemp', checked)} + /> + </div> + </CardContent> + </Card> + + {/* ── Section 4: Saved Configurations ───────────────────────── */} + <Card> + <CardHeader> + <Heading as="h2" className="text-sm uppercase text-text-muted"> + Saved Configurations + </Heading> + </CardHeader> + <CardContent> + <div className="space-y-3"> + {(configs ?? []).map((c) => ( + <Card + key={c.id} + className={cn( + 'flex items-center gap-3 p-3', + c.isActive && 'border-accent', + )} + > + <div className="flex flex-1 flex-col gap-1"> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">{c.name}</span> + {c.isActive ? <Badge>Active</Badge> : null} + </div> + <span className="text-text-muted text-xs"> + {c.targetUrl} · {c.viewportWidth}×{c.viewportHeight} ·{' '} + {c.screenshotMode} + </span> + </div> + {c.isActive ? null : ( + <Button + size="sm" + variant="ghost" + onClick={() => setActive.mutate(c.id)} + > + Use + </Button> + )} + <Button + size="sm" + variant="ghost" + onClick={() => setEditingId(c.id)} + > + Edit + </Button> + <Button + aria-label="Delete" + disabled={c.isActive} + size="sm" + variant="ghost" + onClick={() => del.mutate(c.id)} + > + <Trash2 className="h-4 w-4" /> + </Button> + </Card> + ))} + <Button variant="ghost" onClick={() => setEditingId('new')}> + + Add Configuration + </Button> + </div> + </CardContent> + </Card> + + <ConfigEditDialog + open={editingId !== null} + projectId={activeProjectId} + config={ + editingId && editingId !== 'new' + ? (configs ?? []).find((c) => c.id === editingId) ?? null + : null + } + onOpenChange={(o) => { + if (!o) setEditingId(null); + }} + /> + </div> + </PageContent> + ); +} diff --git a/src/renderer/features/settings/components/TestingSettingsTab/index.ts b/src/renderer/features/settings/components/TestingSettingsTab/index.ts new file mode 100644 index 00000000..b7eb5246 --- /dev/null +++ b/src/renderer/features/settings/components/TestingSettingsTab/index.ts @@ -0,0 +1,2 @@ +export { TestingSettingsTab } from './TestingSettingsTab'; +export { useTestingSettingsTab } from './useTestingSettingsTab'; diff --git a/src/renderer/features/settings/components/TestingSettingsTab/useTestingSettingsTab.ts b/src/renderer/features/settings/components/TestingSettingsTab/useTestingSettingsTab.ts new file mode 100644 index 00000000..aebac209 --- /dev/null +++ b/src/renderer/features/settings/components/TestingSettingsTab/useTestingSettingsTab.ts @@ -0,0 +1,99 @@ +/** + * useTestingSettingsTab — logic hook for TestingSettingsTab + */ + +import { useEffect, useState } from 'react'; + +import type { TestSuiteConfig } from '@shared/ipc/test-suite'; + +import { useDeleteTestSuiteConfig } from '@renderer/features/test-suite/api/useDeleteTestSuiteConfig'; +import { useSaveTestSuiteConfig } from '@renderer/features/test-suite/api/useSaveTestSuiteConfig'; +import { useSetActiveTestSuiteConfig } from '@renderer/features/test-suite/api/useSetActiveTestSuiteConfig'; +import { useTestSuiteConfig } from '@renderer/features/test-suite/api/useTestSuiteConfig'; +import { useTestSuiteConfigs } from '@renderer/features/test-suite/api/useTestSuiteConfigs'; +import { useDebounce } from '@renderer/shared/hooks/useDebounce'; +import { useLayoutStore } from '@renderer/shared/stores'; + +export type ScreenshotMode = TestSuiteConfig['screenshotMode']; + +export interface EditableFields { + targetUrl: string; + testDirectory: string; + viewportWidth: number; + viewportHeight: number; + screenshotMode: ScreenshotMode; + saveScreenshotsToTemp: boolean; +} + +function toBuffer(config: TestSuiteConfig): EditableFields { + return { + targetUrl: config.targetUrl, + testDirectory: config.testDirectory, + viewportWidth: config.viewportWidth, + viewportHeight: config.viewportHeight, + screenshotMode: config.screenshotMode, + saveScreenshotsToTemp: config.saveScreenshotsToTemp, + }; +} + +function bufferMatchesConfig(buffer: EditableFields, config: TestSuiteConfig): boolean { + return ( + buffer.targetUrl === config.targetUrl && + buffer.testDirectory === config.testDirectory && + buffer.viewportWidth === config.viewportWidth && + buffer.viewportHeight === config.viewportHeight && + buffer.screenshotMode === config.screenshotMode && + buffer.saveScreenshotsToTemp === config.saveScreenshotsToTemp + ); +} + +export function useTestingSettingsTab() { + const activeProjectId = useLayoutStore((s) => s.activeProjectId); + const { data: config } = useTestSuiteConfig(activeProjectId); + const save = useSaveTestSuiteConfig(activeProjectId ?? ''); + const { data: configs } = useTestSuiteConfigs(activeProjectId ?? ''); + const setActive = useSetActiveTestSuiteConfig(activeProjectId ?? ''); + const del = useDeleteTestSuiteConfig(activeProjectId ?? ''); + const [editingId, setEditingId] = useState<string | null>(null); + + const [buffer, setBuffer] = useState<EditableFields | null>( + config ? toBuffer(config) : null, + ); + + useEffect(() => { + if (!config) return; + setBuffer(toBuffer(config)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config?.id, config?.updatedAt]); + + const debouncedBuffer = useDebounce(buffer, 300); + + useEffect(() => { + if (!debouncedBuffer || !config || !activeProjectId) return; + if (bufferMatchesConfig(debouncedBuffer, config)) return; + + const merged: TestSuiteConfig = { + ...config, + ...debouncedBuffer, + updatedAt: new Date().toISOString(), + }; + save.mutate(merged); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedBuffer, config?.id, config?.updatedAt, activeProjectId]); + + function update<K extends keyof EditableFields>(key: K, value: EditableFields[K]) { + setBuffer((prev) => (prev ? { ...prev, [key]: value } : prev)); + } + + return { + activeProjectId, + config, + buffer, + configs, + setActive, + del, + editingId, + setEditingId, + update, + }; +} diff --git a/src/renderer/features/settings/components/TypographySection.tsx b/src/renderer/features/settings/components/TypographySection.tsx index b1cd1c09..4c0e7c77 100644 --- a/src/renderer/features/settings/components/TypographySection.tsx +++ b/src/renderer/features/settings/components/TypographySection.tsx @@ -8,7 +8,7 @@ import { Check, ChevronDown } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; -import { Button, Input } from '@ui'; +import { Button, Heading, Input } from '@ui'; // ── Constants ─────────────────────────────────────────────── @@ -70,9 +70,9 @@ export function TypographySection({ <> {/* ── Font Family ── */} <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> Font Family - </h2> + </Heading> <div className="relative"> <Button aria-controls={fontListboxId} @@ -126,9 +126,9 @@ export function TypographySection({ {/* ── Font Size ── */} <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> Font Size - </h2> + </Heading> <div className="flex items-center gap-4"> <span className="text-muted-foreground w-10 text-sm">{FONT_SIZE_MIN}px</span> <Input diff --git a/src/renderer/features/settings/components/UiScaleSection.tsx b/src/renderer/features/settings/components/UiScaleSection.tsx index 104346f0..ade72aa7 100644 --- a/src/renderer/features/settings/components/UiScaleSection.tsx +++ b/src/renderer/features/settings/components/UiScaleSection.tsx @@ -2,7 +2,7 @@ * UiScaleSection — UI scale percentage slider */ -import { Input } from '@ui'; +import { Heading, Input } from '@ui'; // ── Constants ─────────────────────────────────────────────── @@ -20,9 +20,9 @@ interface UiScaleSectionProps { export function UiScaleSection({ currentScale, onScaleChange }: UiScaleSectionProps) { return ( <section className="mb-8"> - <h2 className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> + <Heading as="h2" className="text-muted-foreground mb-3 text-sm font-medium tracking-wider uppercase"> UI Scale - </h2> + </Heading> <div className="flex items-center gap-4"> <span className="text-muted-foreground w-10 text-sm">{UI_SCALE_MIN}%</span> <Input diff --git a/src/renderer/features/settings/components/WebhookSettings.tsx b/src/renderer/features/settings/components/WebhookSettings/WebhookSettings.tsx similarity index 76% rename from src/renderer/features/settings/components/WebhookSettings.tsx rename to src/renderer/features/settings/components/WebhookSettings/WebhookSettings.tsx index 3c69ebf0..9fca4203 100644 --- a/src/renderer/features/settings/components/WebhookSettings.tsx +++ b/src/renderer/features/settings/components/WebhookSettings/WebhookSettings.tsx @@ -8,22 +8,19 @@ * Uses TanStack Form + Zod validation for both Slack and GitHub sections. */ -import { useCallback, useState } from 'react'; - import { useForm } from '@tanstack/react-form'; import { z } from 'zod'; import { cn } from '@renderer/shared/lib/utils'; -import { Button, Form, FormInput, Spinner } from '@ui'; +import { Button, Form, FormInput, Heading, Spinner } from '@ui'; -import { useHubStatus } from '../api/useHub'; -import { useUpdateWebhookConfig, useWebhookConfig } from '../api/useWebhookConfig'; +import { CollapsibleInstructions } from '../CollapsibleInstructions'; +import { GitHubSetupInstructions } from '../GitHubSetupInstructions'; +import { SlackSetupInstructions } from '../SlackSetupInstructions'; +import { WebhookUrlDisplay } from '../WebhookUrlDisplay'; -import { CollapsibleInstructions } from './CollapsibleInstructions'; -import { GitHubSetupInstructions } from './GitHubSetupInstructions'; -import { SlackSetupInstructions } from './SlackSetupInstructions'; -import { WebhookUrlDisplay } from './WebhookUrlDisplay'; +import { useWebhookSettings } from './useWebhookSettings'; // ── Schemas ──────────────────────────────────────────────── @@ -165,48 +162,23 @@ function GitHubForm({ isConfigured, onSave }: GitHubFormProps) { // ── Main component ───────────────────────────────────────── export function WebhookSettings() { - // 1. Hooks - const { data: webhookConfig, isLoading } = useWebhookConfig(); - const updateConfig = useUpdateWebhookConfig(); - const { data: hubStatus } = useHubStatus(); - - const [copiedField, setCopiedField] = useState<string | null>(null); - const [showSlackInstructions, setShowSlackInstructions] = useState(false); - const [showGithubInstructions, setShowGithubInstructions] = useState(false); - - // 2. Derived state - const hubUrl = hubStatus?.hubUrl ?? ''; - const hasHubUrl = hubUrl.length > 0; - const slackWebhookUrl = hasHubUrl ? `${hubUrl}/api/webhooks/slack/commands` : ''; - const githubWebhookUrl = hasHubUrl ? `${hubUrl}/api/webhooks/github` : ''; - const isSlackConfigured = webhookConfig?.slack.configured === true; - const isGithubConfigured = webhookConfig?.github.configured === true; - - // 3. Handlers - const handleCopy = useCallback((text: string, field: string) => { - void navigator.clipboard.writeText(text); - setCopiedField(field); - setTimeout(() => { - setCopiedField(null); - }, 2000); - }, []); - - function handleSaveSlack(data: { botToken?: string; signingSecret?: string }) { - updateConfig.mutate({ - slack: { - botToken: data.botToken, - signingSecret: data.signingSecret, - }, - }); - } - - function handleSaveGithub(data: { webhookSecret?: string }) { - updateConfig.mutate({ - github: { webhookSecret: data.webhookSecret }, - }); - } + const { + isLoading, + copiedField, + showSlackInstructions, + setShowSlackInstructions, + showGithubInstructions, + setShowGithubInstructions, + hasHubUrl, + slackWebhookUrl, + githubWebhookUrl, + isSlackConfigured, + isGithubConfigured, + handleCopy, + handleSaveSlack, + handleSaveGithub, + } = useWebhookSettings(); - // 4. Render if (isLoading) { return ( <div className="text-muted-foreground flex items-center gap-2 py-4"> @@ -219,7 +191,7 @@ export function WebhookSettings() { return ( <div className="space-y-6"> <div> - <h3 className="text-foreground text-lg font-semibold">Assistant & Webhooks</h3> + <Heading as="h3">Assistant & Webhooks</Heading> <p className="text-muted-foreground text-sm"> Configure Slack and GitHub integrations for external task creation. </p> @@ -227,7 +199,7 @@ export function WebhookSettings() { {/* Slack Integration */} <div className="border-border rounded-lg border p-4"> - <h4 className="text-foreground mb-3 text-sm font-semibold">Slack Integration</h4> + <Heading as="h4" className="mb-3 text-sm">Slack Integration</Heading> <div className="flex items-center gap-2"> <span className={cn( @@ -264,7 +236,7 @@ export function WebhookSettings() { </div> ) : ( <p className="text-muted-foreground mt-3 text-xs"> - Connect to a Hub server to get your webhook URL. + Webhook URL relay unavailable — coming in a future release. </p> )} </div> @@ -272,7 +244,7 @@ export function WebhookSettings() { {/* GitHub Integration */} <div className="border-border rounded-lg border p-4"> - <h4 className="text-foreground mb-3 text-sm font-semibold">GitHub Integration</h4> + <Heading as="h4" className="mb-3 text-sm">GitHub Integration</Heading> <div className="flex items-center gap-2"> <span className={cn( @@ -309,7 +281,7 @@ export function WebhookSettings() { </div> ) : ( <p className="text-muted-foreground mt-3 text-xs"> - Connect to a Hub server to get your webhook URL. + Webhook URL relay unavailable — coming in a future release. </p> )} </div> diff --git a/src/renderer/features/settings/components/WebhookSettings/index.ts b/src/renderer/features/settings/components/WebhookSettings/index.ts new file mode 100644 index 00000000..5e4961ea --- /dev/null +++ b/src/renderer/features/settings/components/WebhookSettings/index.ts @@ -0,0 +1,2 @@ +export { WebhookSettings } from './WebhookSettings'; +export { useWebhookSettings } from './useWebhookSettings'; diff --git a/src/renderer/features/settings/components/WebhookSettings/useWebhookSettings.ts b/src/renderer/features/settings/components/WebhookSettings/useWebhookSettings.ts new file mode 100644 index 00000000..bce6d2ec --- /dev/null +++ b/src/renderer/features/settings/components/WebhookSettings/useWebhookSettings.ts @@ -0,0 +1,65 @@ +/** + * useWebhookSettings — logic hook for WebhookSettings + */ + +import { useCallback, useState } from 'react'; + +import { useUpdateWebhookConfig, useWebhookConfig } from '../../api/useWebhookConfig'; + +export function useWebhookSettings() { + const { data: webhookConfig, isLoading } = useWebhookConfig(); + const updateConfig = useUpdateWebhookConfig(); + + const [copiedField, setCopiedField] = useState<string | null>(null); + const [showSlackInstructions, setShowSlackInstructions] = useState(false); + const [showGithubInstructions, setShowGithubInstructions] = useState(false); + + // Hub-relayed webhooks are gone; URLs unavailable until a local webhook + // receiver replaces them. + // TODO(p2p-future): replace with local receiver URL once a webhook receiver replaces the Hub relay. + const hasHubUrl = false; + const slackWebhookUrl = ''; + const githubWebhookUrl = ''; + const isSlackConfigured = webhookConfig?.slack.configured === true; + const isGithubConfigured = webhookConfig?.github.configured === true; + + const handleCopy = useCallback((text: string, field: string) => { + void navigator.clipboard.writeText(text); + setCopiedField(field); + setTimeout(() => { + setCopiedField(null); + }, 2000); + }, []); + + function handleSaveSlack(data: { botToken?: string; signingSecret?: string }) { + updateConfig.mutate({ + slack: { + botToken: data.botToken, + signingSecret: data.signingSecret, + }, + }); + } + + function handleSaveGithub(data: { webhookSecret?: string }) { + updateConfig.mutate({ + github: { webhookSecret: data.webhookSecret }, + }); + } + + return { + isLoading, + copiedField, + showSlackInstructions, + setShowSlackInstructions, + showGithubInstructions, + setShowGithubInstructions, + hasHubUrl, + slackWebhookUrl, + githubWebhookUrl, + isSlackConfigured, + isGithubConfigured, + handleCopy, + handleSaveSlack, + handleSaveGithub, + }; +} diff --git a/src/renderer/features/settings/components/WorkspaceCard.tsx b/src/renderer/features/settings/components/WorkspaceCard.tsx index 1293ed10..6af4f059 100644 --- a/src/renderer/features/settings/components/WorkspaceCard.tsx +++ b/src/renderer/features/settings/components/WorkspaceCard.tsx @@ -10,10 +10,10 @@ import type { Device, Workspace } from '@shared/types'; import { cn } from '@renderer/shared/lib/utils'; -import { Button } from '@ui'; +import { Button, Heading } from '@ui'; -import { useDeleteWorkspace } from '@features/workspaces'; +import { useDeleteWorkspace } from '@features/workspace'; interface WorkspaceCardProps { workspace: Workspace; @@ -52,7 +52,7 @@ export function WorkspaceCard({ workspace, devices, onEdit }: WorkspaceCardProps isOnline ? 'bg-success' : 'bg-muted-foreground', )} /> - <h3 className="text-sm font-semibold">{workspace.name}</h3> + <Heading as="h3" className="text-sm">{workspace.name}</Heading> </div> <div className="flex items-center gap-1"> <Button diff --git a/src/renderer/features/settings/components/WorkspaceEditor.tsx b/src/renderer/features/settings/components/WorkspaceEditor.tsx deleted file mode 100644 index 90d8684b..00000000 --- a/src/renderer/features/settings/components/WorkspaceEditor.tsx +++ /dev/null @@ -1,190 +0,0 @@ -/** - * WorkspaceEditor — Modal dialog for creating/editing a workspace - */ - -import { useState } from 'react'; - -import { X } from 'lucide-react'; - -import type { Workspace } from '@shared/types'; - -import { Button, Input, Label, Switch, Textarea } from '@ui'; - -import { useCreateWorkspace, useUpdateWorkspace } from '@features/workspaces'; - -import { DeviceSelector } from './DeviceSelector'; - -interface WorkspaceEditorProps { - workspace: Workspace | null; - onClose: () => void; -} - -export function WorkspaceEditor({ workspace, onClose }: WorkspaceEditorProps) { - const isEditing = workspace !== null; - - const [name, setName] = useState(workspace?.name ?? ''); - const [description, setDescription] = useState(workspace?.description ?? ''); - const [hostDeviceId, setHostDeviceId] = useState(workspace?.hostDeviceId); - const [autoStart, setAutoStart] = useState(workspace?.settings.autoStart ?? false); - const [maxConcurrent, setMaxConcurrent] = useState(workspace?.settings.maxConcurrent ?? 2); - const [defaultBranch, setDefaultBranch] = useState(workspace?.settings.defaultBranch ?? 'main'); - - const createWorkspace = useCreateWorkspace(); - const updateWorkspace = useUpdateWorkspace(); - - function handleSave() { - if (name.trim().length === 0) return; - - if (isEditing) { - updateWorkspace.mutate( - { - id: workspace.id, - name: name.trim(), - description: description.trim() || undefined, - hostDeviceId, - settings: { autoStart, maxConcurrent, defaultBranch }, - }, - { onSuccess: onClose }, - ); - } else { - createWorkspace.mutate( - { name: name.trim(), description: description.trim() || undefined }, - { onSuccess: onClose }, - ); - } - } - - const isSaving = createWorkspace.isPending || updateWorkspace.isPending; - - return ( - <div - aria-label={isEditing ? 'Edit workspace' : 'Create workspace'} - aria-modal="true" - className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" - role="dialog" - > - <div className="bg-card border-border mx-4 w-full max-w-md rounded-xl border p-6 shadow-xl"> - {/* Header */} - <div className="mb-5 flex items-center justify-between"> - <h2 className="text-lg font-semibold"> - {isEditing ? 'Edit Workspace' : 'New Workspace'} - </h2> - <Button - aria-label="Close" - size="icon" - variant="ghost" - onClick={onClose} - > - <X className="h-5 w-5" /> - </Button> - </div> - - {/* Form */} - <div className="space-y-4"> - {/* Name */} - <div> - <Label className="mb-1 block" htmlFor="ws-name"> - Name - </Label> - <Input - id="ws-name" - placeholder="Workspace name" - type="text" - value={name} - onChange={(e) => setName(e.target.value)} - /> - </div> - - {/* Description */} - <div> - <Label className="mb-1 block" htmlFor="ws-desc"> - Description - </Label> - <Textarea - id="ws-desc" - placeholder="Optional description" - resize="none" - rows={2} - value={description} - onChange={(e) => setDescription(e.target.value)} - /> - </div> - - {/* Device Selector */} - <div> - <span className="text-muted-foreground mb-1 block text-sm font-medium"> - Host Device - </span> - <DeviceSelector value={hostDeviceId} onChange={setHostDeviceId} /> - </div> - - {/* Settings */} - <div className="border-border rounded-lg border p-3"> - <h3 className="text-muted-foreground mb-3 text-xs font-medium uppercase tracking-wider"> - Settings - </h3> - - {/* Auto-start toggle */} - <div className="mb-3 flex items-center justify-between"> - <Label htmlFor="ws-autostart"> - Auto-start agents - </Label> - <Switch - checked={autoStart} - id="ws-autostart" - onCheckedChange={setAutoStart} - /> - </div> - - {/* Max concurrent */} - <div className="mb-3 flex items-center justify-between"> - <Label htmlFor="ws-concurrent"> - Max concurrent - </Label> - <Input - className="w-16 text-center" - id="ws-concurrent" - max={10} - min={1} - size="sm" - type="number" - value={maxConcurrent} - onChange={(e) => setMaxConcurrent(Number(e.target.value))} - /> - </div> - - {/* Default branch */} - <div className="flex items-center justify-between"> - <Label htmlFor="ws-branch"> - Default branch - </Label> - <Input - className="w-28 text-left" - id="ws-branch" - placeholder="main" - size="sm" - type="text" - value={defaultBranch} - onChange={(e) => setDefaultBranch(e.target.value)} - /> - </div> - </div> - </div> - - {/* Actions */} - <div className="mt-5 flex justify-end gap-3"> - <Button disabled={isSaving} variant="ghost" onClick={onClose}> - Cancel - </Button> - <Button - disabled={name.trim().length === 0 || isSaving} - variant="primary" - onClick={handleSave} - > - {isSaving ? 'Saving...' : 'Save'} - </Button> - </div> - </div> - </div> - ); -} diff --git a/src/renderer/features/settings/components/WorkspaceEditor/WorkspaceEditor.tsx b/src/renderer/features/settings/components/WorkspaceEditor/WorkspaceEditor.tsx new file mode 100644 index 00000000..1fde80ee --- /dev/null +++ b/src/renderer/features/settings/components/WorkspaceEditor/WorkspaceEditor.tsx @@ -0,0 +1,170 @@ +/** + * WorkspaceEditor — Modal dialog for creating/editing a workspace + */ + +import { X } from 'lucide-react'; + +import type { Workspace } from '@shared/types'; + +import { Button, Heading, Input, Label, Switch, Textarea } from '@ui'; + +import { DeviceSelector } from '../DeviceSelector'; + +import { useWorkspaceEditor } from './useWorkspaceEditor'; + +interface WorkspaceEditorProps { + workspace: Workspace | null; + onClose: () => void; +} + +export function WorkspaceEditor({ workspace, onClose }: WorkspaceEditorProps) { + const { + isEditing, + name, + setName, + description, + setDescription, + hostDeviceId, + setHostDeviceId, + autoStart, + setAutoStart, + maxConcurrent, + setMaxConcurrent, + defaultBranch, + setDefaultBranch, + isSaving, + handleSave, + } = useWorkspaceEditor(workspace, onClose); + + return ( + <div + aria-label={isEditing ? 'Edit workspace' : 'Create workspace'} + aria-modal="true" + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" + role="dialog" + > + <div className="bg-card border-border mx-4 w-full max-w-md rounded-xl border p-6 shadow-xl"> + {/* Header */} + <div className="mb-5 flex items-center justify-between"> + <Heading as="h2" className="text-lg"> + {isEditing ? 'Edit Workspace' : 'New Workspace'} + </Heading> + <Button + aria-label="Close" + size="icon" + variant="ghost" + onClick={onClose} + > + <X className="h-5 w-5" /> + </Button> + </div> + + {/* Form */} + <div className="space-y-4"> + {/* Name */} + <div> + <Label className="mb-1 block" htmlFor="ws-name"> + Name + </Label> + <Input + id="ws-name" + placeholder="Workspace name" + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + /> + </div> + + {/* Description */} + <div> + <Label className="mb-1 block" htmlFor="ws-desc"> + Description + </Label> + <Textarea + id="ws-desc" + placeholder="Optional description" + resize="none" + rows={2} + value={description} + onChange={(e) => setDescription(e.target.value)} + /> + </div> + + {/* Device Selector */} + <div> + <span className="text-muted-foreground mb-1 block text-sm font-medium"> + Host Device + </span> + <DeviceSelector value={hostDeviceId} onChange={setHostDeviceId} /> + </div> + + {/* Settings */} + <div className="border-border rounded-lg border p-3"> + <Heading as="h3" className="text-muted-foreground mb-3 text-xs font-medium uppercase tracking-wider"> + Settings + </Heading> + + {/* Auto-start toggle */} + <div className="mb-3 flex items-center justify-between"> + <Label htmlFor="ws-autostart"> + Auto-start agents + </Label> + <Switch + checked={autoStart} + id="ws-autostart" + onCheckedChange={setAutoStart} + /> + </div> + + {/* Max concurrent */} + <div className="mb-3 flex items-center justify-between"> + <Label htmlFor="ws-concurrent"> + Max concurrent + </Label> + <Input + className="w-16 text-center" + id="ws-concurrent" + max={10} + min={1} + size="sm" + type="number" + value={maxConcurrent} + onChange={(e) => setMaxConcurrent(Number(e.target.value))} + /> + </div> + + {/* Default branch */} + <div className="flex items-center justify-between"> + <Label htmlFor="ws-branch"> + Default branch + </Label> + <Input + className="w-28 text-left" + id="ws-branch" + placeholder="main" + size="sm" + type="text" + value={defaultBranch} + onChange={(e) => setDefaultBranch(e.target.value)} + /> + </div> + </div> + </div> + + {/* Actions */} + <div className="mt-5 flex justify-end gap-3"> + <Button disabled={isSaving} variant="ghost" onClick={onClose}> + Cancel + </Button> + <Button + disabled={name.trim().length === 0 || isSaving} + variant="primary" + onClick={handleSave} + > + {isSaving ? 'Saving...' : 'Save'} + </Button> + </div> + </div> + </div> + ); +} diff --git a/src/renderer/features/settings/components/WorkspaceEditor/index.ts b/src/renderer/features/settings/components/WorkspaceEditor/index.ts new file mode 100644 index 00000000..4790ba0e --- /dev/null +++ b/src/renderer/features/settings/components/WorkspaceEditor/index.ts @@ -0,0 +1,2 @@ +export { WorkspaceEditor } from './WorkspaceEditor'; +export { useWorkspaceEditor } from './useWorkspaceEditor'; diff --git a/src/renderer/features/settings/components/WorkspaceEditor/useWorkspaceEditor.ts b/src/renderer/features/settings/components/WorkspaceEditor/useWorkspaceEditor.ts new file mode 100644 index 00000000..0b85f7af --- /dev/null +++ b/src/renderer/features/settings/components/WorkspaceEditor/useWorkspaceEditor.ts @@ -0,0 +1,65 @@ +/** + * useWorkspaceEditor — logic hook for WorkspaceEditor + */ + +import { useState } from 'react'; + +import type { Workspace } from '@shared/types'; + +import { useCreateWorkspace, useUpdateWorkspace } from '@features/workspace'; + +export function useWorkspaceEditor(workspace: Workspace | null, onClose: () => void) { + const isEditing = workspace !== null; + + const [name, setName] = useState(workspace?.name ?? ''); + const [description, setDescription] = useState(workspace?.description ?? ''); + const [hostDeviceId, setHostDeviceId] = useState(workspace?.hostDeviceId); + const [autoStart, setAutoStart] = useState(workspace?.settings.autoStart ?? false); + const [maxConcurrent, setMaxConcurrent] = useState(workspace?.settings.maxConcurrent ?? 2); + const [defaultBranch, setDefaultBranch] = useState(workspace?.settings.defaultBranch ?? 'main'); + + const createWorkspace = useCreateWorkspace(); + const updateWorkspace = useUpdateWorkspace(); + + function handleSave() { + if (name.trim().length === 0) return; + + if (isEditing) { + updateWorkspace.mutate( + { + id: workspace.id, + name: name.trim(), + description: description.trim() || undefined, + hostDeviceId, + settings: { autoStart, maxConcurrent, defaultBranch }, + }, + { onSuccess: onClose }, + ); + } else { + createWorkspace.mutate( + { name: name.trim(), description: description.trim() || undefined }, + { onSuccess: onClose }, + ); + } + } + + const isSaving = createWorkspace.isPending || updateWorkspace.isPending; + + return { + isEditing, + name, + setName, + description, + setDescription, + hostDeviceId, + setHostDeviceId, + autoStart, + setAutoStart, + maxConcurrent, + setMaxConcurrent, + defaultBranch, + setDefaultBranch, + isSaving, + handleSave, + }; +} diff --git a/src/renderer/features/settings/components/WorkspacesTab.tsx b/src/renderer/features/settings/components/WorkspacesTab.tsx deleted file mode 100644 index 2ac40951..00000000 --- a/src/renderer/features/settings/components/WorkspacesTab.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** - * WorkspacesTab — Main panel for managing workspaces in settings - */ - -import { useState } from 'react'; - -import { Plus, Server } from 'lucide-react'; - -import type { Workspace } from '@shared/types'; - -import { Button, Spinner } from '@ui'; - -import { useWorkspaces } from '@features/workspaces'; - -import { useDevices } from '../api/useDevices'; - -import { WorkspaceCard } from './WorkspaceCard'; -import { WorkspaceEditor } from './WorkspaceEditor'; - -export function WorkspacesTab() { - const { data: workspaces, isLoading } = useWorkspaces(); - const { data: devices } = useDevices(); - const [editorOpen, setEditorOpen] = useState(false); - const [editingWorkspace, setEditingWorkspace] = useState<Workspace | null>(null); - - function handleAddWorkspace() { - setEditingWorkspace(null); - setEditorOpen(true); - } - - function handleEditWorkspace(workspace: Workspace) { - setEditingWorkspace(workspace); - setEditorOpen(true); - } - - function handleCloseEditor() { - setEditorOpen(false); - setEditingWorkspace(null); - } - - if (isLoading) { - return ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground" size="sm" /> - </div> - ); - } - - const workspaceList = workspaces ?? []; - const deviceList = devices ?? []; - - return ( - <div> - <div className="mb-3 flex items-center justify-between"> - <h2 className="text-muted-foreground text-sm font-medium tracking-wider uppercase"> - Workspaces - </h2> - <Button size="sm" variant="primary" onClick={handleAddWorkspace}> - <Plus className="h-3.5 w-3.5" /> - Add Workspace - </Button> - </div> - - {workspaceList.length > 0 ? ( - <div className="space-y-3"> - {workspaceList.map((workspace) => ( - <WorkspaceCard - key={workspace.id} - devices={deviceList} - workspace={workspace} - onEdit={handleEditWorkspace} - /> - ))} - </div> - ) : ( - <div className="text-muted-foreground flex flex-col items-center justify-center rounded-lg border border-dashed py-10"> - <Server className="mb-2 h-8 w-8 opacity-40" /> - <p className="text-sm">No workspaces yet</p> - <p className="text-xs opacity-60">Create a workspace to organize your projects</p> - </div> - )} - - {editorOpen ? ( - <WorkspaceEditor workspace={editingWorkspace} onClose={handleCloseEditor} /> - ) : null} - </div> - ); -} diff --git a/src/renderer/features/settings/components/WorkspacesTab/WorkspacesTab.tsx b/src/renderer/features/settings/components/WorkspacesTab/WorkspacesTab.tsx new file mode 100644 index 00000000..9bb986c4 --- /dev/null +++ b/src/renderer/features/settings/components/WorkspacesTab/WorkspacesTab.tsx @@ -0,0 +1,73 @@ +/** + * WorkspacesTab — Main panel for managing workspaces in settings + */ + +import { Plus, Server } from 'lucide-react'; + +import { Button, Heading, Spinner } from '@ui'; + +import { WorkspaceCard } from '../WorkspaceCard'; +import { WorkspaceEditor } from '../WorkspaceEditor'; + +import { useWorkspacesTab } from './useWorkspacesTab'; + +export function WorkspacesTab() { + const { + workspaces, + isLoading, + devices, + editorOpen, + editingWorkspace, + handleAddWorkspace, + handleEditWorkspace, + handleCloseEditor, + } = useWorkspacesTab(); + + if (isLoading) { + return ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground" size="sm" /> + </div> + ); + } + + const workspaceList = workspaces ?? []; + const deviceList = devices ?? []; + + return ( + <div> + <div className="mb-3 flex items-center justify-between"> + <Heading as="h2" className="text-muted-foreground text-sm font-medium tracking-wider uppercase"> + Workspaces + </Heading> + <Button size="sm" variant="primary" onClick={handleAddWorkspace}> + <Plus className="h-3.5 w-3.5" /> + Add Workspace + </Button> + </div> + + {workspaceList.length > 0 ? ( + <div className="space-y-3"> + {workspaceList.map((workspace) => ( + <WorkspaceCard + key={workspace.id} + devices={deviceList} + workspace={workspace} + onEdit={handleEditWorkspace} + /> + ))} + </div> + ) : ( + <div className="text-muted-foreground flex flex-col items-center justify-center rounded-lg border border-dashed py-10"> + <Server className="mb-2 h-8 w-8 opacity-40" /> + <p className="text-sm">No workspaces yet</p> + <p className="text-xs opacity-60">Create a workspace to organize your projects</p> + </div> + )} + + {editorOpen ? ( + <WorkspaceEditor workspace={editingWorkspace} onClose={handleCloseEditor} /> + ) : null} + </div> + ); +} diff --git a/src/renderer/features/settings/components/WorkspacesTab/index.ts b/src/renderer/features/settings/components/WorkspacesTab/index.ts new file mode 100644 index 00000000..45aef8cc --- /dev/null +++ b/src/renderer/features/settings/components/WorkspacesTab/index.ts @@ -0,0 +1,2 @@ +export { WorkspacesTab } from './WorkspacesTab'; +export { useWorkspacesTab } from './useWorkspacesTab'; diff --git a/src/renderer/features/settings/components/WorkspacesTab/useWorkspacesTab.ts b/src/renderer/features/settings/components/WorkspacesTab/useWorkspacesTab.ts new file mode 100644 index 00000000..c59f88dd --- /dev/null +++ b/src/renderer/features/settings/components/WorkspacesTab/useWorkspacesTab.ts @@ -0,0 +1,35 @@ +/** + * useWorkspacesTab — logic hook for WorkspacesTab + */ + +import type { Workspace } from '@shared/types'; + +import { useModalWithEditState } from '@renderer/shared/hooks/useModalWithEditState'; + +import { useWorkspaces } from '@features/workspace'; + +import { useDevices } from '../../api/useDevices'; + +export function useWorkspacesTab() { + const { data: workspaces, isLoading } = useWorkspaces(); + const { data: devices } = useDevices(); + + const { + open: editorOpen, + editing: editingWorkspace, + handleAdd: handleAddWorkspace, + handleEdit: handleEditWorkspace, + handleClose: handleCloseEditor, + } = useModalWithEditState<Workspace>(); + + return { + workspaces, + isLoading, + devices, + editorOpen, + editingWorkspace, + handleAddWorkspace, + handleEditWorkspace, + handleCloseEditor, + }; +} diff --git a/src/renderer/features/settings/components/health/HealthPanel.tsx b/src/renderer/features/settings/components/health/HealthPanel.tsx index afa5b14f..e2526f10 100644 --- a/src/renderer/features/settings/components/health/HealthPanel.tsx +++ b/src/renderer/features/settings/components/health/HealthPanel.tsx @@ -23,7 +23,7 @@ import type { ErrorEntry, ErrorSeverity } from '@shared/types'; import { ConfirmDialog } from '@renderer/shared/components/ConfirmDialog'; import { cn, formatRelativeTime } from '@renderer/shared/lib/utils'; -import { Badge, Button, ScrollArea, Tabs, TabsContent, TabsList, TabsTrigger } from '@ui'; +import { Badge, Button, Heading, ScrollArea, Separator, Tabs, TabsContent, TabsList, TabsTrigger } from '@ui'; import { useClearErrorLog, @@ -138,7 +138,9 @@ function ErrorLogEntry({ entry }: { entry: ErrorEntry }) { </div> {isExpanded ? ( - <div className="bg-muted/30 border-border border-t px-3 py-2"> + <> + <Separator /> + <div className="bg-muted/30 px-3 py-2"> <div className="space-y-1 text-[10px]"> {entry.context.route ? ( <p className="text-muted-foreground"> @@ -179,7 +181,8 @@ function ErrorLogEntry({ entry }: { entry: ErrorEntry }) { </> )} </Button> - </div> + </div> + </> ) : null} </div> ); @@ -274,9 +277,9 @@ export function HealthPanel({ isOpen, onClose }: HealthPanelProps) { > {/* Header */} <div className="border-border flex items-center justify-between border-b px-4 py-3"> - <h3 className="text-foreground text-sm font-semibold"> + <Heading as="h3" className="text-sm"> System Health - </h3> + </Heading> <Button aria-label="Close health panel" size="icon" @@ -291,9 +294,9 @@ export function HealthPanel({ isOpen, onClose }: HealthPanelProps) { {/* Service Health Table */} {services.length > 0 ? ( <div className="border-border border-b px-4 py-3"> - <h4 className="text-muted-foreground mb-2 text-[10px] font-semibold uppercase tracking-wide"> + <Heading as="h4" className="text-muted-foreground mb-2 text-[10px] uppercase tracking-wide"> Services - </h4> + </Heading> <div className="space-y-1.5"> {services.map((svc) => { const statusConfig = SERVICE_STATUS_CONFIG[svc.status]; @@ -375,7 +378,8 @@ export function HealthPanel({ isOpen, onClose }: HealthPanelProps) { </Tabs> {/* Footer Actions */} - <div className="border-border flex items-center justify-end gap-2 border-t px-4 py-2"> + <Separator /> + <div className="flex items-center justify-end gap-2 px-4 py-2"> <Button className="h-auto gap-1 px-2 py-1 text-xs" type="button" diff --git a/src/renderer/features/settings/components/screen/ScreenshotViewer.tsx b/src/renderer/features/settings/components/screen/ScreenshotViewer.tsx index 5f549651..0283a398 100644 --- a/src/renderer/features/settings/components/screen/ScreenshotViewer.tsx +++ b/src/renderer/features/settings/components/screen/ScreenshotViewer.tsx @@ -10,7 +10,7 @@ import type { Screenshot } from '@shared/types'; import { cn } from '@renderer/shared/lib/utils'; -import { Button } from '@ui'; +import { Button, Heading } from '@ui'; interface ScreenshotViewerProps { screenshot: Screenshot | null; @@ -134,7 +134,7 @@ export function ScreenshotViewer({ screenshot, onClose, className }: ScreenshotV {/* Header */} <div className="border-border flex items-center justify-between border-b px-4 py-2"> <div className="min-w-0 flex-1"> - <h3 className="text-foreground truncate text-sm font-medium">{screenshot.source.name}</h3> + <Heading as="h3" className="truncate text-sm font-medium">{screenshot.source.name}</Heading> <p className="text-muted-foreground text-xs"> {screenshot.width} x {screenshot.height} - {formattedTime} </p> diff --git a/src/renderer/features/settings/components/theme-editor/ColorSection.tsx b/src/renderer/features/settings/components/theme-editor/ColorSection.tsx index c71436b0..a5a667f0 100644 --- a/src/renderer/features/settings/components/theme-editor/ColorSection.tsx +++ b/src/renderer/features/settings/components/theme-editor/ColorSection.tsx @@ -4,7 +4,7 @@ import type { ThemeTokens } from '@shared/types'; -import { Separator } from '@ui'; +import { Heading, Separator } from '@ui'; import { ColorControl } from './ColorControl'; @@ -23,9 +23,9 @@ interface ColorSectionProps { export function ColorSection({ title, tokens, values, onChange }: ColorSectionProps) { return ( <div className="mb-6"> - <h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase"> + <Heading as="h3" className="text-muted-foreground mb-2 text-xs tracking-wider uppercase"> {title} - </h3> + </Heading> <Separator className="mb-3" /> <div className="grid grid-cols-2 gap-3"> {tokens.map((token) => ( diff --git a/src/renderer/features/settings/components/theme-editor/ThemeEditorPage.tsx b/src/renderer/features/settings/components/theme-editor/ThemeEditorPage.tsx index d17bfcbf..bda18134 100644 --- a/src/renderer/features/settings/components/theme-editor/ThemeEditorPage.tsx +++ b/src/renderer/features/settings/components/theme-editor/ThemeEditorPage.tsx @@ -23,6 +23,7 @@ import { DialogFooter, DialogHeader, DialogTitle, + Heading, Input, Label, PageHeader, @@ -174,7 +175,7 @@ export function ThemeEditorPage() { <Button aria-label="Back to settings" size="icon" variant="ghost" onClick={handleBack}> <ArrowLeft className="h-4 w-4" /> </Button> - <h1 className="text-foreground text-lg font-semibold">Theme Editor</h1> + <Heading as="h1" className="text-lg">Theme Editor</Heading> </div> <div className="flex items-center gap-2"> diff --git a/src/renderer/features/settings/hooks/useAsyncOperation.ts b/src/renderer/features/settings/hooks/useAsyncOperation.ts new file mode 100644 index 00000000..4902c563 --- /dev/null +++ b/src/renderer/features/settings/hooks/useAsyncOperation.ts @@ -0,0 +1,24 @@ +/** + * useAsyncOperation — reusable error-state wrapper for async calls + * + * Replaces the repeated useState<string | null> + try/catch + setError + * pattern found in settings sub-panels and similar forms. + */ + +import { useCallback, useState } from 'react'; + +export function useAsyncOperation() { + const [error, setError] = useState<string | null>(null); + + const execute = useCallback(async <T>(fn: () => Promise<T>): Promise<T | null> => { + setError(null); + try { + return await fn(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return null; + } + }, []); + + return { error, setError, execute }; +} diff --git a/src/renderer/features/settings/hooks/useBooleanSettingsSync.ts b/src/renderer/features/settings/hooks/useBooleanSettingsSync.ts new file mode 100644 index 00000000..b8b28b57 --- /dev/null +++ b/src/renderer/features/settings/hooks/useBooleanSettingsSync.ts @@ -0,0 +1,44 @@ +/** + * useBooleanSettingsSync — local boolean state synced from server settings + * + * Maintains local state for each field so the UI is optimistic, + * while syncing from the query data when it changes on the server side. + */ + +import { useEffect, useState } from 'react'; + +export function useBooleanSettingsSync<K extends string>( + fields: readonly K[], + source: Partial<Record<K, boolean>> | undefined, + defaults: Record<K, boolean>, +): Record<K, boolean> & { setField: (key: K, value: boolean) => void } { + const [state, setState] = useState<Record<K, boolean>>(() => { + const initial = {} as Record<K, boolean>; + for (const key of fields) { + initial[key] = defaults[key]; + } + return initial; + }); + + useEffect(() => { + if (!source) return; + setState((prev) => { + const next = { ...prev }; + let changed = false; + for (const key of fields) { + const value = source[key] ?? defaults[key]; + if (next[key] !== value) { + next[key] = value; + changed = true; + } + } + return changed ? next : prev; + }); + }, [source, fields, defaults]); + + const setField = (key: K, value: boolean) => { + setState((prev) => ({ ...prev, [key]: value })); + }; + + return { ...state, setField } as Record<K, boolean> & { setField: (key: K, value: boolean) => void }; +} diff --git a/src/renderer/features/settings/hooks/useDeviceEvents.ts b/src/renderer/features/settings/hooks/useDeviceEvents.ts deleted file mode 100644 index c9295910..00000000 --- a/src/renderer/features/settings/hooks/useDeviceEvents.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Device IPC event listeners → query invalidation - * - * Bridges Hub WebSocket events to React Query cache for devices. - */ - -import { useQueryClient } from '@tanstack/react-query'; - -import { HUB_EVENTS } from '@shared/ipc/hub/channels'; - -import { useHubEvent } from '@renderer/shared/hooks'; - -import { deviceKeys } from '../api/deviceQueryKeys'; - -/** Subscribe to hub device events and invalidate queries */ -export function useDeviceEvents() { - const queryClient = useQueryClient(); - - useHubEvent(HUB_EVENTS.DEVICE.ONLINE, () => { - void queryClient.invalidateQueries({ queryKey: deviceKeys.list() }); - }); - - useHubEvent(HUB_EVENTS.DEVICE.OFFLINE, () => { - void queryClient.invalidateQueries({ queryKey: deviceKeys.list() }); - }); -} diff --git a/src/renderer/features/settings/index.ts b/src/renderer/features/settings/index.ts index e99bf361..b04ad97c 100644 --- a/src/renderer/features/settings/index.ts +++ b/src/renderer/features/settings/index.ts @@ -42,13 +42,11 @@ export { useUpdateWorkspace, useDeleteWorkspace, workspaceKeys, - useWorkspaceEvents, -} from '@features/workspaces'; +} from '@features/workspace'; // Devices (absorbed from @features/devices) export { useDevices, useRegisterDevice, useUpdateDevice } from './api/useDevices'; export { deviceKeys } from './api/deviceQueryKeys'; -export { useDeviceEvents } from './hooks/useDeviceEvents'; // Screen capture (absorbed from @features/screen) export { useAvailableSources, useCaptureScreen, useScreenPermission } from './api/useScreenCapture'; diff --git a/src/renderer/features/settings/store.ts b/src/renderer/features/settings/store.ts deleted file mode 100644 index c637bdf3..00000000 --- a/src/renderer/features/settings/store.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { create } from 'zustand'; - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Scaffold placeholder -interface SettingsState {} - -export const useSettingsStore = create<SettingsState>()(() => ({})); diff --git a/src/renderer/features/tasks/api/progressKeys.ts b/src/renderer/features/tasks/api/progressKeys.ts index 8b4aa8f3..59df4670 100644 --- a/src/renderer/features/tasks/api/progressKeys.ts +++ b/src/renderer/features/tasks/api/progressKeys.ts @@ -7,7 +7,8 @@ */ export const progressKeys = { all: ['progress'] as const, - list: () => [...progressKeys.all, 'list'] as const, + lists: () => [...progressKeys.all, 'list'] as const, + list: (projectId?: string) => [...progressKeys.all, 'list', projectId ?? 'all'] as const, detail: (slug: string) => [...progressKeys.all, 'detail', slug] as const, archived: () => [...progressKeys.all, 'archived'] as const, sessions: () => [...progressKeys.all, 'sessions'] as const, diff --git a/src/renderer/features/tasks/api/useProgress.ts b/src/renderer/features/tasks/api/useProgress.ts index 1fe5e25e..05133ab6 100644 --- a/src/renderer/features/tasks/api/useProgress.ts +++ b/src/renderer/features/tasks/api/useProgress.ts @@ -14,12 +14,12 @@ import { ipc } from '@renderer/shared/lib/ipc'; import { progressKeys } from './progressKeys'; -/** Fetch all non-archived progress tasks */ -export function useProgressTasks() { +/** Fetch non-archived progress tasks, optionally scoped to a project */ +export function useProgressTasks(projectId?: string) { return useQuery({ - queryKey: progressKeys.list(), + queryKey: progressKeys.list(projectId), queryFn: async () => { - const result = await ipc(PROGRESS.LIST.TASKS, {}); + const result = await ipc(PROGRESS.LIST.TASKS, { projectId }); return result as ProgressTask[]; }, staleTime: 30_000, diff --git a/src/renderer/features/tasks/api/useProgressMutations.ts b/src/renderer/features/tasks/api/useProgressMutations.ts index 0ee48349..cfc539b9 100644 --- a/src/renderer/features/tasks/api/useProgressMutations.ts +++ b/src/renderer/features/tasks/api/useProgressMutations.ts @@ -32,6 +32,7 @@ interface CreateProgressTaskInput { priority?: ProgressPriority; slug?: string; id?: string; + projectId?: string; } /** Create a new progress task */ @@ -46,7 +47,7 @@ export function useCreateProgressTask() { return ipc(PROGRESS.CREATE.TASK, { ...data, id, slug }); }, onSuccess() { - void queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + void queryClient.invalidateQueries({ queryKey: progressKeys.lists() }); }, onError(err) { toastOnError('create progress task')(err); @@ -81,7 +82,7 @@ export function useUpdateProgressTask() { }; }) => ipc(PROGRESS.UPDATE.TASK, data), onSettled: (_data, _error, variables) => { - void queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + void queryClient.invalidateQueries({ queryKey: progressKeys.lists() }); void queryClient.invalidateQueries({ queryKey: progressKeys.detail(variables.slug), }); @@ -97,7 +98,7 @@ export function useArchiveProgressTask() { return useMutation({ mutationFn: (data: { slug: string }) => ipc(PROGRESS.ARCHIVE.TASK, data), onSuccess() { - void queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + void queryClient.invalidateQueries({ queryKey: progressKeys.lists() }); void queryClient.invalidateQueries({ queryKey: progressKeys.archived() }); }, onError: onError('archive progress task'), @@ -111,7 +112,7 @@ export function useDeleteProgressTask() { return useMutation({ mutationFn: (data: { slug: string }) => ipc(PROGRESS.DELETE.TASK, data), onSuccess() { - void queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + void queryClient.invalidateQueries({ queryKey: progressKeys.lists() }); }, onError: onError('delete progress task'), }); @@ -125,7 +126,7 @@ export function useStartResearch() { mutationFn: (data: { slug: string; prompt?: string }) => ipc(PROGRESS.START.RESEARCH, data), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + void queryClient.invalidateQueries({ queryKey: progressKeys.lists() }); }, onError: onError('start research'), }); @@ -139,7 +140,7 @@ export function useCreatePlan() { mutationFn: (data: { slug: string; prompt?: string }) => ipc(PROGRESS.CREATE.PLAN, data), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + void queryClient.invalidateQueries({ queryKey: progressKeys.lists() }); }, onError: onError('create plan'), }); @@ -153,7 +154,7 @@ export function useSpinUpTeam() { mutationFn: (data: { slug: string; prompt?: string }) => ipc(PROGRESS.START.TEAM, data), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + void queryClient.invalidateQueries({ queryKey: progressKeys.lists() }); }, onError: onError('spin up team'), }); @@ -167,7 +168,7 @@ export function useRunWorkflow() { mutationFn: (data: { slug: string; templateId?: string }) => ipc(PROGRESS.START.WORKFLOW, data), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + void queryClient.invalidateQueries({ queryKey: progressKeys.lists() }); }, onError: onError('run workflow'), }); @@ -180,7 +181,7 @@ export function useCancelAction() { return useMutation({ mutationFn: (data: { slug: string }) => ipc(PROGRESS.CANCEL.ACTION, data), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + void queryClient.invalidateQueries({ queryKey: progressKeys.lists() }); void queryClient.invalidateQueries({ queryKey: progressKeys.sessions() }); }, onError: onError('cancel action'), diff --git a/src/renderer/features/tasks/api/useTaskMutations.ts b/src/renderer/features/tasks/api/useTaskMutations.ts deleted file mode 100644 index 288b9192..00000000 --- a/src/renderer/features/tasks/api/useTaskMutations.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Task mutation hooks — routed through Hub API - * - * No optimistic updates — WebSocket events trigger cache invalidation. - */ - -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import { HUB_TASKS } from '@shared/ipc/tasks/channels'; - -import { useMutationErrorToast } from '@renderer/shared/hooks'; -import { ipc } from '@renderer/shared/lib/ipc'; - -import { taskKeys } from './queryKeys'; - -/** Update a task's status via Hub */ -export function useUpdateTaskStatus() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: ({ taskId, status }: { taskId: string; status: string }) => - ipc(HUB_TASKS.UPDATE.STATUS, { - taskId, - status: status as 'backlog' | 'queued' | 'running' | 'paused' | 'review' | 'done' | 'error', - }), - onSettled: () => { - void queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }, - onError: onError('update task status'), - }); -} - -/** Delete a task via Hub */ -export function useDeleteTask() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: ({ taskId, projectId: _projectId }: { taskId: string; projectId?: string }) => - ipc(HUB_TASKS.DELETE.TASK, { taskId }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }, - onError: onError('delete task'), - }); -} - -/** Execute a task via Hub */ -export function useExecuteTask() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: ({ taskId, projectId: _projectId }: { taskId: string; projectId?: string }) => - ipc(HUB_TASKS.EXECUTE.TASK, { taskId }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }, - onError: onError('execute task'), - }); -} - -/** Cancel a task via Hub */ -export function useCancelTask() { - const queryClient = useQueryClient(); - const { onError } = useMutationErrorToast(); - return useMutation({ - mutationFn: ({ taskId, reason }: { taskId: string; reason?: string }) => - ipc(HUB_TASKS.CANCEL.TASK, { taskId, reason }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }, - onError: onError('cancel task'), - }); -} diff --git a/src/renderer/features/tasks/api/useTasks.ts b/src/renderer/features/tasks/api/useTasks.ts deleted file mode 100644 index 07847db8..00000000 --- a/src/renderer/features/tasks/api/useTasks.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * React Query hooks for task operations - * - * Routes through Hub API channels for multi-device sync. - */ - -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; - -import { HUB_TASKS } from '@shared/ipc/tasks/channels'; -import type { Task } from '@shared/types'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { taskKeys } from './queryKeys'; - -/** Fetch all tasks for a project via Hub */ -export function useTasks(projectId: string | null) { - return useQuery({ - queryKey: taskKeys.list(projectId ?? ''), - queryFn: async () => { - const result = await ipc(HUB_TASKS.LIST.ALL, { projectId: projectId ?? '' }); - return result.tasks as Task[]; - }, - enabled: projectId !== null, - staleTime: 30_000, - }); -} - -/** Fetch a single task via Hub */ -export function useTask(taskId: string | null) { - return useQuery({ - queryKey: taskKeys.detail(taskId ?? ''), - queryFn: async () => { - const result = await ipc(HUB_TASKS.GET.TASK, { taskId: taskId ?? '' }); - return result as Task; - }, - enabled: taskId !== null, - staleTime: 10_000, - }); -} - -/** Fetch all tasks across all projects via Hub */ -export function useAllTasks() { - return useQuery({ - queryKey: taskKeys.lists(), - queryFn: async () => { - const result = await ipc(HUB_TASKS.LIST.ALL, {}); - return result.tasks as Task[]; - }, - staleTime: 30_000, - }); -} - -/** Create a new task via Hub */ -export function useCreateTask() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: { - projectId: string; - title: string; - description?: string; - priority?: 'low' | 'normal' | 'high' | 'urgent'; - }) => ipc(HUB_TASKS.CREATE.TASK, data), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }, - }); -} diff --git a/src/renderer/features/tasks/components/BulkActionBar.tsx b/src/renderer/features/tasks/components/BulkActionBar/BulkActionBar.tsx similarity index 92% rename from src/renderer/features/tasks/components/BulkActionBar.tsx rename to src/renderer/features/tasks/components/BulkActionBar/BulkActionBar.tsx index afb6dfd6..45ebf8c8 100644 --- a/src/renderer/features/tasks/components/BulkActionBar.tsx +++ b/src/renderer/features/tasks/components/BulkActionBar/BulkActionBar.tsx @@ -4,8 +4,6 @@ * Destructive actions (Archive, Delete) require AlertDialog confirmation. */ -import { useState } from 'react'; - import { Trash2, Archive, X } from 'lucide-react'; import type { ProgressPriority, ProgressStatus } from '@shared/types/progress'; @@ -31,6 +29,8 @@ import { Text, } from '@ui'; +import { useBulkActionBar } from './useBulkActionBar'; + const BULK_STATUSES: ProgressStatus[] = [ 'backlog', 'researching', @@ -83,8 +83,16 @@ export function BulkActionBar({ onChangeStatus, onChangePriority, }: BulkActionBarProps) { - const [archiveOpen, setArchiveOpen] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); + const { + archiveOpen, + setArchiveOpen, + deleteOpen, + setDeleteOpen, + handleArchiveConfirm, + handleDeleteConfirm, + handleStatusChange, + handlePriorityChange, + } = useBulkActionBar({ onArchive, onDelete, onChangeStatus, onChangePriority }); const count = selected.size; const hasSelection = count > 0; @@ -93,24 +101,6 @@ export function BulkActionBar({ return null; } - async function handleArchiveConfirm() { - await onArchive(); - setArchiveOpen(false); - } - - async function handleDeleteConfirm() { - await onDelete(); - setDeleteOpen(false); - } - - function handleStatusChange(value: string) { - void onChangeStatus(value as ProgressStatus); - } - - function handlePriorityChange(value: string) { - void onChangePriority(value as ProgressPriority); - } - return ( <div className="border-border bg-card flex items-center gap-3 border-t px-4 py-2"> {/* Selection count */} diff --git a/src/renderer/features/tasks/components/BulkActionBar/index.ts b/src/renderer/features/tasks/components/BulkActionBar/index.ts new file mode 100644 index 00000000..d177e1ff --- /dev/null +++ b/src/renderer/features/tasks/components/BulkActionBar/index.ts @@ -0,0 +1 @@ +export { BulkActionBar } from './BulkActionBar'; diff --git a/src/renderer/features/tasks/components/BulkActionBar/useBulkActionBar.ts b/src/renderer/features/tasks/components/BulkActionBar/useBulkActionBar.ts new file mode 100644 index 00000000..777c0f54 --- /dev/null +++ b/src/renderer/features/tasks/components/BulkActionBar/useBulkActionBar.ts @@ -0,0 +1,59 @@ +/** + * useBulkActionBar — logic for BulkActionBar confirmation dialogs and handlers + */ + +import { useCallback, useState } from 'react'; + +import type { ProgressPriority, ProgressStatus } from '@shared/types/progress'; + +interface UseBulkActionBarParams { + onArchive: () => Promise<void>; + onDelete: () => Promise<void>; + onChangeStatus: (status: ProgressStatus) => Promise<void>; + onChangePriority: (priority: ProgressPriority) => Promise<void>; +} + +export function useBulkActionBar({ + onArchive, + onDelete, + onChangeStatus, + onChangePriority, +}: UseBulkActionBarParams) { + const [archiveOpen, setArchiveOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const handleArchiveConfirm = useCallback(async () => { + await onArchive(); + setArchiveOpen(false); + }, [onArchive]); + + const handleDeleteConfirm = useCallback(async () => { + await onDelete(); + setDeleteOpen(false); + }, [onDelete]); + + const handleStatusChange = useCallback( + (value: string) => { + void onChangeStatus(value as ProgressStatus); + }, + [onChangeStatus], + ); + + const handlePriorityChange = useCallback( + (value: string) => { + void onChangePriority(value as ProgressPriority); + }, + [onChangePriority], + ); + + return { + archiveOpen, + setArchiveOpen, + deleteOpen, + setDeleteOpen, + handleArchiveConfirm, + handleDeleteConfirm, + handleStatusChange, + handlePriorityChange, + }; +} diff --git a/src/renderer/features/tasks/components/CreatePrDialog.tsx b/src/renderer/features/tasks/components/CreatePrDialog.tsx deleted file mode 100644 index 21f7138f..00000000 --- a/src/renderer/features/tasks/components/CreatePrDialog.tsx +++ /dev/null @@ -1,286 +0,0 @@ -/** - * CreatePrDialog — Modal dialog for creating a GitHub pull request - * after task completion. - */ - -import { useCallback, useEffect, useState } from 'react'; - -import { useMutation } from '@tanstack/react-query'; -import { AlertTriangle, CheckCircle2, ExternalLink, GitPullRequestDraft } from 'lucide-react'; - -import { GIT } from '@shared/ipc/git/channels'; - -import { ipc } from '@renderer/shared/lib/ipc'; - -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - Spinner, - Textarea, -} from '@ui'; - -// ── Types ────────────────────────────────────────────────────── - -interface CreatePrDialogProps { - open: boolean; - projectPath: string; - taskDescription: string; - taskName: string; - onOpenChange: (open: boolean) => void; -} - -interface PrResult { - number: number; - title: string; - url: string; -} - -// ── Component ────────────────────────────────────────────────── - -export function CreatePrDialog({ - open, - projectPath, - taskDescription, - taskName, - onOpenChange, -}: CreatePrDialogProps) { - // 1. Hooks - const [title, setTitle] = useState(''); - const [body, setBody] = useState(''); - const [baseBranch, setBaseBranch] = useState('main'); - const [headBranch, setHeadBranch] = useState(''); - const [createdPr, setCreatedPr] = useState<PrResult | null>(null); - - const createPr = useMutation({ - mutationFn: (input: { - baseBranch: string; - body: string; - headBranch: string; - projectPath: string; - title: string; - }) => ipc(GIT.CREATE.PR, input), - onSuccess: (data) => { - setCreatedPr({ url: data.url, number: data.number, title: data.title }); - }, - }); - - const resetForm = useCallback(() => { - setTitle(taskName); - setBody(taskDescription); - setBaseBranch('main'); - setHeadBranch(''); - setCreatedPr(null); - createPr.reset(); - }, [taskName, taskDescription, createPr]); - - // Auto-detect current branch when dialog opens - useEffect(() => { - if (!open) return; - resetForm(); - - if (projectPath.length === 0) return; - - // Fire-and-forget: fetch current branch to pre-fill headBranch - void ipc(GIT.GET.STATUS, { repoPath: projectPath }).then((status) => { - setHeadBranch(status.branch); - return status; - }); - }, [open, projectPath, resetForm]); - - // 2. Derived state - const isFormValid = - title.trim().length > 0 && - baseBranch.trim().length > 0 && - headBranch.trim().length > 0 && - projectPath.length > 0; - const hasError = createPr.isError; - const isSuccess = createdPr !== null; - - // 3. Event handlers - function handleSubmit() { - if (!isFormValid || createPr.isPending) return; - - createPr.mutate({ - projectPath, - title: title.trim(), - body: body.trim(), - baseBranch: baseBranch.trim(), - headBranch: headBranch.trim(), - }); - } - - function handleClose() { - onOpenChange(false); - } - - function handleOpenPr() { - if (createdPr !== null) { - void window.open(createdPr.url, '_blank'); - } - } - - // 4. Render helpers - function renderSuccessState() { - if (createdPr === null) return null; - - return ( - <div className="space-y-4"> - <div className="flex items-center gap-3 rounded-md bg-emerald-500/10 p-4"> - <CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-400" /> - <div> - <div className="text-foreground text-sm font-medium"> - Pull request created successfully - </div> - <div className="text-muted-foreground mt-0.5 text-xs"> - PR #{String(createdPr.number)}: {createdPr.title} - </div> - </div> - </div> - <Button className="w-full" variant="outline" onClick={handleOpenPr}> - <ExternalLink className="mr-1.5 h-4 w-4" /> - Open PR in Browser - </Button> - </div> - ); - } - - function renderFormState() { - return ( - <div className="space-y-4"> - {/* Title field */} - <div className="space-y-1.5"> - <Label htmlFor="create-pr-title" variant="required"> - Title - </Label> - <Input - aria-required="true" - id="create-pr-title" - placeholder="PR title..." - type="text" - value={title} - onChange={(e) => setTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && isFormValid && !createPr.isPending) { - handleSubmit(); - } - }} - /> - </div> - - {/* Body field */} - <div className="space-y-1.5"> - <Label htmlFor="create-pr-body">Description</Label> - <Textarea - id="create-pr-body" - placeholder="PR description..." - resize="none" - rows={4} - value={body} - onChange={(e) => setBody(e.target.value)} - /> - </div> - - {/* Branch fields */} - <div className="grid grid-cols-2 gap-3"> - <div className="space-y-1.5"> - <Label htmlFor="create-pr-base-branch" variant="required"> - Base Branch - </Label> - <Input - aria-required="true" - id="create-pr-base-branch" - placeholder="main" - type="text" - value={baseBranch} - onChange={(e) => setBaseBranch(e.target.value)} - /> - </div> - <div className="space-y-1.5"> - <Label htmlFor="create-pr-head-branch" variant="required"> - Head Branch - </Label> - <Input - aria-required="true" - id="create-pr-head-branch" - placeholder="feature/..." - type="text" - value={headBranch} - onChange={(e) => setHeadBranch(e.target.value)} - /> - </div> - </div> - - {/* Error message */} - {hasError ? ( - <div className="rounded-md bg-red-500/10 p-3"> - <div className="flex items-center gap-2 text-sm text-red-400"> - <AlertTriangle className="h-4 w-4 shrink-0" /> - {createPr.error instanceof Error - ? createPr.error.message - : 'Failed to create pull request'} - </div> - </div> - ) : null} - </div> - ); - } - - // 5. Render - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <GitPullRequestDraft className="h-5 w-5" /> - Create Pull Request - </DialogTitle> - <DialogDescription> - {isSuccess - ? 'Your pull request has been created.' - : 'Create a GitHub pull request for this task.'} - </DialogDescription> - </DialogHeader> - - {isSuccess ? renderSuccessState() : renderFormState()} - - <DialogFooter> - {isSuccess ? ( - <Button variant="primary" onClick={handleClose}> - Done - </Button> - ) : ( - <> - <Button variant="ghost" onClick={handleClose}> - Cancel - </Button> - <Button - disabled={!isFormValid || createPr.isPending} - variant="primary" - onClick={handleSubmit} - > - {createPr.isPending ? ( - <> - <Spinner size="sm" /> - Creating... - </> - ) : ( - <> - <GitPullRequestDraft className="h-4 w-4" /> - Create PR - </> - )} - </Button> - </> - )} - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/tasks/components/CreatePrDialog/CreatePrDialog.tsx b/src/renderer/features/tasks/components/CreatePrDialog/CreatePrDialog.tsx new file mode 100644 index 00000000..ccd338cb --- /dev/null +++ b/src/renderer/features/tasks/components/CreatePrDialog/CreatePrDialog.tsx @@ -0,0 +1,221 @@ +/** + * CreatePrDialog — Modal dialog for creating a GitHub pull request + * after task completion. + */ + +import { AlertTriangle, CheckCircle2, ExternalLink, GitPullRequestDraft } from 'lucide-react'; + +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Spinner, + Textarea, +} from '@ui'; + +import { useCreatePrDialog } from './useCreatePrDialog'; + +// ── Types ────────────────────────────────────────────────────── + +interface CreatePrDialogProps { + open: boolean; + projectPath: string; + taskDescription: string; + taskName: string; + onOpenChange: (open: boolean) => void; +} + +// ── Component ────────────────────────────────────────────────── + +export function CreatePrDialog({ + open, + projectPath, + taskDescription, + taskName, + onOpenChange, +}: CreatePrDialogProps) { + const { + title, + setTitle, + body, + setBody, + baseBranch, + setBaseBranch, + headBranch, + setHeadBranch, + createdPr, + createPr, + isFormValid, + hasError, + isSuccess, + handleSubmit, + handleClose, + handleOpenPr, + } = useCreatePrDialog({ open, projectPath, taskDescription, taskName, onOpenChange }); + + // ── Render helpers ───────────────────────────────────────── + + function renderSuccessState() { + if (createdPr === null) return null; + + return ( + <div className="space-y-4"> + <div className="flex items-center gap-3 rounded-md bg-emerald-500/10 p-4"> + <CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-400" /> + <div> + <div className="text-foreground text-sm font-medium"> + Pull request created successfully + </div> + <div className="text-muted-foreground mt-0.5 text-xs"> + PR #{String(createdPr.number)}: {createdPr.title} + </div> + </div> + </div> + <Button className="w-full" variant="outline" onClick={handleOpenPr}> + <ExternalLink className="mr-1.5 h-4 w-4" /> + Open PR in Browser + </Button> + </div> + ); + } + + function renderFormState() { + return ( + <div className="space-y-4"> + {/* Title field */} + <div className="space-y-1.5"> + <Label htmlFor="create-pr-title" variant="required"> + Title + </Label> + <Input + aria-required="true" + id="create-pr-title" + placeholder="PR title..." + type="text" + value={title} + onChange={(e) => setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && isFormValid && !createPr.isPending) { + handleSubmit(); + } + }} + /> + </div> + + {/* Body field */} + <div className="space-y-1.5"> + <Label htmlFor="create-pr-body">Description</Label> + <Textarea + id="create-pr-body" + placeholder="PR description..." + resize="none" + rows={4} + value={body} + onChange={(e) => setBody(e.target.value)} + /> + </div> + + {/* Branch fields */} + <div className="grid grid-cols-2 gap-3"> + <div className="space-y-1.5"> + <Label htmlFor="create-pr-base-branch" variant="required"> + Base Branch + </Label> + <Input + aria-required="true" + id="create-pr-base-branch" + placeholder="main" + type="text" + value={baseBranch} + onChange={(e) => setBaseBranch(e.target.value)} + /> + </div> + <div className="space-y-1.5"> + <Label htmlFor="create-pr-head-branch" variant="required"> + Head Branch + </Label> + <Input + aria-required="true" + id="create-pr-head-branch" + placeholder="feature/..." + type="text" + value={headBranch} + onChange={(e) => setHeadBranch(e.target.value)} + /> + </div> + </div> + + {/* Error message */} + {hasError ? ( + <div className="rounded-md bg-red-500/10 p-3"> + <div className="flex items-center gap-2 text-sm text-red-400"> + <AlertTriangle className="h-4 w-4 shrink-0" /> + {createPr.error instanceof Error + ? createPr.error.message + : 'Failed to create pull request'} + </div> + </div> + ) : null} + </div> + ); + } + + // ── Render ───────────────────────────────────────────────── + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <GitPullRequestDraft className="h-5 w-5" /> + Create Pull Request + </DialogTitle> + <DialogDescription> + {isSuccess + ? 'Your pull request has been created.' + : 'Create a GitHub pull request for this task.'} + </DialogDescription> + </DialogHeader> + + {isSuccess ? renderSuccessState() : renderFormState()} + + <DialogFooter> + {isSuccess ? ( + <Button variant="primary" onClick={handleClose}> + Done + </Button> + ) : ( + <> + <Button variant="ghost" onClick={handleClose}> + Cancel + </Button> + <Button + disabled={!isFormValid || createPr.isPending} + variant="primary" + onClick={handleSubmit} + > + {createPr.isPending ? ( + <> + <Spinner size="sm" /> + Creating... + </> + ) : ( + <> + <GitPullRequestDraft className="h-4 w-4" /> + Create PR + </> + )} + </Button> + </> + )} + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/tasks/components/CreatePrDialog/index.ts b/src/renderer/features/tasks/components/CreatePrDialog/index.ts new file mode 100644 index 00000000..eec8b88c --- /dev/null +++ b/src/renderer/features/tasks/components/CreatePrDialog/index.ts @@ -0,0 +1 @@ +export { CreatePrDialog } from './CreatePrDialog'; diff --git a/src/renderer/features/tasks/components/CreatePrDialog/useCreatePrDialog.ts b/src/renderer/features/tasks/components/CreatePrDialog/useCreatePrDialog.ts new file mode 100644 index 00000000..a93577f9 --- /dev/null +++ b/src/renderer/features/tasks/components/CreatePrDialog/useCreatePrDialog.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useMutation } from '@tanstack/react-query'; + +import { GIT } from '@shared/ipc/git/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +interface UseCreatePrDialogProps { + open: boolean; + projectPath: string; + taskDescription: string; + taskName: string; + onOpenChange: (open: boolean) => void; +} + +export interface PrResult { + number: number; + title: string; + url: string; +} + +export function useCreatePrDialog({ + open, + projectPath, + taskDescription, + taskName, + onOpenChange, +}: UseCreatePrDialogProps) { + const [title, setTitle] = useState(''); + const [body, setBody] = useState(''); + const [baseBranch, setBaseBranch] = useState('main'); + const [headBranch, setHeadBranch] = useState(''); + const [createdPr, setCreatedPr] = useState<PrResult | null>(null); + + const createPr = useMutation({ + mutationFn: (input: { + baseBranch: string; + body: string; + headBranch: string; + projectPath: string; + title: string; + }) => ipc(GIT.CREATE.PR, input), + onSuccess: (data) => { + setCreatedPr({ url: data.url, number: data.number, title: data.title }); + }, + }); + + const resetForm = useCallback(() => { + setTitle(taskName); + setBody(taskDescription); + setBaseBranch('main'); + setHeadBranch(''); + setCreatedPr(null); + createPr.reset(); + }, [taskName, taskDescription, createPr]); + + // Auto-detect current branch when dialog opens + useEffect(() => { + if (!open) return; + resetForm(); + + if (projectPath.length === 0) return; + + void ipc(GIT.GET.STATUS, { repoPath: projectPath }).then((status) => { + setHeadBranch(status.branch); + return status; + }); + }, [open, projectPath, resetForm]); + + const isFormValid = + title.trim().length > 0 && + baseBranch.trim().length > 0 && + headBranch.trim().length > 0 && + projectPath.length > 0; + + const hasError = createPr.isError; + const isSuccess = createdPr !== null; + + function handleSubmit() { + if (!isFormValid || createPr.isPending) return; + + createPr.mutate({ + projectPath, + title: title.trim(), + body: body.trim(), + baseBranch: baseBranch.trim(), + headBranch: headBranch.trim(), + }); + } + + function handleClose() { + onOpenChange(false); + } + + function handleOpenPr() { + if (createdPr !== null) { + void window.open(createdPr.url, '_blank'); + } + } + + return { + title, + setTitle, + body, + setBody, + baseBranch, + setBaseBranch, + headBranch, + setHeadBranch, + createdPr, + createPr, + isFormValid, + hasError, + isSuccess, + handleSubmit, + handleClose, + handleOpenPr, + }; +} diff --git a/src/renderer/features/tasks/components/CreateTaskDialog.tsx b/src/renderer/features/tasks/components/CreateTaskDialog.tsx deleted file mode 100644 index 51516f88..00000000 --- a/src/renderer/features/tasks/components/CreateTaskDialog.tsx +++ /dev/null @@ -1,235 +0,0 @@ -/** - * CreateTaskDialog — Modal dialog for creating a new task - */ - -import { useCallback, useEffect, useState } from 'react'; - -import { AlertTriangle, ListPlus, X } from 'lucide-react'; - -import { useLayoutStore } from '@renderer/shared/stores/layout-store'; - -import { - Button, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Spinner, - Textarea, -} from '@ui'; - -import { useCreateTask } from '../api/useTasks'; - -type TaskPriority = 'low' | 'normal' | 'high' | 'urgent'; - -const PRIORITY_OPTIONS: readonly TaskPriority[] = ['low', 'normal', 'high', 'urgent']; - -interface CreateTaskDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) { - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [priority, setPriority] = useState<TaskPriority>('normal'); - const [error, setError] = useState<string | null>(null); - - const activeProjectId = useLayoutStore((s) => s.activeProjectId); - const createTask = useCreateTask(); - - const resetForm = useCallback(() => { - setTitle(''); - setDescription(''); - setPriority('normal'); - setError(null); - }, []); - - // Reset form when dialog opens or closes - useEffect(() => { - resetForm(); - }, [open, resetForm]); - - function handleClose() { - onOpenChange(false); - } - - function handleSubmit() { - if (activeProjectId === null) return; - if (title.trim().length === 0) return; - - setError(null); - createTask.mutate( - { - projectId: activeProjectId, - title: title.trim(), - description: description.trim().length > 0 ? description.trim() : undefined, - priority: priority === 'normal' ? undefined : priority, - }, - { - onSuccess: () => { - handleClose(); - }, - onError: (err) => { - setError(err instanceof Error ? err.message : 'Failed to create task'); - }, - }, - ); - } - - if (!open) { - return null; - } - - const isFormValid = title.trim().length > 0 && activeProjectId !== null; - - return ( - <div className="fixed inset-0 z-50 flex items-center justify-center"> - {/* Backdrop */} - <div - aria-label="Close dialog" - className="absolute inset-0 bg-black/50" - role="button" - tabIndex={0} - onClick={handleClose} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ' || e.key === 'Escape') handleClose(); - }} - /> - - {/* Modal */} - <div - aria-labelledby="create-task-dialog-title" - className="bg-card border-border relative z-10 w-full max-w-lg rounded-lg border shadow-xl" - role="dialog" - > - {/* Header */} - <div className="border-border flex items-center justify-between border-b px-6 py-4"> - <div className="flex items-center gap-3"> - <ListPlus className="text-primary h-5 w-5" /> - <h2 className="text-foreground text-lg font-semibold" id="create-task-dialog-title"> - Create Task - </h2> - </div> - <Button - aria-label="Close dialog" - size="icon" - variant="ghost" - onClick={handleClose} - > - <X className="h-5 w-5" /> - </Button> - </div> - - {/* Body */} - <div className="space-y-4 px-6 py-4"> - {activeProjectId === null ? ( - <div className="bg-info/10 text-info rounded-md p-3 text-sm"> - Select a project first to create a task. - </div> - ) : null} - - {/* Title field */} - <div className="space-y-1.5"> - <Label htmlFor="create-task-title" variant="required"> - Title - </Label> - <Input - aria-required="true" - disabled={activeProjectId === null} - id="create-task-title" - placeholder="Task title..." - type="text" - value={title} - onChange={(e) => setTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && isFormValid && !createTask.isPending) { - handleSubmit(); - } - }} - /> - </div> - - {/* Description field */} - <div className="space-y-1.5"> - <Label htmlFor="create-task-description"> - Description - </Label> - <Textarea - disabled={activeProjectId === null} - id="create-task-description" - placeholder="Describe the task..." - resize="none" - rows={3} - value={description} - onChange={(e) => setDescription(e.target.value)} - /> - </div> - - {/* Priority field */} - <div className="space-y-1.5"> - <Label htmlFor="create-task-priority"> - Priority - </Label> - <Select - disabled={activeProjectId === null} - value={priority} - onValueChange={(value) => setPriority(value as TaskPriority)} - > - <SelectTrigger id="create-task-priority"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {PRIORITY_OPTIONS.map((opt) => ( - <SelectItem key={opt} value={opt}> - {opt.charAt(0).toUpperCase() + opt.slice(1)} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - {/* Error message */} - {error === null ? null : ( - <div className="rounded-md bg-red-500/10 p-3"> - <div className="flex items-center gap-2 text-sm text-red-400"> - <AlertTriangle className="h-4 w-4 shrink-0" /> - {error} - </div> - </div> - )} - </div> - - {/* Footer */} - <div className="border-border flex items-center justify-end gap-2 border-t px-6 py-4"> - <Button - variant="ghost" - onClick={handleClose} - > - Cancel - </Button> - <Button - disabled={!isFormValid || createTask.isPending} - variant="primary" - onClick={handleSubmit} - > - {createTask.isPending ? ( - <> - <Spinner size="sm" /> - Creating... - </> - ) : ( - <> - <ListPlus className="h-4 w-4" /> - Create Task - </> - )} - </Button> - </div> - </div> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/EditProgressTaskDialog.tsx b/src/renderer/features/tasks/components/EditProgressTaskDialog/EditProgressTaskDialog.tsx similarity index 75% rename from src/renderer/features/tasks/components/EditProgressTaskDialog.tsx rename to src/renderer/features/tasks/components/EditProgressTaskDialog/EditProgressTaskDialog.tsx index bf264169..9c9bdb98 100644 --- a/src/renderer/features/tasks/components/EditProgressTaskDialog.tsx +++ b/src/renderer/features/tasks/components/EditProgressTaskDialog/EditProgressTaskDialog.tsx @@ -7,8 +7,6 @@ * - ProgressTaskDetailRow footer edit button */ -import { useState } from 'react'; - import type { ProgressPriority, ProgressStatus, ProgressTask } from '@shared/types/progress'; import { @@ -31,7 +29,7 @@ import { Textarea, } from '@ui'; -import { useUpdateProgressTask } from '../api/useProgressMutations'; +import { useEditProgressTaskDialog } from './useEditProgressTaskDialog'; // ── Constants ─────────────────────────────────────────────── @@ -65,57 +63,20 @@ interface EditProgressTaskDialogProps { // ── Component ─────────────────────────────────────────────── export function EditProgressTaskDialog({ task, open, onOpenChange }: EditProgressTaskDialogProps) { - const updateTask = useUpdateProgressTask(); - - const [title, setTitle] = useState(task.title); - const [description, setDescription] = useState(task.description); - const [priority, setPriority] = useState<ProgressPriority>(task.priority); - const [status, setStatus] = useState<ProgressStatus>(task.status); - const [error, setError] = useState<string | null>(null); - - function handleOpenChange(nextOpen: boolean) { - if (!nextOpen) { - // Reset form to task values on close - setTitle(task.title); - setDescription(task.description); - setPriority(task.priority); - setStatus(task.status); - setError(null); - } - onOpenChange(nextOpen); - } - - async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) { - e.preventDefault(); - - if (title.trim().length === 0) { - setError('Title is required.'); - return; - } - - setError(null); - - const updates: { - title?: string; - description?: string; - priority?: ProgressPriority; - status?: ProgressStatus; - } = {}; - - if (title.trim() !== task.title) updates.title = title.trim(); - if (description.trim() !== task.description) updates.description = description.trim(); - if (priority !== task.priority) updates.priority = priority; - if (status !== task.status) updates.status = status; - - try { - await updateTask.mutateAsync({ slug: task.slug, updates }); - handleOpenChange(false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update task.'); - } - } - - const isSubmitting = updateTask.isPending; + const { + title, + setTitle, + description, + setDescription, + priority, + setPriority, + status, + setStatus, + error, + isSubmitting, + handleOpenChange, + handleSubmit, + } = useEditProgressTaskDialog(task, onOpenChange); return ( <Dialog open={open} onOpenChange={handleOpenChange}> diff --git a/src/renderer/features/tasks/components/EditProgressTaskDialog/index.ts b/src/renderer/features/tasks/components/EditProgressTaskDialog/index.ts new file mode 100644 index 00000000..03848652 --- /dev/null +++ b/src/renderer/features/tasks/components/EditProgressTaskDialog/index.ts @@ -0,0 +1 @@ +export { EditProgressTaskDialog } from './EditProgressTaskDialog'; diff --git a/src/renderer/features/tasks/components/EditProgressTaskDialog/useEditProgressTaskDialog.ts b/src/renderer/features/tasks/components/EditProgressTaskDialog/useEditProgressTaskDialog.ts new file mode 100644 index 00000000..b4fa35b0 --- /dev/null +++ b/src/renderer/features/tasks/components/EditProgressTaskDialog/useEditProgressTaskDialog.ts @@ -0,0 +1,74 @@ +import { useState } from 'react'; + +import type { ProgressPriority, ProgressStatus, ProgressTask } from '@shared/types/progress'; + +import { useUpdateProgressTask } from '../../api/useProgressMutations'; + +export function useEditProgressTaskDialog( + task: ProgressTask, + onOpenChange: (open: boolean) => void, +) { + const updateTask = useUpdateProgressTask(); + + const [title, setTitle] = useState(task.title); + const [description, setDescription] = useState(task.description); + const [priority, setPriority] = useState<ProgressPriority>(task.priority); + const [status, setStatus] = useState<ProgressStatus>(task.status); + const [error, setError] = useState<string | null>(null); + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen) { + setTitle(task.title); + setDescription(task.description); + setPriority(task.priority); + setStatus(task.status); + setError(null); + } + onOpenChange(nextOpen); + } + + async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) { + e.preventDefault(); + + if (title.trim().length === 0) { + setError('Title is required.'); + return; + } + + setError(null); + + const updates: { + title?: string; + description?: string; + priority?: ProgressPriority; + status?: ProgressStatus; + } = {}; + + if (title.trim() !== task.title) updates.title = title.trim(); + if (description.trim() !== task.description) updates.description = description.trim(); + if (priority !== task.priority) updates.priority = priority; + if (status !== task.status) updates.status = status; + + try { + await updateTask.mutateAsync({ slug: task.slug, updates }); + handleOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update task.'); + } + } + + return { + title, + setTitle, + description, + setDescription, + priority, + setPriority, + status, + setStatus, + error, + isSubmitting: updateTask.isPending, + handleOpenChange, + handleSubmit, + }; +} diff --git a/src/renderer/features/tasks/components/LinkJiraDialog.tsx b/src/renderer/features/tasks/components/LinkJiraDialog.tsx deleted file mode 100644 index 13ec982f..00000000 --- a/src/renderer/features/tasks/components/LinkJiraDialog.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * LinkJiraDialog — Associate a Jira ticket with a progress task. - * - * Fields: jiraTicket (text) + jiraUrl (URL, validated on submit). - * Calls useUpdateProgressTask() on submit. - */ - -import { useState } from 'react'; - -import type { ProgressTask } from '@shared/types/progress'; - -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - Spinner, - Text, -} from '@ui'; - -import { useUpdateProgressTask } from '../api/useProgressMutations'; - -// ── Props ──────────────────────────────────────────────────── - -interface LinkJiraDialogProps { - task: ProgressTask; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -// ── Helpers ────────────────────────────────────────────────── - -function isValidUrl(value: string): boolean { - if (value.trim().length === 0) return true; // optional - try { - new URL(value.trim()); - return true; - } catch { - return false; - } -} - -// ── Component ──────────────────────────────────────────────── - -export function LinkJiraDialog({ task, open, onOpenChange }: LinkJiraDialogProps) { - const updateTask = useUpdateProgressTask(); - - const [jiraTicket, setJiraTicket] = useState(task.jiraTicket ?? ''); - const [jiraUrl, setJiraUrl] = useState(task.jiraUrl ?? ''); - const [error, setError] = useState<string | null>(null); - - function handleOpenChange(nextOpen: boolean) { - if (!nextOpen) { - setJiraTicket(task.jiraTicket ?? ''); - setJiraUrl(task.jiraUrl ?? ''); - setError(null); - } - onOpenChange(nextOpen); - } - - async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) { - e.preventDefault(); - - if (jiraTicket.trim().length === 0) { - setError('Ticket ID is required.'); - return; - } - - if (!isValidUrl(jiraUrl)) { - setError('Jira URL must be a valid URL (e.g. https://jira.example.com/browse/ABC-123).'); - return; - } - - setError(null); - - try { - await updateTask.mutateAsync({ - slug: task.slug, - updates: { - jiraTicket: jiraTicket.trim(), - jiraUrl: jiraUrl.trim() || undefined, - }, - }); - handleOpenChange(false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to link Jira ticket.'); - } - } - - const isSubmitting = updateTask.isPending; - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="max-w-md"> - <DialogHeader> - <DialogTitle>Link Jira Ticket</DialogTitle> - <DialogDescription> - Associate a Jira ticket with this task. The ticket ID will appear as a badge. - </DialogDescription> - </DialogHeader> - - <form className="flex flex-col gap-4" id="link-jira-form" onSubmit={handleSubmit}> - <div className="flex flex-col gap-1.5"> - <Label htmlFor="jira-ticket-id">Ticket ID</Label> - <Input - id="jira-ticket-id" - placeholder="ABC-123" - value={jiraTicket} - onChange={(e) => setJiraTicket(e.target.value)} - /> - </div> - - <div className="flex flex-col gap-1.5"> - <Label htmlFor="jira-ticket-url">Jira URL (optional)</Label> - <Input - id="jira-ticket-url" - placeholder="https://jira.example.com/browse/ABC-123" - type="url" - value={jiraUrl} - onChange={(e) => setJiraUrl(e.target.value)} - /> - </div> - - {error === null ? null : ( - <Text className="text-destructive text-sm">{error}</Text> - )} - </form> - - <DialogFooter> - <Button - disabled={isSubmitting} - variant="outline" - onClick={() => handleOpenChange(false)} - > - Cancel - </Button> - <Button - disabled={isSubmitting || jiraTicket.trim().length === 0} - form="link-jira-form" - type="submit" - > - {isSubmitting ? <Spinner className="mr-2" size="sm" /> : null} - Link Ticket - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/tasks/components/LinkJiraDialog/LinkJiraDialog.tsx b/src/renderer/features/tasks/components/LinkJiraDialog/LinkJiraDialog.tsx new file mode 100644 index 00000000..ea210ae9 --- /dev/null +++ b/src/renderer/features/tasks/components/LinkJiraDialog/LinkJiraDialog.tsx @@ -0,0 +1,105 @@ +/** + * LinkJiraDialog — Associate a Jira ticket with a progress task. + * + * Fields: jiraTicket (text) + jiraUrl (URL, validated on submit). + * Calls useUpdateProgressTask() on submit. + */ + +import type { ProgressTask } from '@shared/types/progress'; + +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Spinner, + Text, +} from '@ui'; + +import { useLinkJiraDialog } from './useLinkJiraDialog'; + +// ── Props ──────────────────────────────────────────────────── + +interface LinkJiraDialogProps { + task: ProgressTask; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// ── Component ──────────────────────────────────────────────── + +export function LinkJiraDialog({ task, open, onOpenChange }: LinkJiraDialogProps) { + const { + jiraTicket, + setJiraTicket, + jiraUrl, + setJiraUrl, + error, + isSubmitting, + handleOpenChange, + handleSubmit, + } = useLinkJiraDialog({ task, onOpenChange }); + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Link Jira Ticket</DialogTitle> + <DialogDescription> + Associate a Jira ticket with this task. The ticket ID will appear as a badge. + </DialogDescription> + </DialogHeader> + + <form className="flex flex-col gap-4" id="link-jira-form" onSubmit={handleSubmit}> + <div className="flex flex-col gap-1.5"> + <Label htmlFor="jira-ticket-id">Ticket ID</Label> + <Input + id="jira-ticket-id" + placeholder="ABC-123" + value={jiraTicket} + onChange={(e) => setJiraTicket(e.target.value)} + /> + </div> + + <div className="flex flex-col gap-1.5"> + <Label htmlFor="jira-ticket-url">Jira URL (optional)</Label> + <Input + id="jira-ticket-url" + placeholder="https://jira.example.com/browse/ABC-123" + type="url" + value={jiraUrl} + onChange={(e) => setJiraUrl(e.target.value)} + /> + </div> + + {error === null ? null : ( + <Text className="text-destructive text-sm">{error}</Text> + )} + </form> + + <DialogFooter> + <Button + disabled={isSubmitting} + variant="outline" + onClick={() => handleOpenChange(false)} + > + Cancel + </Button> + <Button + disabled={isSubmitting || jiraTicket.trim().length === 0} + form="link-jira-form" + type="submit" + > + {isSubmitting ? <Spinner className="mr-2" size="sm" /> : null} + Link Ticket + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/tasks/components/LinkJiraDialog/index.ts b/src/renderer/features/tasks/components/LinkJiraDialog/index.ts new file mode 100644 index 00000000..903d4199 --- /dev/null +++ b/src/renderer/features/tasks/components/LinkJiraDialog/index.ts @@ -0,0 +1 @@ +export { LinkJiraDialog } from './LinkJiraDialog'; diff --git a/src/renderer/features/tasks/components/LinkJiraDialog/useLinkJiraDialog.ts b/src/renderer/features/tasks/components/LinkJiraDialog/useLinkJiraDialog.ts new file mode 100644 index 00000000..4ed3eb52 --- /dev/null +++ b/src/renderer/features/tasks/components/LinkJiraDialog/useLinkJiraDialog.ts @@ -0,0 +1,89 @@ +/** + * useLinkJiraDialog — logic for LinkJiraDialog + */ + +import { useCallback, useState } from 'react'; + +import type { ProgressTask } from '@shared/types/progress'; + +import { useUpdateProgressTask } from '../../api/useProgressMutations'; + +function isValidUrl(value: string): boolean { + if (value.trim().length === 0) return true; + try { + new URL(value.trim()); + return true; + } catch { + return false; + } +} + +interface UseLinkJiraDialogParams { + task: ProgressTask; + onOpenChange: (open: boolean) => void; +} + +export function useLinkJiraDialog({ task, onOpenChange }: UseLinkJiraDialogParams) { + const updateTask = useUpdateProgressTask(); + + const [jiraTicket, setJiraTicket] = useState(task.jiraTicket ?? ''); + const [jiraUrl, setJiraUrl] = useState(task.jiraUrl ?? ''); + const [error, setError] = useState<string | null>(null); + + const isSubmitting = updateTask.isPending; + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + setJiraTicket(task.jiraTicket ?? ''); + setJiraUrl(task.jiraUrl ?? ''); + setError(null); + } + onOpenChange(nextOpen); + }, + [task.jiraTicket, task.jiraUrl, onOpenChange], + ); + + const handleSubmit = useCallback( + async (e: React.SyntheticEvent<HTMLFormElement>) => { + e.preventDefault(); + + if (jiraTicket.trim().length === 0) { + setError('Ticket ID is required.'); + return; + } + + if (!isValidUrl(jiraUrl)) { + setError('Jira URL must be a valid URL (e.g. https://jira.example.com/browse/ABC-123).'); + return; + } + + setError(null); + + try { + await updateTask.mutateAsync({ + slug: task.slug, + updates: { + jiraTicket: jiraTicket.trim(), + jiraUrl: jiraUrl.trim() || undefined, + }, + }); + handleOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to link Jira ticket.'); + } + }, + [jiraTicket, jiraUrl, task.slug, updateTask, handleOpenChange], + ); + + return { + jiraTicket, + setJiraTicket, + jiraUrl, + setJiraUrl, + error, + isSubmitting, + handleOpenChange, + handleSubmit, + }; +} diff --git a/src/renderer/features/tasks/components/LinkPrDialog.tsx b/src/renderer/features/tasks/components/LinkPrDialog.tsx deleted file mode 100644 index 7f350f37..00000000 --- a/src/renderer/features/tasks/components/LinkPrDialog.tsx +++ /dev/null @@ -1,158 +0,0 @@ -/** - * LinkPrDialog — Associate a GitHub PR with a progress task. - * - * Input: prUrl (text). On submit, parses a GitHub pull request URL to - * derive prNumber via regex. Sets prStatus to "open" as default. - * Calls useUpdateProgressTask() on submit. - */ - -import { useState } from 'react'; - -import type { ProgressTask } from '@shared/types/progress'; - -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - Spinner, - Text, -} from '@ui'; - -import { useUpdateProgressTask } from '../api/useProgressMutations'; - -// ── Props ──────────────────────────────────────────────────── - -interface LinkPrDialogProps { - task: ProgressTask; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -// ── Helpers ────────────────────────────────────────────────── - -const GITHUB_PR_REGEX = /github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/; - -function parsePrNumber(url: string): number | undefined { - const match = GITHUB_PR_REGEX.exec(url.trim()); - if (match?.[1] === undefined) return undefined; - const parsed = Number.parseInt(match[1], 10); - return Number.isFinite(parsed) ? parsed : undefined; -} - -// ── Component ──────────────────────────────────────────────── - -export function LinkPrDialog({ task, open, onOpenChange }: LinkPrDialogProps) { - const updateTask = useUpdateProgressTask(); - - const [prUrl, setPrUrl] = useState(task.prUrl ?? ''); - const [error, setError] = useState<string | null>(null); - - function handleOpenChange(nextOpen: boolean) { - if (!nextOpen) { - setPrUrl(task.prUrl ?? ''); - setError(null); - } - onOpenChange(nextOpen); - } - - async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) { - e.preventDefault(); - - const trimmedUrl = prUrl.trim(); - - if (trimmedUrl.length === 0) { - setError('PR URL is required.'); - return; - } - - // Validate URL syntax - try { - new URL(trimmedUrl); - } catch { - setError('Must be a valid URL (e.g. https://github.com/owner/repo/pull/42).'); - return; - } - - const prNumber = parsePrNumber(trimmedUrl); - - setError(null); - - try { - await updateTask.mutateAsync({ - slug: task.slug, - updates: { - prUrl: trimmedUrl, - prNumber, - prStatus: task.prStatus ?? 'open', - }, - }); - handleOpenChange(false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to link PR.'); - } - } - - const isSubmitting = updateTask.isPending; - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="max-w-md"> - <DialogHeader> - <DialogTitle>Link Pull Request</DialogTitle> - <DialogDescription> - Associate a GitHub pull request URL with this task. The PR number will be derived - automatically from GitHub URLs. - </DialogDescription> - </DialogHeader> - - <form className="flex flex-col gap-4" id="link-pr-form" onSubmit={handleSubmit}> - <div className="flex flex-col gap-1.5"> - <Label htmlFor="pr-url">PR URL</Label> - <Input - id="pr-url" - placeholder="https://github.com/owner/repo/pull/42" - type="url" - value={prUrl} - onChange={(e) => setPrUrl(e.target.value)} - /> - {prUrl.trim().length > 0 ? ( - <Text className="text-muted-foreground text-xs"> - {parsePrNumber(prUrl) === undefined - ? 'PR number could not be parsed from this URL' - : `PR #${String(parsePrNumber(prUrl))} detected`} - </Text> - ) : null} - </div> - - {error === null ? null : ( - <Text className="text-destructive text-sm">{error}</Text> - )} - </form> - - <DialogFooter> - <Button - disabled={isSubmitting} - variant="outline" - onClick={() => handleOpenChange(false)} - > - Cancel - </Button> - <Button - disabled={isSubmitting || prUrl.trim().length === 0} - form="link-pr-form" - type="submit" - > - {isSubmitting ? <Spinner className="mr-2" size="sm" /> : null} - Link PR - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/tasks/components/LinkPrDialog/LinkPrDialog.tsx b/src/renderer/features/tasks/components/LinkPrDialog/LinkPrDialog.tsx new file mode 100644 index 00000000..8551bbf0 --- /dev/null +++ b/src/renderer/features/tasks/components/LinkPrDialog/LinkPrDialog.tsx @@ -0,0 +1,103 @@ +/** + * LinkPrDialog — Associate a GitHub PR with a progress task. + * + * Input: prUrl (text). On submit, parses a GitHub pull request URL to + * derive prNumber via regex. Sets prStatus to "open" as default. + * Calls useUpdateProgressTask() on submit. + */ + +import type { ProgressTask } from '@shared/types/progress'; + +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Spinner, + Text, +} from '@ui'; + +import { useLinkPrDialog } from './useLinkPrDialog'; + +// ── Props ──────────────────────────────────────────────────── + +interface LinkPrDialogProps { + task: ProgressTask; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// ── Component ──────────────────────────────────────────────── + +export function LinkPrDialog({ task, open, onOpenChange }: LinkPrDialogProps) { + const { + prUrl, + setPrUrl, + error, + isSubmitting, + parsedPrNumber, + handleOpenChange, + handleSubmit, + } = useLinkPrDialog({ task, onOpenChange }); + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Link Pull Request</DialogTitle> + <DialogDescription> + Associate a GitHub pull request URL with this task. The PR number will be derived + automatically from GitHub URLs. + </DialogDescription> + </DialogHeader> + + <form className="flex flex-col gap-4" id="link-pr-form" onSubmit={handleSubmit}> + <div className="flex flex-col gap-1.5"> + <Label htmlFor="pr-url">PR URL</Label> + <Input + id="pr-url" + placeholder="https://github.com/owner/repo/pull/42" + type="url" + value={prUrl} + onChange={(e) => setPrUrl(e.target.value)} + /> + {prUrl.trim().length > 0 ? ( + <Text className="text-muted-foreground text-xs"> + {parsedPrNumber === undefined + ? 'PR number could not be parsed from this URL' + : `PR #${String(parsedPrNumber)} detected`} + </Text> + ) : null} + </div> + + {error === null ? null : ( + <Text className="text-destructive text-sm">{error}</Text> + )} + </form> + + <DialogFooter> + <Button + disabled={isSubmitting} + variant="outline" + onClick={() => handleOpenChange(false)} + > + Cancel + </Button> + <Button + disabled={isSubmitting || prUrl.trim().length === 0} + form="link-pr-form" + type="submit" + > + {isSubmitting ? <Spinner className="mr-2" size="sm" /> : null} + Link PR + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/tasks/components/LinkPrDialog/index.ts b/src/renderer/features/tasks/components/LinkPrDialog/index.ts new file mode 100644 index 00000000..a396030d --- /dev/null +++ b/src/renderer/features/tasks/components/LinkPrDialog/index.ts @@ -0,0 +1 @@ +export { LinkPrDialog } from './LinkPrDialog'; diff --git a/src/renderer/features/tasks/components/LinkPrDialog/useLinkPrDialog.ts b/src/renderer/features/tasks/components/LinkPrDialog/useLinkPrDialog.ts new file mode 100644 index 00000000..e416423e --- /dev/null +++ b/src/renderer/features/tasks/components/LinkPrDialog/useLinkPrDialog.ts @@ -0,0 +1,92 @@ +/** + * useLinkPrDialog — logic for LinkPrDialog + */ + +import { useCallback, useState } from 'react'; + +import type { ProgressTask } from '@shared/types/progress'; + +import { useUpdateProgressTask } from '../../api/useProgressMutations'; + +const GITHUB_PR_REGEX = /github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/; + +export function parsePrNumber(url: string): number | undefined { + const match = GITHUB_PR_REGEX.exec(url.trim()); + if (match?.[1] === undefined) return undefined; + const parsed = Number.parseInt(match[1], 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +interface UseLinkPrDialogParams { + task: ProgressTask; + onOpenChange: (open: boolean) => void; +} + +export function useLinkPrDialog({ task, onOpenChange }: UseLinkPrDialogParams) { + const updateTask = useUpdateProgressTask(); + + const [prUrl, setPrUrl] = useState(task.prUrl ?? ''); + const [error, setError] = useState<string | null>(null); + + const isSubmitting = updateTask.isPending; + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + setPrUrl(task.prUrl ?? ''); + setError(null); + } + onOpenChange(nextOpen); + }, + [task.prUrl, onOpenChange], + ); + + const handleSubmit = useCallback( + async (e: React.SyntheticEvent<HTMLFormElement>) => { + e.preventDefault(); + + const trimmedUrl = prUrl.trim(); + + if (trimmedUrl.length === 0) { + setError('PR URL is required.'); + return; + } + + try { + new URL(trimmedUrl); + } catch { + setError('Must be a valid URL (e.g. https://github.com/owner/repo/pull/42).'); + return; + } + + const prNumber = parsePrNumber(trimmedUrl); + + setError(null); + + try { + await updateTask.mutateAsync({ + slug: task.slug, + updates: { + prUrl: trimmedUrl, + prNumber, + prStatus: task.prStatus ?? 'open', + }, + }); + handleOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to link PR.'); + } + }, + [prUrl, task.slug, task.prStatus, updateTask, handleOpenChange], + ); + + return { + prUrl, + setPrUrl, + error, + isSubmitting, + parsedPrNumber: prUrl.trim().length > 0 ? parsePrNumber(prUrl) : undefined, + handleOpenChange, + handleSubmit, + }; +} diff --git a/src/renderer/features/tasks/components/TaskFiltersToolbar.tsx b/src/renderer/features/tasks/components/TaskFiltersToolbar.tsx deleted file mode 100644 index 759bebe0..00000000 --- a/src/renderer/features/tasks/components/TaskFiltersToolbar.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/** - * TaskFiltersToolbar -- Filter bar above the AG-Grid: - * Text search, status multi-select, priority multi-select, clear filters button. - */ - -import { useState } from 'react'; - -import { Plus, Search, X } from 'lucide-react'; - -import type { TaskStatus } from '@shared/types'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { Button, Input } from '@ui'; - -import { useTaskUI } from '../store'; - -import { CreateTaskDialog } from './CreateTaskDialog'; -import { TaskStatusBadge } from './TaskStatusBadge'; - -const ALL_STATUSES: TaskStatus[] = [ - 'backlog', - 'planning', - 'plan_ready', - 'queued', - 'running', - 'paused', - 'review', - 'done', - 'error', -]; - -interface FilterDropdownProps { - label: string; - options: readonly string[]; - selectedOptions: string[]; - onToggle: (option: string) => void; - renderOption?: (option: string, isSelected: boolean) => React.ReactNode; -} - -function FilterDropdown({ - label, - options, - selectedOptions, - onToggle, - renderOption, -}: FilterDropdownProps) { - const [isOpen, setIsOpen] = useState(false); - - return ( - <div className="relative"> - <Button - aria-expanded={isOpen} - aria-haspopup="listbox" - variant="outline" - className={cn( - selectedOptions.length > 0 && 'border-primary', - )} - onClick={() => setIsOpen((prev) => !prev)} - > - <span>{label}</span> - {selectedOptions.length > 0 ? ( - <span className="bg-primary text-primary-foreground flex h-5 w-5 items-center justify-center rounded-full text-xs"> - {selectedOptions.length} - </span> - ) : null} - </Button> - - {isOpen ? ( - <> - <Button - aria-label={`Close ${label.toLowerCase()} filter`} - className="fixed inset-0 z-40 h-full w-full cursor-default rounded-none" - tabIndex={-1} - variant="ghost" - onClick={() => setIsOpen(false)} - /> - <div - role="listbox" - className={cn( - 'bg-popover border-border absolute top-full right-0 z-50 mt-1', - 'w-48 rounded-md border p-1 shadow-lg', - )} - > - {options.map((option) => { - const isSelected = selectedOptions.includes(option); - return ( - <Button - key={option} - aria-selected={isSelected} - role="option" - variant="ghost" - className={cn( - 'flex w-full justify-start gap-2 px-2 py-1.5', - isSelected && 'bg-muted/30', - )} - onClick={() => onToggle(option)} - > - <span - className={cn( - 'border-border flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border', - isSelected && 'bg-primary border-primary', - )} - > - {isSelected ? ( - <svg - className="text-primary-foreground h-3 w-3" - fill="none" - stroke="currentColor" - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - viewBox="0 0 24 24" - > - <path d="M20 6 9 17l-5-5" /> - </svg> - ) : null} - </span> - {renderOption ? renderOption(option, isSelected) : ( - <span className="text-foreground text-sm capitalize"> - {option.replaceAll('_', ' ')} - </span> - )} - </Button> - ); - })} - </div> - </> - ) : null} - </div> - ); -} - -export function TaskFiltersToolbar() { - const gridSearchText = useTaskUI((s) => s.gridSearchText); - const filterStatuses = useTaskUI((s) => s.filterStatuses); - const setGridSearchText = useTaskUI((s) => s.setGridSearchText); - const toggleFilterStatus = useTaskUI((s) => s.toggleFilterStatus); - const clearFilters = useTaskUI((s) => s.clearFilters); - const createDialogOpen = useTaskUI((s) => s.createDialogOpen); - const setCreateDialogOpen = useTaskUI((s) => s.setCreateDialogOpen); - - const hasActiveFilters = filterStatuses.length > 0 || gridSearchText.length > 0; - - return ( - <> - <div className="flex items-center gap-3 px-4 py-3"> - {/* New Task button */} - <Button - variant="primary" - onClick={() => setCreateDialogOpen(true)} - > - <Plus className="h-4 w-4" /> - <span>New Task</span> - </Button> - - {/* Search input */} - <div className="relative flex-1"> - <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> - <Input - aria-label="Search tasks" - className="pl-9" - placeholder="Search tasks..." - type="text" - value={gridSearchText} - onChange={(e) => setGridSearchText(e.target.value)} - /> - </div> - - {/* Status multi-select */} - <FilterDropdown - label="Status" - options={ALL_STATUSES} - selectedOptions={filterStatuses} - renderOption={(status) => ( - <TaskStatusBadge status={status as TaskStatus} /> - )} - onToggle={(status) => toggleFilterStatus(status as TaskStatus)} - /> - - {/* Clear filters */} - {hasActiveFilters ? ( - <Button - variant="ghost" - onClick={clearFilters} - > - <X className="h-4 w-4" /> - <span>Clear</span> - </Button> - ) : null} - </div> - - <CreateTaskDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen} /> - </> - ); -} diff --git a/src/renderer/features/tasks/components/TaskStatusBadge.tsx b/src/renderer/features/tasks/components/TaskStatusBadge.tsx deleted file mode 100644 index 7466dd45..00000000 --- a/src/renderer/features/tasks/components/TaskStatusBadge.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * TaskStatusBadge — Color-coded status indicator for hub TaskStatus values. - * - * Uses the shared StatusBadge glass-pill component for consistent styling - * across the app (matches HealthIndicator and ProgressTaskGrid badges). - */ - -import type { TaskStatus } from '@shared/types'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { StatusBadge } from '@ui'; - -import type { StatusBadgeProps } from '@ui'; - -const ACTIVE_STATUSES = new Set<TaskStatus>(['planning', 'running']); - -const statusConfig: Record< - TaskStatus, - { label: string; tone: StatusBadgeProps['tone'] } -> = { - backlog: { label: 'Backlog', tone: 'muted' }, - planning: { label: 'Planning', tone: 'info' }, - plan_ready: { label: 'Plan Ready', tone: 'purple' }, - queued: { label: 'Queued', tone: 'info' }, - running: { label: 'Running', tone: 'primary' }, - paused: { label: 'Paused', tone: 'amber' }, - review: { label: 'Review', tone: 'amber' }, - done: { label: 'Done', tone: 'success' }, - error: { label: 'Error', tone: 'destructive' }, -}; - -interface TaskStatusBadgeProps { - status: TaskStatus; - className?: string; -} - -export function TaskStatusBadge({ status, className }: TaskStatusBadgeProps) { - const config = statusConfig[status]; - return ( - <StatusBadge - className={cn(className)} - pulsing={ACTIVE_STATUSES.has(status)} - tone={config.tone} - > - {config.label} - </StatusBadge> - ); -} diff --git a/src/renderer/features/tasks/components/cells/ActionsCell.tsx b/src/renderer/features/tasks/components/cells/ActionsCell.tsx deleted file mode 100644 index ae18ce96..00000000 --- a/src/renderer/features/tasks/components/cells/ActionsCell.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/** - * ActionsCell — cell renderer with context-sensitive action buttons. - * Actions vary by task status: planning/execution/kill/restart/delete. - */ - -import { useState } from 'react'; - -import { - Brain, - MessageSquare, - Play, - RefreshCw, - RotateCcw, - Square, - Trash2, - X, -} from 'lucide-react'; - -import { ConfirmDialog } from '@renderer/shared/components/ConfirmDialog'; - -import { Button } from '@ui'; - -import { useDeleteTask, useUpdateTaskStatus } from '../../api/useTaskMutations'; - -export interface ActionsCellData { - id: string; - status: string; - title: string; - description: string; - projectId?: string; -} - -interface ActionsCellProps { - data: ActionsCellData; - onStartPlanning?: (taskId: string) => void; - onStartExecution?: (taskId: string) => void; - onRequestChanges?: (taskId: string) => void; - onKillAgent?: (taskId: string) => void; - onRestartCheckpoint?: (taskId: string) => void; -} - -const ICON_SIZE = 'h-3.5 w-3.5'; - -export function ActionsCell(props: ActionsCellProps) { - const { data } = props; - const deleteTask = useDeleteTask(); - const updateStatus = useUpdateTaskStatus(); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [killConfirmOpen, setKillConfirmOpen] = useState(false); - - const { id: taskId, status } = data; - - function handleClick(event: React.MouseEvent, handler?: (id: string) => void) { - event.stopPropagation(); - handler?.(taskId); - } - - function handleDeleteClick(event: React.MouseEvent) { - event.stopPropagation(); - setDeleteConfirmOpen(true); - } - - function handleKillClick(event: React.MouseEvent) { - event.stopPropagation(); - setKillConfirmOpen(true); - } - - function handleReject(event: React.MouseEvent) { - event.stopPropagation(); - updateStatus.mutate({ taskId, status: 'backlog' }); - } - - return ( - <div className="flex items-center gap-1 py-1"> - {/* Planning action — available for backlog tasks */} - {status === 'backlog' ? ( - <Button - aria-label="Start planning" - className="text-info hover:text-info" - size="icon" - variant="ghost" - onClick={(event) => { - handleClick(event, props.onStartPlanning); - }} - > - <Brain className={ICON_SIZE} /> - </Button> - ) : null} - - {/* Execute action — available for backlog and plan_ready */} - {status === 'backlog' || status === 'plan_ready' ? ( - <Button - aria-label={status === 'plan_ready' ? 'Approve and execute' : 'Implement feature'} - className="text-success hover:text-success" - size="icon" - variant="ghost" - onClick={(event) => { - handleClick(event, props.onStartExecution); - }} - > - <Play className={ICON_SIZE} /> - </Button> - ) : null} - - {/* Kill action — available during planning and running */} - {status === 'planning' || status === 'running' ? ( - <Button - aria-label="Kill agent" - className="text-warning hover:text-warning" - size="icon" - variant="ghost" - onClick={handleKillClick} - > - <Square className={ICON_SIZE} /> - </Button> - ) : null} - - {/* Request changes — available for plan_ready */} - {status === 'plan_ready' ? ( - <Button - aria-label="Request changes to plan" - className="text-warning hover:text-warning" - size="icon" - variant="ghost" - onClick={(event) => { - handleClick(event, props.onRequestChanges); - }} - > - <MessageSquare className={ICON_SIZE} /> - </Button> - ) : null} - - {/* Reject plan — move back to backlog */} - {status === 'plan_ready' ? ( - <Button - aria-label="Reject plan" - className="text-muted-foreground hover:text-foreground" - size="icon" - variant="ghost" - onClick={handleReject} - > - <X className={ICON_SIZE} /> - </Button> - ) : null} - - {/* Restart from checkpoint — available on error */} - {status === 'error' ? ( - <Button - aria-label="Restart from checkpoint" - className="text-info hover:text-info" - size="icon" - variant="ghost" - onClick={(event) => { - handleClick(event, props.onRestartCheckpoint); - }} - > - <RotateCcw className={ICON_SIZE} /> - </Button> - ) : null} - - {/* Restart fresh — available on error */} - {status === 'error' ? ( - <Button - aria-label="Restart fresh" - className="text-muted-foreground hover:text-foreground" - size="icon" - variant="ghost" - onClick={(event) => { - handleClick(event, props.onStartPlanning); - }} - > - <RefreshCw className={ICON_SIZE} /> - </Button> - ) : null} - - {/* Delete — available for backlog, plan_ready, done, error */} - {status === 'backlog' || - status === 'plan_ready' || - status === 'done' || - status === 'error' ? ( - <Button - aria-label="Delete task" - className="text-muted-foreground hover:text-destructive" - size="icon" - variant="ghost" - onClick={handleDeleteClick} - > - <Trash2 className={ICON_SIZE} /> - </Button> - ) : null} - - <ConfirmDialog - confirmLabel="Delete" - description="Are you sure you want to delete this task? This action cannot be undone." - loading={deleteTask.isPending} - open={deleteConfirmOpen} - title="Delete Task" - variant="destructive" - onOpenChange={setDeleteConfirmOpen} - onConfirm={() => { - deleteTask.mutate( - { taskId }, - { - onSuccess: () => { - setDeleteConfirmOpen(false); - }, - }, - ); - }} - /> - - <ConfirmDialog - confirmLabel="Kill" - description="Are you sure you want to kill the running agent? Progress up to the last checkpoint will be preserved." - open={killConfirmOpen} - title="Kill Agent" - variant="destructive" - onOpenChange={setKillConfirmOpen} - onConfirm={() => { - setKillConfirmOpen(false); - props.onKillAgent?.(taskId); - }} - /> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/cells/ActivitySparklineCell.tsx b/src/renderer/features/tasks/components/cells/ActivitySparklineCell.tsx deleted file mode 100644 index c4801056..00000000 --- a/src/renderer/features/tasks/components/cells/ActivitySparklineCell.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * ActivitySparklineCell — cell renderer with SVG polyline sparkline. - * Renders a mini chart from a number array (~120px wide, 28px tall). - */ - -const SPARKLINE_WIDTH = 120; -const SPARKLINE_HEIGHT = 28; - -export function ActivitySparklineCell({ value }: { value: unknown }) { - const data: number[] = Array.isArray(value) ? (value as number[]) : []; - - if (data.length === 0) { - return <span className="text-muted-foreground text-xs">No data</span>; - } - - const max = Math.max(...data, 1); - const min = Math.min(...data, 0); - const range = max - min; - const effectiveRange = range === 0 ? 1 : range; - - const points = data - .map((val, i) => { - const x = data.length > 1 ? (i / (data.length - 1)) * SPARKLINE_WIDTH : SPARKLINE_WIDTH / 2; - const y = SPARKLINE_HEIGHT - ((val - min) / effectiveRange) * SPARKLINE_HEIGHT; - return `${String(x)},${String(y)}`; - }) - .join(' '); - - return ( - <div className="flex items-center py-1"> - <svg className="overflow-visible" height={SPARKLINE_HEIGHT} width={SPARKLINE_WIDTH}> - <polyline - fill="none" - points={points} - stroke="var(--primary)" - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth="1.5" - /> - </svg> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/cells/AgentCell.tsx b/src/renderer/features/tasks/components/cells/AgentCell.tsx deleted file mode 100644 index 0974c821..00000000 --- a/src/renderer/features/tasks/components/cells/AgentCell.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/** - * AgentCell — cell renderer showing agent avatar (initials) + name. - */ - -export function AgentCell({ value }: { value: string }) { - const agentName = value; - - if (agentName === '') { - return <span className="text-muted-foreground text-sm">Unassigned</span>; - } - - const initials = agentName - .split(/\s+/) - .map((word) => word.charAt(0)) - .join('') - .slice(0, 2) - .toUpperCase(); - - return ( - <div className="flex items-center gap-2 py-1"> - <div className="bg-primary/15 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold"> - {initials} - </div> - <span className="truncate text-sm">{agentName}</span> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/cells/CostCell.tsx b/src/renderer/features/tasks/components/cells/CostCell.tsx deleted file mode 100644 index e094c975..00000000 --- a/src/renderer/features/tasks/components/cells/CostCell.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/** - * CostCell — cell renderer for formatted dollar amounts. - * Shows dash if value is 0 or undefined. - */ - -export function CostCell({ value }: { value: number | null | undefined }) { - const cost = typeof value === 'number' ? value : 0; - - if (cost === 0) { - return <span className="text-muted-foreground text-sm">—</span>; - } - - return <span className="text-foreground text-sm">${cost.toFixed(2)}</span>; -} diff --git a/src/renderer/features/tasks/components/cells/PrStatusCell.tsx b/src/renderer/features/tasks/components/cells/PrStatusCell.tsx deleted file mode 100644 index b9898272..00000000 --- a/src/renderer/features/tasks/components/cells/PrStatusCell.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * PrStatusCell — cell renderer for PR badge + CI status dot. - * Shows dash when no PR exists. - */ - -import { cn } from '@renderer/shared/lib/utils'; - -type PrState = 'open' | 'closed' | 'merged'; -type CiStatus = 'pending' | 'success' | 'failure' | 'passing' | 'failing' | 'none'; - -export interface PrData { - state?: PrState; - ciStatus?: CiStatus; - number?: number; -} - -const PR_STATE_CLASSES: Record<PrState, string> = { - open: 'bg-success/15 text-success', - closed: 'bg-destructive/15 text-destructive', - merged: 'bg-primary/15 text-primary', -}; - -const CI_STATUS_CLASSES: Record<CiStatus, string> = { - pending: 'bg-warning', - success: 'bg-success', - passing: 'bg-success', - failure: 'bg-destructive', - failing: 'bg-destructive', - none: 'bg-muted-foreground', -}; - -export function PrStatusCell({ value }: { value: PrData | null | undefined }) { - const data = value; - - if (data?.state === undefined) { - return <span className="text-muted-foreground text-sm">—</span>; - } - - const stateClass = PR_STATE_CLASSES[data.state]; - const prLabel = data.number === undefined ? data.state : `#${String(data.number)}`; - - return ( - <div className="flex items-center gap-2 py-1"> - <span - className={cn( - 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium', - stateClass, - )} - > - {prLabel} - </span> - {data.ciStatus === undefined ? null : ( - <span - className={cn('inline-block h-2 w-2 rounded-full', CI_STATUS_CLASSES[data.ciStatus])} - title={`CI: ${data.ciStatus}`} - /> - )} - </div> - ); -} diff --git a/src/renderer/features/tasks/components/cells/PriorityCell.tsx b/src/renderer/features/tasks/components/cells/PriorityCell.tsx deleted file mode 100644 index 52f27a74..00000000 --- a/src/renderer/features/tasks/components/cells/PriorityCell.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * PriorityCell — AG-Grid cell renderer for task priority icon + label. - */ - -import { AlertTriangle, ArrowDown, ArrowUp, Minus } from 'lucide-react'; - -import { cn } from '@renderer/shared/lib/utils'; - -type Priority = 'low' | 'medium' | 'high' | 'critical'; - -interface PriorityConfig { - label: string; - icon: typeof ArrowDown; - className: string; -} - -const PRIORITY_CONFIG: Record<Priority, PriorityConfig> = { - low: { - label: 'Low', - icon: ArrowDown, - className: 'text-muted-foreground', - }, - medium: { - label: 'Medium', - icon: Minus, - className: 'text-foreground', - }, - high: { - label: 'High', - icon: ArrowUp, - className: 'text-warning', - }, - critical: { - label: 'Critical', - icon: AlertTriangle, - className: 'text-destructive', - }, -}; - -export function PriorityCell({ value }: { value: string }) { - const priority = (value as Priority | undefined) ?? 'medium'; - const config = PRIORITY_CONFIG[priority]; - const Icon = config.icon; - - return ( - <div className={cn('flex items-center gap-1.5 py-1', config.className)}> - <Icon className="h-3.5 w-3.5 shrink-0" /> - <span className="text-sm">{config.label}</span> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/cells/ProgressBarCell.tsx b/src/renderer/features/tasks/components/cells/ProgressBarCell.tsx deleted file mode 100644 index ffc95f93..00000000 --- a/src/renderer/features/tasks/components/cells/ProgressBarCell.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * ProgressBarCell — cell renderer showing a horizontal progress bar. - * Color transitions: 0-30 muted, 30-60 warning, 60-100 primary, 100 success. - */ - -function getProgressColor(percent: number): string { - if (percent >= 100) return 'var(--success)'; - if (percent >= 60) return 'var(--primary)'; - if (percent >= 30) return 'var(--warning)'; - return 'var(--muted-foreground)'; -} - -export function ProgressBarCell({ value }: { value: number | null | undefined }) { - const progress = typeof value === 'number' ? value : 0; - const clampedProgress = Math.min(Math.max(progress, 0), 100); - - return ( - <div className="flex items-center gap-2 px-1 py-2"> - <div className="bg-muted h-2 flex-1 overflow-hidden rounded-full"> - <div - className="h-full rounded-full transition-all duration-300" - style={{ - width: `${String(clampedProgress)}%`, - backgroundColor: getProgressColor(clampedProgress), - }} - /> - </div> - <span className="text-muted-foreground min-w-[3ch] text-right text-xs"> - {clampedProgress}% - </span> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/cells/RelativeTimeCell.tsx b/src/renderer/features/tasks/components/cells/RelativeTimeCell.tsx deleted file mode 100644 index d070a5b1..00000000 --- a/src/renderer/features/tasks/components/cells/RelativeTimeCell.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/** - * RelativeTimeCell — cell renderer for relative timestamps. - * Displays "2m ago", "1h ago", "3d ago" from ISO date strings. - */ - -import { formatRelativeTime } from '@renderer/shared/lib/utils'; - -export function RelativeTimeCell({ value }: { value: string | null | undefined }) { - const dateString = value; - - if (dateString === null || dateString === undefined) { - return <span className="text-muted-foreground text-sm">—</span>; - } - - const relative = formatRelativeTime(dateString); - - return ( - <span className="text-muted-foreground text-sm" title={dateString}> - {relative} - </span> - ); -} diff --git a/src/renderer/features/tasks/components/cells/StatusBadgeCell.tsx b/src/renderer/features/tasks/components/cells/StatusBadgeCell.tsx deleted file mode 100644 index ed1562f3..00000000 --- a/src/renderer/features/tasks/components/cells/StatusBadgeCell.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * StatusBadgeCell — cell renderer for task status with colored badge. - * Shows a pulsing dot for active statuses (planning, running). - * - * Uses the shared StatusBadge glass-pill component for consistent styling - * across the app (matches HealthIndicator and ProgressTaskGrid badges). - */ - -import { StatusBadge } from '@ui'; - -import type { StatusBadgeProps } from '@ui'; - -interface StatusConfig { - label: string; - tone: StatusBadgeProps['tone']; - pulsing?: boolean; -} - -const STATUS_CONFIG: Record<string, StatusConfig> = { - backlog: { label: 'Backlog', tone: 'muted' }, - planning: { label: 'Planning...', tone: 'info', pulsing: true }, - plan_ready: { label: 'Plan Ready', tone: 'purple' }, - queued: { label: 'Queued', tone: 'info' }, - running: { label: 'Running', tone: 'primary', pulsing: true }, - paused: { label: 'Paused', tone: 'muted' }, - review: { label: 'Review', tone: 'amber' }, - done: { label: 'Done', tone: 'success' }, - error: { label: 'Error', tone: 'destructive' }, -}; - -const FALLBACK_CONFIG: StatusConfig = { - label: 'Unknown', - tone: 'muted', -}; - -export function StatusBadgeCell({ - value, -}: { - value: string; - data?: { id?: string }; -}) { - const config = STATUS_CONFIG[value] ?? FALLBACK_CONFIG; - - return ( - <div className="flex items-center py-1"> - <StatusBadge pulsing={config.pulsing} tone={config.tone}> - {config.label} - </StatusBadge> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/cells/TitleCell.tsx b/src/renderer/features/tasks/components/cells/TitleCell.tsx deleted file mode 100644 index 65a0ba8d..00000000 --- a/src/renderer/features/tasks/components/cells/TitleCell.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/** - * TitleCell — cell renderer for bold truncated task title. - * Full title shown on hover via title attribute. - */ - -export function TitleCell({ value }: { value: string }) { - if (value.length === 0) { - return <span className="text-muted-foreground text-sm italic">Untitled</span>; - } - - return ( - <span className="text-foreground truncate text-sm font-medium" title={value}> - {value} - </span> - ); -} diff --git a/src/renderer/features/tasks/components/cells/WatchdogDropdown.tsx b/src/renderer/features/tasks/components/cells/WatchdogDropdown.tsx deleted file mode 100644 index 74b1dbc1..00000000 --- a/src/renderer/features/tasks/components/cells/WatchdogDropdown.tsx +++ /dev/null @@ -1,185 +0,0 @@ -/** - * WatchdogDropdown — Alert action dropdown for watchdog alerts on task rows. - * - * The agent orchestrator watchdog system has been removed. This component - * is preserved as a generic alert dropdown that can be reused if a new - * alerting system is added. It no longer depends on orchestrator types. - */ - -import { useEffect, useRef, useState } from 'react'; - -import { AlertTriangle, FileText, Play, RefreshCw, XCircle } from 'lucide-react'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { Button } from '@ui'; - -export interface WatchdogAlert { - type: 'warning' | 'dead' | 'stale'; - message: string; -} - -interface WatchdogDropdownProps { - alert: WatchdogAlert; - onRestartCheckpoint: () => void; - onRestartFresh: () => void; - onViewLogs: () => void; - onMarkError: () => void; -} - -export function WatchdogDropdown({ - alert, - onRestartCheckpoint, - onRestartFresh, - onViewLogs, - onMarkError, -}: WatchdogDropdownProps) { - const [open, setOpen] = useState(false); - const dropdownRef = useRef<HTMLDivElement>(null); - const buttonRef = useRef<HTMLButtonElement>(null); - - // Close on outside click - useEffect(() => { - if (!open) { - return; - } - - function handleClickOutside(event: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { - setOpen(false); - } - } - - function handleEscape(event: KeyboardEvent) { - if (event.key === 'Escape') { - setOpen(false); - } - } - - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleEscape); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleEscape); - }; - }, [open]); - - const isWarning = alert.type === 'warning'; - const isCritical = alert.type === 'dead' || alert.type === 'stale'; - - function handleToggle(event: React.MouseEvent) { - event.stopPropagation(); - setOpen((previous) => !previous); - } - - function handleAction(action: () => void) { - return (event: React.MouseEvent) => { - event.stopPropagation(); - action(); - setOpen(false); - }; - } - - function handleKeyAction(action: () => void) { - return (event: React.KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { - event.stopPropagation(); - event.preventDefault(); - action(); - setOpen(false); - } - }; - } - - return ( - <div className="relative inline-flex"> - <Button - ref={buttonRef} - aria-label="Watchdog alert" - size="icon" - variant="ghost" - className={cn( - 'ml-1 h-auto w-auto rounded-full p-0.5', - isWarning - ? 'text-warning hover:bg-warning/20' - : 'text-destructive hover:bg-destructive/20', - )} - onClick={handleToggle} - > - <AlertTriangle className="h-3.5 w-3.5" /> - </Button> - - {open ? ( - <div - ref={dropdownRef} - className="bg-popover border-border absolute top-full right-0 z-50 mt-1 w-64 rounded-md border shadow-lg" - role="menu" - > - {/* Alert message */} - <div - className={cn( - 'border-b px-3 py-2 text-xs font-medium', - isCritical - ? 'border-destructive/20 bg-destructive/10 text-destructive' - : 'border-warning/20 bg-warning/10 text-warning', - )} - > - {alert.message} - </div> - - {/* Actions */} - <div className="p-1"> - <div - className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs" - role="menuitem" - tabIndex={0} - onClick={handleAction(onRestartCheckpoint)} - onKeyDown={handleKeyAction(onRestartCheckpoint)} - > - <Play className="text-muted-foreground h-3.5 w-3.5" /> - <span>Restart from checkpoint</span> - </div> - - <div - className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs" - role="menuitem" - tabIndex={0} - onClick={handleAction(onRestartFresh)} - onKeyDown={handleKeyAction(onRestartFresh)} - > - <RefreshCw className="text-muted-foreground h-3.5 w-3.5" /> - <span>Restart from scratch</span> - </div> - - <div - className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs" - role="menuitem" - tabIndex={0} - onClick={handleAction(onViewLogs)} - onKeyDown={handleKeyAction(onViewLogs)} - > - <FileText className="text-muted-foreground h-3.5 w-3.5" /> - <span>View logs</span> - </div> - - <div - className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs" - role="menuitem" - tabIndex={0} - onClick={handleAction(onMarkError)} - onKeyDown={handleKeyAction(onMarkError)} - > - <XCircle className="text-muted-foreground h-3.5 w-3.5" /> - <span>Mark as error</span> - </div> - </div> - </div> - ) : null} - </div> - ); -} diff --git a/src/renderer/features/tasks/components/cells/WorkspaceCell.tsx b/src/renderer/features/tasks/components/cells/WorkspaceCell.tsx deleted file mode 100644 index ad8b075b..00000000 --- a/src/renderer/features/tasks/components/cells/WorkspaceCell.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** - * WorkspaceCell — cell renderer for workspace name + online/offline dot. - */ - -import { cn } from '@renderer/shared/lib/utils'; - -export interface WorkspaceData { - name?: string; - isOnline?: boolean; -} - -export function WorkspaceCell({ - value, -}: { - value: WorkspaceData | string | null | undefined; -}) { - const raw = value; - - if (raw === null || raw === undefined) { - return <span className="text-muted-foreground text-sm">—</span>; - } - - const name = typeof raw === 'string' ? raw : (raw.name ?? ''); - const isOnline = typeof raw === 'string' ? false : raw.isOnline === true; - - if (name === '') { - return <span className="text-muted-foreground text-sm">—</span>; - } - - return ( - <div className="flex items-center gap-2 py-1"> - <span - title={isOnline ? 'Online' : 'Offline'} - className={cn( - 'inline-block h-2 w-2 shrink-0 rounded-full', - isOnline ? 'bg-success' : 'bg-muted-foreground', - )} - /> - <span className="truncate text-sm">{name}</span> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/detail/PlanFeedbackDialog.tsx b/src/renderer/features/tasks/components/detail/PlanFeedbackDialog.tsx index f04b7766..8462b52c 100644 --- a/src/renderer/features/tasks/components/detail/PlanFeedbackDialog.tsx +++ b/src/renderer/features/tasks/components/detail/PlanFeedbackDialog.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from 'react'; import { MessageSquare } from 'lucide-react'; -import { Button, Spinner, Textarea } from '@ui'; +import { Button, Heading, Spinner, Textarea } from '@ui'; interface PlanFeedbackDialogProps { open: boolean; @@ -99,7 +99,7 @@ export function PlanFeedbackDialog({ <MessageSquare className="text-warning h-5 w-5" /> </div> <div> - <h2 className="text-foreground text-lg font-semibold">Request Changes</h2> + <Heading as="h2" className="text-lg">Request Changes</Heading> <p className="text-muted-foreground text-sm"> Describe what changes you'd like to see in the plan. </p> diff --git a/src/renderer/features/tasks/components/detail/PlanViewer.tsx b/src/renderer/features/tasks/components/detail/PlanViewer.tsx deleted file mode 100644 index c9cde324..00000000 --- a/src/renderer/features/tasks/components/detail/PlanViewer.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/** - * PlanViewer — Displays agent plan content with approve/reject/request-changes actions. - * Shown in the task detail row when a plan exists (status plan_ready or later). - */ - -import { useState } from 'react'; - -import { FileText, MessageSquare, Play, X } from 'lucide-react'; - -import { Button } from '@ui'; - -import { PlanFeedbackDialog } from './PlanFeedbackDialog'; - -interface PlanViewerProps { - taskId: string; - planContent: string | null; - status: string; - onApproveAndExecute?: (taskId: string) => void; - onReject?: (taskId: string) => void; - onRequestChanges?: (taskId: string, feedback: string) => void; -} - -export function PlanViewer({ - taskId, - planContent, - status, - onApproveAndExecute, - onReject, - onRequestChanges, -}: PlanViewerProps) { - const [feedbackDialogOpen, setFeedbackDialogOpen] = useState(false); - - if (!planContent) { - return ( - <div className="flex flex-col items-center justify-center py-8 text-center"> - <FileText className="text-muted-foreground mb-2 h-8 w-8 opacity-50" /> - <p className="text-muted-foreground text-sm">No plan available for this task.</p> - </div> - ); - } - - const showActions = status === 'plan_ready'; - - function handleFeedbackSubmit(feedback: string) { - onRequestChanges?.(taskId, feedback); - setFeedbackDialogOpen(false); - } - - return ( - <div className="flex flex-col gap-3"> - <div className="flex items-center justify-between"> - <h4 className="text-foreground flex items-center gap-1.5 text-sm font-medium"> - <FileText className="h-4 w-4" /> - Agent Plan - </h4> - {showActions ? ( - <div className="flex items-center gap-2"> - <Button - aria-label="Approve and execute plan" - className="bg-success/10 text-success hover:bg-success/20" - size="sm" - variant="ghost" - onClick={() => { - onApproveAndExecute?.(taskId); - }} - > - <Play className="h-3.5 w-3.5" /> - Approve & Execute - </Button> - <Button - aria-label="Request changes to plan" - className="bg-warning/10 text-warning hover:bg-warning/20" - size="sm" - variant="ghost" - onClick={() => { - setFeedbackDialogOpen(true); - }} - > - <MessageSquare className="h-3.5 w-3.5" /> - Request Changes - </Button> - <Button - aria-label="Reject plan" - className="bg-destructive/10 text-destructive hover:bg-destructive/20" - size="sm" - variant="ghost" - onClick={() => { - onReject?.(taskId); - }} - > - <X className="h-3.5 w-3.5" /> - Reject - </Button> - </div> - ) : null} - </div> - - <div className="bg-muted/50 border-border max-h-64 overflow-y-auto rounded-md border p-3"> - <pre className="text-foreground whitespace-pre-wrap text-xs leading-relaxed"> - {planContent} - </pre> - </div> - - <PlanFeedbackDialog - open={feedbackDialogOpen} - onOpenChange={setFeedbackDialogOpen} - onSubmit={handleFeedbackSubmit} - /> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/detail/ProgressTaskDetailRow.tsx b/src/renderer/features/tasks/components/detail/ProgressTaskDetailRow.tsx index 25ddc130..a5f99c56 100644 --- a/src/renderer/features/tasks/components/detail/ProgressTaskDetailRow.tsx +++ b/src/renderer/features/tasks/components/detail/ProgressTaskDetailRow.tsx @@ -56,6 +56,7 @@ import { LinkPrDialog } from '../LinkPrDialog'; import { extractSummaryBlock } from './summary-block-parser'; import { TeamActivityPanel } from './TeamActivityPanel'; +import { TestPhasePanel } from './TestPhasePanel'; // ─── Live Agent Preview ────────────────────────────────── @@ -512,7 +513,7 @@ export function ProgressTaskDetailRow({ task }: ProgressTaskDetailRowProps) { const [linkJiraOpen, setLinkJiraOpen] = useState(false); const [linkPrOpen, setLinkPrOpen] = useState(false); - type PipelineTab = 'research' | 'plan' | 'execute'; + type PipelineTab = 'research' | 'plan' | 'execute' | 'test-phase'; const [activeTab, setActiveTab] = useState<PipelineTab>(() => { if (task.hasTeamTasks) return 'execute'; if (task.hasPlan) return 'plan'; @@ -582,6 +583,7 @@ export function ProgressTaskDetailRow({ task }: ProgressTaskDetailRowProps) { task.status === 'done' || task.status === 'review', activeAction === 'team' || task.hasTeamTasks, ); + const testPhaseStatus: StepStatus = 'pending'; return ( <div className="flex max-h-[60vh] flex-col"> @@ -611,6 +613,7 @@ export function ProgressTaskDetailRow({ task }: ProgressTaskDetailRowProps) { { key: 'research' as PipelineTab, label: 'Research', status: researchStatus }, { key: 'plan' as PipelineTab, label: 'Plan', status: planStatus }, { key: 'execute' as PipelineTab, label: 'Execute', status: executeStatus }, + { key: 'test-phase' as PipelineTab, label: 'Test Phase', status: testPhaseStatus }, ]).map((tab) => ( <Button key={tab.key} @@ -655,6 +658,10 @@ export function ProgressTaskDetailRow({ task }: ProgressTaskDetailRowProps) { task={task} /> ) : null} + + {activeTab === 'test-phase' ? ( + <TestPhasePanel taskId={task.slug} /> + ) : null} </div> {/* ── Sticky Footer ────────────────────────────── */} diff --git a/src/renderer/features/tasks/components/detail/SubtaskList.tsx b/src/renderer/features/tasks/components/detail/SubtaskList.tsx deleted file mode 100644 index 8c2dd351..00000000 --- a/src/renderer/features/tasks/components/detail/SubtaskList.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * SubtaskList -- Checkbox list of subtasks from task.subtasks[] - */ - -import { CheckCircle2, Circle, XCircle } from 'lucide-react'; - -import type { Subtask, SubtaskStatus } from '@shared/types'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { Spinner } from '@ui'; - -interface SubtaskListProps { - subtasks: Subtask[]; -} - -function getSubtaskIcon(status: SubtaskStatus) { - switch (status) { - case 'completed': { - return <CheckCircle2 className="text-success h-4 w-4 shrink-0" />; - } - case 'in_progress': { - return <Spinner className="text-primary shrink-0" size="sm" />; - } - case 'failed': { - return <XCircle className="text-destructive h-4 w-4 shrink-0" />; - } - case 'pending': { - return <Circle className="text-muted-foreground h-4 w-4 shrink-0" />; - } - } -} - -export function SubtaskList({ subtasks }: SubtaskListProps) { - if (subtasks.length === 0) { - return ( - <p className="text-muted-foreground text-xs italic">No subtasks defined</p> - ); - } - - const completedCount = subtasks.filter((s) => s.status === 'completed').length; - - return ( - <div className="flex flex-col gap-1"> - <div className="text-muted-foreground mb-1 text-xs font-medium"> - Subtasks ({completedCount}/{subtasks.length}) - </div> - <ul className="flex flex-col gap-1"> - {subtasks.map((subtask) => ( - <li - key={subtask.id} - className="flex items-start gap-2" - > - {getSubtaskIcon(subtask.status)} - <span - title={subtask.description} - className={cn( - 'text-xs leading-tight', - subtask.status === 'completed' ? 'text-muted-foreground line-through' : 'text-foreground', - )} - > - {subtask.title} - </span> - </li> - ))} - </ul> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/detail/TaskControls.tsx b/src/renderer/features/tasks/components/detail/TaskControls.tsx deleted file mode 100644 index 800fee01..00000000 --- a/src/renderer/features/tasks/components/detail/TaskControls.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/** - * TaskControls -- Action buttons for task detail: Run, Stop, Retry, Launch, Delete - * - * Run/Stop/Retry delegate to orchestrator callbacks from the parent. - * Launch delegates to the workflow feature's useLaunchTask. - * Delete is handled directly via useDeleteTask. - */ - -import { useState } from 'react'; - -import { Play, RefreshCw, Rocket, Square, Trash2 } from 'lucide-react'; - -import type { Task } from '@shared/types'; - -import { ConfirmDialog } from '@renderer/shared/components/ConfirmDialog'; - -import { Button } from '@ui'; - -import { useDeleteTask } from '../../api/useTaskMutations'; - -interface TaskControlsProps { - task: Task; - onRun?: (taskId: string) => void; - onStop?: (taskId: string) => void; - onRetry?: (taskId: string) => void; - onLaunch?: (taskId: string) => void; -} - -export function TaskControls({ task, onRun, onStop, onRetry, onLaunch }: TaskControlsProps) { - const deleteTask = useDeleteTask(); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - - const status = task.status as string; - const isRunning = status === 'running' || status === 'planning'; - const isDone = status === 'done'; - const isError = status === 'error'; - - return ( - <div className="flex items-center gap-2"> - {isRunning ? ( - <Button - aria-label="Stop task" - className="bg-warning/10 text-warning hover:bg-warning/20" - size="sm" - variant="ghost" - onClick={() => { - onStop?.(task.id); - }} - > - <Square className="h-3.5 w-3.5" /> - Stop - </Button> - ) : ( - <Button - aria-label="Run task" - className="bg-primary/10 text-primary hover:bg-primary/20" - disabled={isDone} - size="sm" - variant="ghost" - onClick={() => { - onRun?.(task.id); - }} - > - <Play className="h-3.5 w-3.5" /> - Run - </Button> - )} - - {!isRunning && !isDone ? ( - <Button - aria-label="Launch via workflow" - className="bg-accent/10 text-accent-foreground hover:bg-accent/20" - size="sm" - variant="ghost" - onClick={() => { - onLaunch?.(task.id); - }} - > - <Rocket className="h-3.5 w-3.5" /> - Launch - </Button> - ) : null} - - {isError ? ( - <Button - aria-label="Retry task" - className="bg-info/10 text-info hover:bg-info/20" - size="sm" - variant="ghost" - onClick={() => { - onRetry?.(task.id); - }} - > - <RefreshCw className="h-3.5 w-3.5" /> - Retry - </Button> - ) : null} - - <Button - aria-label="Delete task" - className="bg-destructive/10 text-destructive hover:bg-destructive/20" - size="sm" - variant="ghost" - onClick={() => { - setDeleteConfirmOpen(true); - }} - > - <Trash2 className="h-3.5 w-3.5" /> - Delete - </Button> - - <ConfirmDialog - confirmLabel="Delete" - description={`Are you sure you want to delete "${task.title}"? This action cannot be undone.`} - loading={deleteTask.isPending} - open={deleteConfirmOpen} - title="Delete Task" - variant="destructive" - onOpenChange={setDeleteConfirmOpen} - onConfirm={() => { - deleteTask.mutate( - { taskId: task.id }, - { - onSuccess: () => { - setDeleteConfirmOpen(false); - }, - }, - ); - }} - /> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/detail/TaskDetailRow.tsx b/src/renderer/features/tasks/components/detail/TaskDetailRow.tsx deleted file mode 100644 index 2f65471c..00000000 --- a/src/renderer/features/tasks/components/detail/TaskDetailRow.tsx +++ /dev/null @@ -1,165 +0,0 @@ -/** - * TaskDetailRow -- Full-width expanded view with subtasks, logs, PR info, plan, cost, and controls. - * Rendered as a full-width row below the parent task row in the grid. - */ - -import { Coins } from 'lucide-react'; - -import type { Task } from '@shared/types'; - -import { Card, CardContent } from '@ui'; - -import { ExecutionLog } from './ExecutionLog'; -import { PlanViewer } from './PlanViewer'; -import { PRStatusPanel } from './PrStatusPanel'; -import { QaReportViewer } from './QaReportViewer'; -import { SubtaskList } from './SubtaskList'; -import { TaskControls } from './TaskControls'; -import { TaskResultView } from './TaskResultView'; - -interface TaskDetailRowProps { - task: Task; - onApproveAndExecute?: (taskId: string) => void; - onRejectPlan?: (taskId: string) => void; - onRequestChanges?: (taskId: string, feedback: string) => void; - onKillAgent?: (taskId: string) => void; - onRestartCheckpoint?: (taskId: string) => void; - onLaunchWorkflow?: (taskId: string) => void; -} - -const tokenFormatter = new Intl.NumberFormat('en-US'); -const costFormatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}); - -/** Format cost value, handling sub-cent amounts */ -function formatCostUsd(usd: number): string { - if (usd > 0 && usd < 0.01) { - return '<$0.01'; - } - return costFormatter.format(usd); -} - -/** Check if a task has a plan based on status or metadata */ -function hasPlan(task: Task): boolean { - const hubStatus = task.status as string; - const planStatuses = new Set(['plan_ready', 'queued', 'running', 'paused', 'review', 'done']); - return planStatuses.has(hubStatus) || typeof task.metadata?.planContent === 'string'; -} - -interface TaskCostDisplayProps { - metadata: Record<string, unknown> | undefined; -} - -function TaskCostDisplay({ metadata }: TaskCostDisplayProps) { - const costUsd = typeof metadata?.costUsd === 'number' ? metadata.costUsd : null; - const costTokens = typeof metadata?.costTokens === 'number' ? metadata.costTokens : null; - - return ( - <Card className="border-border bg-card/50"> - <CardContent className="flex items-center gap-4 px-4 py-3"> - <Coins className="text-muted-foreground h-4 w-4 shrink-0" /> - <div className="flex items-center gap-6 text-sm"> - <div> - <span className="text-muted-foreground mr-1.5">Tokens:</span> - <span className="text-foreground font-medium"> - {costTokens === null ? '--' : tokenFormatter.format(costTokens)} - </span> - </div> - <div> - <span className="text-muted-foreground mr-1.5">Cost:</span> - <span className="text-foreground font-medium"> - {costUsd === null ? '--' : formatCostUsd(costUsd)} - </span> - </div> - </div> - </CardContent> - </Card> - ); -} - -export function TaskDetailRow({ task, onApproveAndExecute, onRejectPlan, onRequestChanges, onKillAgent, onRestartCheckpoint, onLaunchWorkflow }: TaskDetailRowProps) { - const showPlan = hasPlan(task); - const planContent = (task.metadata?.planContent as string | undefined) ?? null; - - // Show QA report tab for review/done statuses or if metadata indicates QA ran - const qaRelevantStatuses = new Set(['review', 'done']); - const showQa = qaRelevantStatuses.has(task.status as string); - - // Show result view for tasks that have completed execution - const resultStatuses = new Set(['review', 'done', 'error']); - const showResult = resultStatuses.has(task.status as string); - - return ( - <div className="bg-card/50 border-border border-b px-6 py-4"> - {/* Plan section — shown when plan exists */} - {showPlan ? ( - <div className="border-border mb-4 border-b pb-4"> - <PlanViewer - planContent={planContent} - status={task.status as string} - taskId={task.id} - onApproveAndExecute={onApproveAndExecute} - onReject={onRejectPlan} - onRequestChanges={onRequestChanges} - /> - </div> - ) : null} - - {/* QA Report section — shown for review/done statuses */} - {showQa ? ( - <div className="border-border mb-4 border-b pb-4"> - <div className="text-muted-foreground mb-2 text-xs font-medium">QA Report</div> - <QaReportViewer taskId={task.id} /> - </div> - ) : null} - - {/* Execution Result section — shown for review/done/error tasks */} - {showResult ? ( - <div className="border-border mb-4 border-b pb-4"> - <TaskResultView task={task} /> - </div> - ) : ( - <div className="mb-4"> - <TaskCostDisplay metadata={task.metadata as Record<string, unknown> | undefined} /> - </div> - )} - - {/* Three-column layout: Subtasks | Logs | PR Status */} - <div className="mb-4 grid grid-cols-3 gap-6"> - {/* Left: Subtasks */} - <div className="min-w-0"> - {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Hub data may omit subtasks at runtime */} - <SubtaskList subtasks={task.subtasks ?? []} /> - </div> - - {/* Center: Execution Log */} - <div className="min-w-0"> - <ExecutionLog logs={task.logs ?? []} /> - </div> - - {/* Right: PR Status */} - <div className="min-w-0"> - <PRStatusPanel - prStatus={task.prStatus} - prUrl={task.metadata?.prUrl} - /> - </div> - </div> - - {/* Bottom: Controls */} - <div className="border-border border-t pt-3"> - <TaskControls - task={task} - onLaunch={onLaunchWorkflow} - onRetry={onRestartCheckpoint} - onRun={onApproveAndExecute} - onStop={onKillAgent} - /> - </div> - </div> - ); -} diff --git a/src/renderer/features/tasks/components/detail/TaskResultView.tsx b/src/renderer/features/tasks/components/detail/TaskResultView.tsx deleted file mode 100644 index 53d2af9c..00000000 --- a/src/renderer/features/tasks/components/detail/TaskResultView.tsx +++ /dev/null @@ -1,426 +0,0 @@ -/** - * TaskResultView -- Summary view for completed/reviewed/errored tasks. - * Shows execution duration, final status, cost breakdown, log summary, - * and action buttons (View Diff, Create PR). - */ - -import { useMemo, useState } from 'react'; - -import { - AlertCircle, - CheckCircle2, - ChevronDown, - ChevronUp, - Clock, - Coins, - ExternalLink, - GitPullRequestDraft, - ScrollText, -} from 'lucide-react'; - -import type { Task, TaskStatus } from '@shared/types'; - -import { cn } from '@renderer/shared/lib/utils'; - -import { - Badge, - Button, - Card, - CardContent, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, - ScrollArea, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@ui'; - -import { CreatePrDialog } from '../CreatePrDialog'; - -// ── Constants ─────────────────────────────────────────────────── - -const RESULT_STATUSES = new Set<string>(['done', 'review', 'error']); -const MAX_LOG_LINES_COLLAPSED = 20; - -const tokenFormatter = new Intl.NumberFormat('en-US'); -const costFormatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 4, -}); - -// ── Helpers ───────────────────────────────────────────────────── - -/** Format cost value, handling sub-cent amounts */ -function formatCostUsd(usd: number): string { - if (usd > 0 && usd < 0.01) { - return '<$0.01'; - } - return costFormatter.format(usd); -} - -/** Format a duration in milliseconds to a human-readable string */ -function formatDuration(ms: number): string { - if (ms < 0) return '--'; - const totalSeconds = Math.floor(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `${String(hours)}h ${String(minutes)}m ${String(seconds)}s`; - } - if (minutes > 0) { - return `${String(minutes)}m ${String(seconds)}s`; - } - return `${String(seconds)}s`; -} - -/** Compute duration between two ISO date strings */ -function computeDuration(startIso: string, endIso: string | undefined): number | null { - if (!endIso) return null; - const start = new Date(startIso).getTime(); - const end = new Date(endIso).getTime(); - if (Number.isNaN(start) || Number.isNaN(end)) return null; - return end - start; -} - -/** Get the appropriate icon and styling for a final status */ -function getStatusPresentation(status: TaskStatus): { - icon: typeof CheckCircle2; - colorClass: string; - badgeClass: string; - label: string; -} { - switch (status) { - case 'done': { - return { - icon: CheckCircle2, - label: 'Completed', - colorClass: 'text-emerald-400', - badgeClass: 'bg-emerald-500/15 text-emerald-400 border-transparent', - }; - } - case 'review': { - return { - icon: ScrollText, - label: 'In Review', - colorClass: 'text-purple-400', - badgeClass: 'bg-purple-500/15 text-purple-400 border-transparent', - }; - } - case 'error': { - return { - icon: AlertCircle, - label: 'Error', - colorClass: 'text-red-400', - badgeClass: 'bg-red-500/15 text-red-400 border-transparent', - }; - } - case 'backlog': - case 'planning': - case 'plan_ready': - case 'queued': - case 'running': - case 'paused': { - return { - icon: Clock, - label: status, - colorClass: 'text-muted-foreground', - badgeClass: 'bg-muted text-muted-foreground border-transparent', - }; - } - } -} - -// ── Sub-Components ────────────────────────────────────────────── - -interface StatusHeaderProps { - task: Task; - durationMs: number | null; -} - -function StatusHeader({ task, durationMs }: StatusHeaderProps) { - const presentation = getStatusPresentation(task.status); - const StatusIcon = presentation.icon; - - return ( - <div className="flex items-center justify-between"> - <div className="flex items-center gap-3"> - <StatusIcon className={cn('h-5 w-5 shrink-0', presentation.colorClass)} /> - <div> - <div className="text-foreground text-sm font-medium">Execution Result</div> - {durationMs === null ? null : ( - <div className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs"> - <Clock className="h-3 w-3 shrink-0" /> - <span>Duration: {formatDuration(durationMs)}</span> - </div> - )} - </div> - </div> - <Badge className={cn(presentation.badgeClass)} size="sm" variant="outline"> - {presentation.label} - </Badge> - </div> - ); -} - -interface CostSummaryProps { - metadata: Record<string, unknown> | undefined; -} - -function CostSummary({ metadata }: CostSummaryProps) { - const costUsd = typeof metadata?.costUsd === 'number' ? metadata.costUsd : null; - const costTokens = typeof metadata?.costTokens === 'number' ? metadata.costTokens : null; - const inputTokens = - typeof metadata?.inputTokens === 'number' ? metadata.inputTokens : null; - const outputTokens = - typeof metadata?.outputTokens === 'number' ? metadata.outputTokens : null; - - const showBreakdown = inputTokens !== null || outputTokens !== null; - - return ( - <div className="flex items-center gap-4"> - <Coins className="text-muted-foreground h-4 w-4 shrink-0" /> - <div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-sm"> - {showBreakdown ? ( - <> - {inputTokens === null ? null : ( - <div> - <span className="text-muted-foreground mr-1.5">Input:</span> - <span className="text-foreground font-medium"> - {tokenFormatter.format(inputTokens)} - </span> - </div> - )} - {outputTokens === null ? null : ( - <div> - <span className="text-muted-foreground mr-1.5">Output:</span> - <span className="text-foreground font-medium"> - {tokenFormatter.format(outputTokens)} - </span> - </div> - )} - </> - ) : ( - <div> - <span className="text-muted-foreground mr-1.5">Tokens:</span> - <span className="text-foreground font-medium"> - {costTokens === null ? '--' : tokenFormatter.format(costTokens)} - </span> - </div> - )} - <div> - <span className="text-muted-foreground mr-1.5">Cost:</span> - <span className="text-foreground font-medium"> - {costUsd === null ? '--' : formatCostUsd(costUsd)} - </span> - </div> - </div> - </div> - ); -} - -interface LogSummaryProps { - logs: string[]; - taskId: string; -} - -function LogSummary({ logs, taskId }: LogSummaryProps) { - const [isExpanded, setIsExpanded] = useState(false); - - if (logs.length === 0) { - return ( - <div className="text-muted-foreground flex items-center gap-2 text-xs italic"> - <ScrollText className="h-3.5 w-3.5 shrink-0" /> - <span>No execution logs recorded</span> - </div> - ); - } - - const hasMore = logs.length > MAX_LOG_LINES_COLLAPSED; - const displayedLogs = isExpanded ? logs : logs.slice(-MAX_LOG_LINES_COLLAPSED); - - return ( - <Collapsible open={isExpanded} onOpenChange={setIsExpanded}> - <div className="flex items-center justify-between"> - <div className="text-muted-foreground mb-1 text-xs font-medium"> - Log Summary ({logs.length} {logs.length === 1 ? 'line' : 'lines'}) - </div> - {hasMore ? ( - <CollapsibleTrigger asChild> - <Button className="h-6 text-xs" size="sm" variant="ghost"> - {isExpanded ? ( - <> - <ChevronUp className="mr-1 h-3 w-3" /> - Show less - </> - ) : ( - <> - <ChevronDown className="mr-1 h-3 w-3" /> - Show all ({logs.length}) - </> - )} - </Button> - </CollapsibleTrigger> - ) : null} - </div> - <CollapsibleContent forceMount> - <ScrollArea className="bg-muted/30 border-border max-h-48 rounded border"> - <div className="p-2"> - {displayedLogs.map((entry, index) => ( - <div - key={`${taskId}-log-${String(index)}`} - className="text-muted-foreground font-mono text-[11px] leading-relaxed" - > - <span className="text-foreground/50 mr-2 select-none">></span> - {entry} - </div> - ))} - </div> - </ScrollArea> - </CollapsibleContent> - </Collapsible> - ); -} - -interface ActionButtonsProps { - task: Task; -} - -function ActionButtons({ task }: ActionButtonsProps) { - const [isPrDialogOpen, setIsPrDialogOpen] = useState(false); - - const hasBranch = typeof task.metadata?.branch === 'string'; - const prUrl = typeof task.metadata?.prUrl === 'string' ? task.metadata.prUrl : null; - const hasPr = prUrl !== null || typeof task.prStatus?.prNumber === 'number'; - const projectPath = - typeof task.metadata?.worktreePath === 'string' ? task.metadata.worktreePath : ''; - const canCreatePr = hasBranch && projectPath.length > 0; - - return ( - <div className="flex items-center gap-2"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <span className="inline-flex"> - <Button - disabled={!hasBranch} - size="sm" - variant="outline" - onClick={() => { - // Placeholder: will navigate to merge diff view once route is wired - }} - > - <ExternalLink className="mr-1.5 h-3.5 w-3.5" /> - View Diff - </Button> - </span> - </TooltipTrigger> - {hasBranch ? null : ( - <TooltipContent> - <span>No branch available for diff</span> - </TooltipContent> - )} - </Tooltip> - </TooltipProvider> - - {hasPr && prUrl ? ( - <Button - size="sm" - variant="outline" - onClick={() => { - void window.open(prUrl, '_blank'); - }} - > - <ExternalLink className="mr-1.5 h-3.5 w-3.5" /> - Open PR - </Button> - ) : ( - <> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <span className="inline-flex"> - <Button - disabled={!canCreatePr} - size="sm" - variant="outline" - onClick={() => setIsPrDialogOpen(true)} - > - <GitPullRequestDraft className="mr-1.5 h-3.5 w-3.5" /> - Create PR - </Button> - </span> - </TooltipTrigger> - {canCreatePr ? null : ( - <TooltipContent> - <span> - {hasBranch ? 'No project path available' : 'No branch available'} - </span> - </TooltipContent> - )} - </Tooltip> - </TooltipProvider> - <CreatePrDialog - open={isPrDialogOpen} - projectPath={projectPath} - taskDescription={task.description} - taskName={task.title} - onOpenChange={setIsPrDialogOpen} - /> - </> - )} - </div> - ); -} - -// ── Main Component ────────────────────────────────────────────── - -interface TaskResultViewProps { - task: Task; -} - -export function TaskResultView({ task }: TaskResultViewProps) { - const isResultStatus = RESULT_STATUSES.has(task.status); - - const durationMs = useMemo(() => { - const startIso = task.executionProgress?.startedAt ?? task.createdAt; - const endIso = task.updatedAt; - return computeDuration(startIso, endIso); - }, [task.createdAt, task.executionProgress?.startedAt, task.updatedAt]); - - if (!isResultStatus) { - return null; - } - - const logs = task.logs ?? []; - const metadata = task.metadata as Record<string, unknown> | undefined; - - return ( - <Card className="border-border bg-card/50"> - <CardContent className="space-y-4 p-4"> - {/* Status + Duration */} - <StatusHeader durationMs={durationMs} task={task} /> - - {/* Cost Breakdown */} - <div className="border-border border-t pt-3"> - <CostSummary metadata={metadata} /> - </div> - - {/* Log Summary */} - <div className="border-border border-t pt-3"> - <LogSummary logs={logs} taskId={task.id} /> - </div> - - {/* Action Buttons */} - <div className="border-border border-t pt-3"> - <ActionButtons task={task} /> - </div> - </CardContent> - </Card> - ); -} diff --git a/src/renderer/features/tasks/components/detail/TestPhasePanel.tsx b/src/renderer/features/tasks/components/detail/TestPhasePanel.tsx new file mode 100644 index 00000000..ca7b0de7 --- /dev/null +++ b/src/renderer/features/tasks/components/detail/TestPhasePanel.tsx @@ -0,0 +1,363 @@ +/** + * TestPhasePanel — select test scripts, run them in batch, and view + * results within the context of a task's pipeline. + * + * Uses the test-suite domain's IPC channels and hooks for script + * listing, run execution, and output streaming. The "Attach to Task" + * button is a placeholder — cross-domain attachment IPC will be added + * in a follow-up task. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { CheckCircle, Play, XCircle } from 'lucide-react'; + +import { TEST_SUITE_EVENTS } from '@shared/ipc/test-suite/channels'; + +import { useIpcEvent } from '@renderer/shared/hooks'; +import { useLayoutStore } from '@renderer/shared/stores/layout-store'; + +import { Badge, Button, Checkbox, Label, Spinner, Stack, Text } from '@ui'; + +import { useAttachRunToTask } from '@features/test-suite/api/useAttachRunToTask'; +import { useRunScript } from '@features/test-suite/api/useRuns'; +import { useTestSuiteScripts } from '@features/test-suite/api/useTestSuiteScripts'; +import { useRunOutput } from '@features/test-suite/hooks/useRunOutput'; + +// ─── Types ────────────────────────────────────────────── + +interface TestPhasePanelProps { + taskId: string; +} + +interface RunResult { + scriptId: string; + scriptName: string; + runId: string; + status: 'running' | 'passed' | 'failed' | 'cancelled'; +} + +// ─── Helpers ──────────────────────────────────────────── + +function getOutputLineClass(line: string): string { + if (line.includes('\u2713') || line.includes('passed')) return 'text-green-500'; + if (line.includes('\u2717') || line.includes('failed') || line.includes('Error')) + return 'text-destructive'; + return 'text-text-muted'; +} + +type OverallStatus = 'idle' | 'running' | 'passed' | 'failed' | 'mixed'; + +function overallStatus(results: RunResult[]): OverallStatus { + if (results.length === 0) return 'idle'; + const statuses = new Set(results.map((r) => r.status)); + if (statuses.has('running')) return 'running'; + if (statuses.has('failed') || statuses.has('cancelled')) return 'failed'; + if (statuses.size === 1 && statuses.has('passed')) return 'passed'; + return 'mixed'; +} + +function statusBadgeVariant( + status: OverallStatus, +): 'default' | 'destructive' | 'secondary' | 'success' { + switch (status) { + case 'passed': return 'success'; + case 'failed': return 'destructive'; + case 'running': return 'secondary'; + case 'idle': return 'default'; + case 'mixed': return 'default'; + } +} + +function statusLabel(status: OverallStatus): string { + switch (status) { + case 'idle': return 'Not Started'; + case 'running': return 'Running'; + case 'passed': return 'All Passed'; + case 'failed': return 'Failed'; + case 'mixed': return 'Mixed Results'; + } +} + +// ─── Sub-components ───────────────────────────────────── + +function RunStatusIcon({ status }: { status: RunResult['status'] }) { + switch (status) { + case 'running': return <Spinner className="shrink-0" size="sm" />; + case 'passed': return <CheckCircle className="h-4 w-4 shrink-0 text-green-500" />; + case 'failed': return <XCircle className="h-4 w-4 shrink-0 text-destructive" />; + case 'cancelled': return <XCircle className="h-4 w-4 shrink-0 text-text-muted" />; + } +} + +function ScriptListLoading() { + return ( + <div className="flex items-center justify-center py-8"> + <Spinner size="sm" /> + </div> + ); +} + +function ScriptListEmpty() { + return ( + <div className="px-3 py-8 text-center"> + <Text size="sm" variant="muted"> + No test scripts found. Create scripts in the Test Suite tab first. + </Text> + </div> + ); +} + +// ─── Component ────────────────────────────────────────── + +export function TestPhasePanel({ taskId }: TestPhasePanelProps) { + const activeProjectId = useLayoutStore((s) => s.activeProjectId); + const { data: scripts = [], isLoading: scriptsLoading } = useTestSuiteScripts(activeProjectId); + const runScript = useRunScript(); + const attachRun = useAttachRunToTask(); + + const [selected, setSelected] = useState<Set<string>>(new Set()); + const [results, setResults] = useState<RunResult[]>([]); + const [activeRunId, setActiveRunId] = useState<string | null>(null); + + const { lines, clear: clearOutput } = useRunOutput(activeRunId); + const outputRef = useRef<HTMLDivElement>(null); + + // Auto-scroll output + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [lines.length]); + + // Listen for run completion events to update result statuses + useIpcEvent(TEST_SUITE_EVENTS.RUN.COMPLETED, (payload) => { + setResults((prev) => + prev.map((r) => + r.runId === payload.runId ? { ...r, status: payload.status } : r, + ), + ); + }); + + // ── Selection handlers ────────────────────────────────── + + const toggleScript = useCallback((scriptId: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(scriptId)) { + next.delete(scriptId); + } else { + next.add(scriptId); + } + return next; + }); + }, []); + + const selectAll = useCallback(() => { + setSelected(new Set(scripts.map((s) => s.id))); + }, [scripts]); + + const clearSelection = useCallback(() => { + setSelected(new Set()); + }, []); + + // ── Run handler ───────────────────────────────────────── + + const isRunning = results.some((r) => r.status === 'running'); + + const handleStartTestPhase = useCallback(async () => { + if (selected.size === 0) return; + + clearOutput(); + const newResults: RunResult[] = []; + + // Run scripts sequentially so output is readable + for (const scriptId of selected) { + const script = scripts.find((s) => s.id === scriptId); + if (!script) continue; + + try { + const { runId } = await runScript.mutateAsync({ + scriptId, + triggeredBy: 'manual', + }); + + const result: RunResult = { + scriptId, + scriptName: script.name, + runId, + status: 'running', + }; + newResults.push(result); + setResults([...newResults]); + setActiveRunId(runId); + } catch { + newResults.push({ + scriptId, + scriptName: script.name, + runId: '', + status: 'failed', + }); + setResults([...newResults]); + } + } + }, [selected, scripts, runScript, clearOutput]); + + // ── Derived state ─────────────────────────────────────── + + const status = overallStatus(results); + const latestRunId = results.at(-1)?.runId ?? null; + const canAttach = latestRunId !== null && !isRunning && status !== 'idle'; + + // ── Render ────────────────────────────────────────────── + + if (!activeProjectId) { + return ( + <Stack className="items-center justify-center py-12" gap="sm"> + <Text size="sm" variant="muted">No active project selected.</Text> + </Stack> + ); + } + + return ( + <div className="flex min-h-0 gap-0"> + {/* ── Left Column: Script Selection ────────── */} + <div className="flex w-80 shrink-0 flex-col border-r border-border"> + {/* Status badge */} + <div className="border-b border-border px-3 py-2"> + <Badge size="sm" variant={statusBadgeVariant(status)}> + {statusLabel(status)} + </Badge> + </div> + + {/* Script list */} + <div className="flex-1 overflow-y-auto"> + {scriptsLoading ? <ScriptListLoading /> : null} + {!scriptsLoading && scripts.length === 0 ? <ScriptListEmpty /> : null} + {!scriptsLoading && scripts.length > 0 ? ( + <div className="flex flex-col"> + {scripts.map((script) => { + const result = results.find((r) => r.scriptId === script.id); + return ( + <Label + key={script.id} + className="flex cursor-pointer items-center gap-3 border-b border-border px-3 py-2.5 hover:bg-muted/50" + > + <Checkbox + checked={selected.has(script.id)} + disabled={isRunning} + size="sm" + onCheckedChange={() => { toggleScript(script.id); }} + /> + <div className="min-w-0 flex-1"> + <Text className="truncate" size="sm"> + {script.name} + </Text> + {script.description ? ( + <Text className="truncate" size="sm" variant="muted"> + {script.description} + </Text> + ) : null} + </div> + {result ? <RunStatusIcon status={result.status} /> : null} + </Label> + ); + })} + </div> + ) : null} + </div> + + {/* Footer actions */} + <div className="border-t border-border px-3 py-2"> + <div className="mb-2 flex gap-2"> + <Button + disabled={isRunning || scripts.length === 0} + size="sm" + variant="ghost" + onClick={selectAll} + > + Select All + </Button> + <Button + disabled={isRunning || selected.size === 0} + size="sm" + variant="ghost" + onClick={clearSelection} + > + Clear + </Button> + </div> + <Button + className="w-full" + disabled={selected.size === 0 || isRunning} + size="sm" + variant="primary" + onClick={() => { void handleStartTestPhase(); }} + > + <Play className="mr-1.5 h-3.5 w-3.5" /> + Start Test Phase ({selected.size}) + </Button> + + <Button + className="mt-2 w-full" + disabled={!canAttach || attachRun.isPending} + size="sm" + variant="outline" + onClick={() => { + if (latestRunId) { + attachRun.mutate({ runId: latestRunId, taskId }); + } + }} + > + {attachRun.isPending ? 'Attaching...' : 'Attach to Task'} + </Button> + </div> + </div> + + {/* ── Right Column: Output ─────────────────── */} + <div className="flex min-w-0 flex-1 flex-col"> + {results.length === 0 ? ( + <div className="flex flex-1 items-center justify-center"> + <Stack className="items-center" gap="sm"> + <Text size="sm" variant="muted"> + Select test scripts and click Start Test Phase to begin. + </Text> + </Stack> + </div> + ) : ( + <> + {/* Run summary bar */} + <div className="flex items-center gap-2 border-b border-border px-4 py-2"> + <Text className="font-medium" size="sm"> + Results + </Text> + <Badge size="sm" variant="secondary"> + {results.filter((r) => r.status === 'passed').length}/{results.length} passed + </Badge> + {results.some((r) => r.status === 'failed') ? ( + <Badge size="sm" variant="destructive"> + {results.filter((r) => r.status === 'failed').length} failed + </Badge> + ) : null} + </div> + + {/* Output stream */} + <div ref={outputRef} className="flex-1 overflow-y-auto bg-bg-surface p-3"> + <pre className="whitespace-pre-wrap font-mono text-xs"> + {lines.length > 0 ? ( + lines.map((l) => ( + <div key={l.timestamp} className={getOutputLineClass(l.line)}> + {l.line} + </div> + )) + ) : ( + <span className="text-text-muted">Waiting for output...</span> + )} + </pre> + </div> + </> + )} + </div> + </div> + ); +} diff --git a/src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx b/src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx index 96b0a4ac..bb654dd1 100644 --- a/src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx +++ b/src/renderer/features/tasks/components/grid/ProgressTaskGrid.tsx @@ -19,6 +19,7 @@ import { Archive, ArrowUpDown, ChevronDown, ChevronRight, Pencil, Play, Plus } f import type { ProgressPriority, ProgressStatus, ProgressTask } from '@shared/types/progress'; import { RelativeTime } from '@renderer/shared/components/RelativeTime'; +import { useLooseParams } from '@renderer/shared/hooks'; import { cn, formatRelativeTime } from '@renderer/shared/lib/utils'; import { @@ -658,8 +659,10 @@ function createProgressColumns( // ── Main component ───────────────────────────────────────── export function ProgressTaskGrid() { + const { projectId } = useLooseParams(); + // Data hooks - const { data: tasks = [], isLoading } = useProgressTasks(); + const { data: tasks = [], isLoading } = useProgressTasks(projectId); const createTaskMutation = useCreateProgressTask(); const runWorkflowMutation = useRunWorkflow(); const archiveTaskMutation = useArchiveProgressTask(); @@ -840,14 +843,14 @@ export function ProgressTaskGrid() { if (isLoading) { return ( - <div className="flex flex-1 items-center justify-center"> + <div className="flex h-full items-center justify-center"> <Spinner size="lg" /> </div> ); } return ( - <div className="flex flex-1 flex-col gap-0 overflow-hidden"> + <div className="flex h-full w-full min-w-0 flex-col gap-0 overflow-hidden"> {/* Toolbar */} <div className="flex flex-wrap items-center gap-2 border-b px-4 py-3"> <SearchInput @@ -981,7 +984,13 @@ export function ProgressTaskGrid() { open={newTaskDialogOpen} onOpenChange={setNewTaskDialogOpen} onCreate={async (slug, title, description, priority) => { - await createTaskMutation.mutateAsync({ slug, title, description, priority }); + await createTaskMutation.mutateAsync({ + slug, + title, + description, + priority, + projectId, + }); }} /> diff --git a/src/renderer/features/tasks/hooks/useTaskEvents.ts b/src/renderer/features/tasks/hooks/useTaskEvents.ts deleted file mode 100644 index e812f0d5..00000000 --- a/src/renderer/features/tasks/hooks/useTaskEvents.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Task IPC event listeners → query invalidation - * - * Bridges real-time events from the main process to React Query cache. - * Handles both local task events and Hub entity task events (synced from other devices). - */ - -import { useQueryClient } from '@tanstack/react-query'; - -import { HUB_TASKS_EVENTS, TASKS_EVENTS } from '@shared/ipc/tasks/channels'; -import type { Task } from '@shared/types'; - -import { useHubEvent, useIpcEvent } from '@renderer/shared/hooks'; -import { useToastStore } from '@renderer/shared/stores'; - -import { taskKeys } from '../api/queryKeys'; - -import { useAgentEvents } from './useAgentEvents'; -import { useQaEvents } from './useQaEvents'; - -export function useTaskEvents() { - // Agent orchestrator events (planning, execution, watchdog) - useAgentEvents(); - // QA session events (started, progress, completed) - useQaEvents(); - // NOTE: Workflow progress events (event:task.progressUpdated) are already - // handled below — do NOT call useWorkflowEvents() here to avoid duplicate handlers. - const queryClient = useQueryClient(); - - // ── Local task events ── - - // Task status changed → invalidate list and detail - useIpcEvent(TASKS_EVENTS.STATUS.CHANGED, ({ taskId, projectId }) => { - void queryClient.invalidateQueries({ queryKey: taskKeys.list(projectId) }); - void queryClient.invalidateQueries({ queryKey: taskKeys.detail(taskId) }); - }); - - // Task progress updated → patch detail cache directly - useIpcEvent(TASKS_EVENTS.PROGRESS.UPDATED, ({ taskId, progress }) => { - queryClient.setQueryData<Task>(taskKeys.detail(taskId), (old) => - old ? { ...old, executionProgress: progress } : old, - ); - // Also invalidate lists to update progress indicators on cards - void queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }); - - // Log appended → patch detail cache - useIpcEvent(TASKS_EVENTS.LOG.APPENDED, ({ taskId, log }) => { - queryClient.setQueryData<Task>(taskKeys.detail(taskId), (old) => - old ? { ...old, logs: [...(old.logs ?? []), log] } : old, - ); - }); - - // Plan updated → invalidate detail - useIpcEvent(TASKS_EVENTS.PLAN.UPDATED, ({ taskId }) => { - void queryClient.invalidateQueries({ queryKey: taskKeys.detail(taskId) }); - }); - - // ── Hub entity task events (synced from other devices) ── - - // Task created on another device → invalidate list - useHubEvent(HUB_TASKS_EVENTS.TASK.CREATED, ({ projectId }) => { - void queryClient.invalidateQueries({ queryKey: taskKeys.list(projectId) }); - }); - - // Task updated on another device → invalidate list and detail - useHubEvent(HUB_TASKS_EVENTS.TASK.UPDATED, ({ taskId, projectId }) => { - void queryClient.invalidateQueries({ queryKey: taskKeys.list(projectId) }); - void queryClient.invalidateQueries({ queryKey: taskKeys.detail(taskId) }); - }); - - // Task deleted on another device → invalidate list - useHubEvent(HUB_TASKS_EVENTS.TASK.DELETED, ({ projectId }) => { - void queryClient.invalidateQueries({ queryKey: taskKeys.list(projectId) }); - }); - - // Task progress from another device → patch detail cache directly - useHubEvent(HUB_TASKS_EVENTS.PROGRESS.UPDATED, ({ taskId, progress, phase }) => { - queryClient.setQueryData<Task>(taskKeys.detail(taskId), (old) => { - if (!old) return old; - const existing = old.executionProgress ?? { - phase: 'idle' as const, - phaseProgress: 0, - overallProgress: 0, - }; - return { - ...old, - executionProgress: { ...existing, overallProgress: progress, message: phase }, - }; - }); - void queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }); - - // Task completed on another device → invalidate lists, detail, and show toast - const addToast = useToastStore((s) => s.addToast); - useHubEvent(HUB_TASKS_EVENTS.TASK_RUN.COMPLETED, ({ taskId, projectId, result: _result }) => { - void queryClient.invalidateQueries({ queryKey: taskKeys.list(projectId) }); - void queryClient.invalidateQueries({ queryKey: taskKeys.detail(taskId) }); - addToast(`Task ${taskId} completed`, 'success'); - }); -} diff --git a/src/renderer/features/tasks/index.ts b/src/renderer/features/tasks/index.ts index 5759c924..f1c543d3 100644 --- a/src/renderer/features/tasks/index.ts +++ b/src/renderer/features/tasks/index.ts @@ -3,12 +3,10 @@ */ // API hooks (only barrel-export hooks with external consumers) -export { useAllTasks } from './api/useTasks'; export { useCreateProgressTask } from './api/useProgressMutations'; export { taskKeys } from './api/queryKeys'; // Events -export { useTaskEvents } from './hooks/useTaskEvents'; export { useAgentEvents } from './hooks/useAgentEvents'; export { useQaEvents } from './hooks/useQaEvents'; @@ -18,6 +16,3 @@ export { useTaskUI } from './store'; // Components export { CreatePrDialog } from './components/CreatePrDialog'; export { ProgressTaskGrid } from './components/grid/ProgressTaskGrid'; -export { TaskFiltersToolbar } from './components/TaskFiltersToolbar'; -export { TaskResultView } from './components/detail/TaskResultView'; -export { TaskStatusBadge } from './components/TaskStatusBadge'; diff --git a/src/renderer/features/terminals/components/TerminalGrid.tsx b/src/renderer/features/terminals/components/TerminalGrid.tsx index e4e9d4f4..ae17f505 100644 --- a/src/renderer/features/terminals/components/TerminalGrid.tsx +++ b/src/renderer/features/terminals/components/TerminalGrid.tsx @@ -1,132 +1 @@ -/** - * TerminalGrid — Multi-terminal view with tabs - * - * Uses xterm.js for terminal rendering via TerminalInstance. - */ - -import { useEffect } from 'react'; - -import { Loader2, Plus, Terminal as TerminalIcon, X } from 'lucide-react'; - -import { useLooseParams } from '@renderer/shared/hooks'; -import { cn } from '@renderer/shared/lib/utils'; - -import { Button, EmptyState } from '@ui'; - -import { useTerminals, useCreateTerminal, useCloseTerminal } from '../api/useTerminals'; -import { useTerminalEvents } from '../hooks/useTerminalEvents'; -import { useTerminalUI } from '../store'; - -import { TerminalInstance } from './TerminalInstance'; - -export function TerminalGrid() { - const params = useLooseParams(); - const { data: terminals, isLoading } = useTerminals(); - const createTerminal = useCreateTerminal(); - const closeTerminal = useCloseTerminal(); - const { activeTerminalId, setActiveTerminal } = useTerminalUI(); - - // Register real-time event listeners - useTerminalEvents(); - - // Auto-select first terminal if none active - useEffect(() => { - if (!activeTerminalId && terminals && terminals.length > 0) { - setActiveTerminal(terminals[0].id); - } - }, [activeTerminalId, terminals, setActiveTerminal]); - - function handleCreateTerminal() { - createTerminal.mutate({ - cwd: '~', - projectPath: params.projectId, - }); - } - - function handleCloseTerminal(e: React.MouseEvent, sessionId: string) { - e.stopPropagation(); - closeTerminal.mutate(sessionId); - // If closing active terminal, switch to next one - if (activeTerminalId === sessionId) { - const remaining = terminals?.filter((t) => t.id !== sessionId); - setActiveTerminal(remaining?.[0]?.id ?? null); - } - } - - if (isLoading) { - return ( - <div className="flex h-full items-center justify-center"> - <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" /> - </div> - ); - } - - return ( - <div className="flex h-full flex-col"> - {/* Terminal tabs */} - <div className="border-border bg-card flex items-center gap-px border-b px-2"> - {terminals?.map((term) => ( - <Button - key={term.id} - variant="ghost" - className={cn( - 'group flex h-auto items-center gap-2 rounded-none px-3 py-2 text-sm transition-colors', - activeTerminalId === term.id - ? 'border-primary text-foreground border-b-2' - : 'text-muted-foreground hover:text-foreground', - )} - onClick={() => setActiveTerminal(term.id)} - > - <TerminalIcon className="h-3.5 w-3.5" /> - <span>{term.name || `Terminal ${term.id.slice(0, 6)}`}</span> - <span - className="hover:bg-muted rounded p-0.5 opacity-0 group-hover:opacity-100" - role="button" - tabIndex={0} - onClick={(e) => handleCloseTerminal(e, term.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleCloseTerminal(e as unknown as React.MouseEvent, term.id); - } - }} - > - <X className="h-3 w-3" /> - </span> - </Button> - ))} - <Button - className="text-muted-foreground hover:bg-accent hover:text-foreground ml-1 rounded p-1.5" - size="icon" - title="New terminal" - variant="ghost" - onClick={handleCreateTerminal} - > - <Plus className="h-4 w-4" /> - </Button> - </div> - - {/* Terminal content area */} - <div className="relative flex-1 bg-background"> - {terminals?.map((term) => ( - <TerminalInstance key={term.id} isActive={term.id === activeTerminalId} session={term} /> - ))} - - {/* Empty state */} - {(terminals === undefined || terminals.length === 0) ? ( - <EmptyState - className="h-full" - icon={TerminalIcon} - size="sm" - title="No terminal open" - action={ - <Button size="sm" variant="secondary" onClick={handleCreateTerminal}> - Create Terminal - </Button> - } - /> - ) : null} - </div> - </div> - ); -} +export { TerminalGrid } from './TerminalGrid/index'; diff --git a/src/renderer/features/terminals/components/TerminalGrid/TerminalGrid.tsx b/src/renderer/features/terminals/components/TerminalGrid/TerminalGrid.tsx new file mode 100644 index 00000000..b0ca9a32 --- /dev/null +++ b/src/renderer/features/terminals/components/TerminalGrid/TerminalGrid.tsx @@ -0,0 +1,103 @@ +/** + * TerminalGrid — Multi-terminal view with tabs + * + * Uses xterm.js for terminal rendering via TerminalInstance. + */ + +import { Loader2, Plus, Terminal as TerminalIcon, X } from 'lucide-react'; + +import { cn } from '@renderer/shared/lib/utils'; + +import { Button, EmptyState } from '@ui'; + +import { TerminalInstance } from '../TerminalInstance'; + +import { useTerminalGrid } from './useTerminalGrid'; + +export function TerminalGrid() { + const { + terminals, + isLoading, + activeTerminalId, + setActiveTerminal, + handleCreateTerminal, + handleCloseTerminal, + } = useTerminalGrid(); + + if (isLoading) { + return ( + <div className="flex h-full items-center justify-center"> + <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" /> + </div> + ); + } + + return ( + <div className="flex h-full flex-col"> + {/* Terminal tabs */} + <div className="border-border bg-card flex items-center gap-px border-b px-2"> + {terminals?.map((term) => ( + <Button + key={term.id} + variant="ghost" + className={cn( + 'group flex h-auto items-center gap-2 rounded-none px-3 py-2 text-sm transition-colors', + activeTerminalId === term.id + ? 'border-primary text-foreground border-b-2' + : 'text-muted-foreground hover:text-foreground', + )} + onClick={() => setActiveTerminal(term.id)} + > + <TerminalIcon className="h-3.5 w-3.5" /> + <span>{term.name || `Terminal ${term.id.slice(0, 6)}`}</span> + <span + className="hover:bg-muted rounded p-0.5 opacity-0 group-hover:opacity-100" + role="button" + tabIndex={0} + onClick={(e) => handleCloseTerminal(e, term.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleCloseTerminal(e as unknown as React.MouseEvent, term.id); + } + }} + > + <X className="h-3 w-3" /> + </span> + </Button> + ))} + <Button + className="text-muted-foreground hover:bg-accent hover:text-foreground ml-1 rounded p-1.5" + size="icon" + title="New terminal" + variant="ghost" + onClick={handleCreateTerminal} + > + <Plus className="h-4 w-4" /> + </Button> + </div> + + {/* Terminal content area */} + <div className="relative flex-1 bg-background"> + {terminals?.map((term) => ( + <TerminalInstance key={term.id} isActive={term.id === activeTerminalId} session={term} /> + ))} + + {/* Empty state */} + {(terminals === undefined || terminals.length === 0) ? ( + <EmptyState + className="h-full" + icon={TerminalIcon} + size="sm" + title="No terminal open" + action={ + <Button size="sm" variant="secondary" onClick={handleCreateTerminal}> + Create Terminal + </Button> + } + /> + ) : null} + </div> + </div> + ); +} diff --git a/src/renderer/features/terminals/components/TerminalGrid/index.ts b/src/renderer/features/terminals/components/TerminalGrid/index.ts new file mode 100644 index 00000000..663bb217 --- /dev/null +++ b/src/renderer/features/terminals/components/TerminalGrid/index.ts @@ -0,0 +1,2 @@ +export { TerminalGrid } from './TerminalGrid'; +export { useTerminalGrid } from './useTerminalGrid'; diff --git a/src/renderer/features/terminals/components/TerminalGrid/useTerminalGrid.ts b/src/renderer/features/terminals/components/TerminalGrid/useTerminalGrid.ts new file mode 100644 index 00000000..dc489c5a --- /dev/null +++ b/src/renderer/features/terminals/components/TerminalGrid/useTerminalGrid.ts @@ -0,0 +1,48 @@ +import { useEffect } from 'react'; + +import { useLooseParams } from '@renderer/shared/hooks'; + +import { useTerminals, useCreateTerminal, useCloseTerminal } from '../../api/useTerminals'; +import { useTerminalEvents } from '../../hooks/useTerminalEvents'; +import { useTerminalUI } from '../../store'; + +export function useTerminalGrid() { + const params = useLooseParams(); + const { data: terminals, isLoading } = useTerminals(); + const createTerminal = useCreateTerminal(); + const closeTerminal = useCloseTerminal(); + const { activeTerminalId, setActiveTerminal } = useTerminalUI(); + + useTerminalEvents(); + + useEffect(() => { + if (!activeTerminalId && terminals && terminals.length > 0) { + setActiveTerminal(terminals[0].id); + } + }, [activeTerminalId, terminals, setActiveTerminal]); + + function handleCreateTerminal() { + createTerminal.mutate({ + cwd: '~', + projectPath: params.projectId, + }); + } + + function handleCloseTerminal(e: React.MouseEvent, sessionId: string) { + e.stopPropagation(); + closeTerminal.mutate(sessionId); + if (activeTerminalId === sessionId) { + const remaining = terminals?.filter((t) => t.id !== sessionId); + setActiveTerminal(remaining?.[0]?.id ?? null); + } + } + + return { + terminals, + isLoading, + activeTerminalId, + setActiveTerminal, + handleCreateTerminal, + handleCloseTerminal, + }; +} diff --git a/src/renderer/features/terminals/components/TerminalInstance.tsx b/src/renderer/features/terminals/components/TerminalInstance.tsx index c40b732c..150d3132 100644 --- a/src/renderer/features/terminals/components/TerminalInstance.tsx +++ b/src/renderer/features/terminals/components/TerminalInstance.tsx @@ -1,173 +1 @@ -/** - * TerminalInstance — Single xterm.js terminal - * - * Mounts an xterm.js instance, connects it to IPC for I/O, - * and handles resize/fit. - */ - -import { useEffect, useRef } from 'react'; - -import { FitAddon } from '@xterm/addon-fit'; -import { WebLinksAddon } from '@xterm/addon-web-links'; -import { WebglAddon } from '@xterm/addon-webgl'; -import { Terminal as XTerm } from '@xterm/xterm'; - -import { TERMINALS, TERMINALS_EVENTS } from '@shared/ipc/terminals/channels'; -import type { TerminalSession } from '@shared/types'; - -import { useIpcEvent } from '@renderer/shared/hooks'; -import { ipc } from '@renderer/shared/lib/ipc'; -import { useThemeStore } from '@renderer/shared/stores/theme-store'; - -interface TerminalInstanceProps { - session: TerminalSession; - isActive: boolean; -} - -/** - * Reads a CSS custom property from the document root and returns its value. - * Falls back to the provided default if the property is not set. - */ -function getCssVar(name: string, fallback: string): string { - const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); - return value || fallback; -} - -/** - * Builds an xterm.js ITheme from the active CSS custom properties. - * This ensures the terminal adapts to whichever color theme is active. - */ -function getTerminalTheme(): Record<string, string> { - return { - background: getCssVar('--background', '#0a0a0a'), - foreground: getCssVar('--foreground', '#e4e4e7'), - cursor: getCssVar('--foreground', '#e4e4e7'), - cursorAccent: getCssVar('--background', '#0a0a0a'), - selectionBackground: getCssVar('--muted', '#3f3f46'), - black: getCssVar('--secondary', '#18181b'), - red: getCssVar('--destructive', '#f87171'), - green: getCssVar('--success', '#4ade80'), - yellow: getCssVar('--warning', '#facc15'), - blue: getCssVar('--info', '#60a5fa'), - magenta: getCssVar('--accent-foreground', '#c084fc'), - cyan: getCssVar('--info', '#22d3ee'), - white: getCssVar('--foreground', '#e4e4e7'), - brightBlack: getCssVar('--muted-foreground', '#52525b'), - brightRed: getCssVar('--error', '#fca5a5'), - brightGreen: getCssVar('--success', '#86efac'), - brightYellow: getCssVar('--warning', '#fde68a'), - brightBlue: getCssVar('--info', '#93c5fd'), - brightMagenta: getCssVar('--accent-foreground', '#d8b4fe'), - brightCyan: getCssVar('--info', '#67e8f9'), - brightWhite: getCssVar('--card-foreground', '#fafafa'), - }; -} - -export function TerminalInstance({ session, isActive }: TerminalInstanceProps) { - const containerRef = useRef<HTMLDivElement>(null); - const xtermRef = useRef<XTerm | null>(null); - const fitAddonRef = useRef<FitAddon | null>(null); - const colorTheme = useThemeStore((s) => s.colorTheme); - const mode = useThemeStore((s) => s.mode); - - // Initialize xterm on mount - useEffect(() => { - if (!containerRef.current) return; - - const term = new XTerm({ - theme: getTerminalTheme(), - fontSize: 13, - fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace", - cursorBlink: true, - cursorStyle: 'bar', - scrollback: 10000, - allowProposedApi: true, - }); - - const fitAddon = new FitAddon(); - term.loadAddon(fitAddon); - term.loadAddon(new WebLinksAddon()); - - // Try WebGL renderer, fall back to canvas - try { - term.loadAddon(new WebglAddon()); - } catch { - // WebGL not available, canvas renderer is fine - } - - term.open(containerRef.current); - fitAddon.fit(); - - // Send user input to main process - term.onData((data) => { - void ipc(TERMINALS.SEND.INPUT, { sessionId: session.id, data }); - }); - - xtermRef.current = term; - fitAddonRef.current = fitAddon; - - return () => { - term.dispose(); - xtermRef.current = null; - fitAddonRef.current = null; - }; - }, [session.id]); - - // Handle resize when tab becomes active or window resizes - useEffect(() => { - if (!isActive || !fitAddonRef.current || !xtermRef.current) return; - - const fit = () => { - try { - fitAddonRef.current?.fit(); - const term = xtermRef.current; - if (term) { - void ipc(TERMINALS.RESIZE.SESSION, { - sessionId: session.id, - cols: term.cols, - rows: term.rows, - }); - } - } catch { - // Ignore fit errors during transitions - } - }; - - // Fit on activation - fit(); - - // Fit on window resize - const observer = new ResizeObserver(fit); - if (containerRef.current) { - observer.observe(containerRef.current); - } - - return () => observer.disconnect(); - }, [isActive, session.id]); - - // Re-apply theme when color theme or mode changes - useEffect(() => { - if (!xtermRef.current) return; - xtermRef.current.options.theme = getTerminalTheme(); - }, [colorTheme, mode]); - - // Receive output from main process - useIpcEvent(TERMINALS_EVENTS.TERMINAL.OUTPUT, ({ sessionId, data }) => { - if (sessionId === session.id && xtermRef.current) { - xtermRef.current.write(data); - } - }); - - // Handle terminal title changes - useIpcEvent(TERMINALS_EVENTS.TERMINAL['TITLE-CHANGED'], ({ sessionId: _sessionId, title: _title }) => { - // Could update tab title through store if needed - }); - - return ( - <div - ref={containerRef} - className="h-full w-full" - style={{ display: isActive ? 'block' : 'none' }} - /> - ); -} +export { TerminalInstance } from './TerminalInstance/index'; diff --git a/src/renderer/features/terminals/components/TerminalInstance/TerminalInstance.tsx b/src/renderer/features/terminals/components/TerminalInstance/TerminalInstance.tsx new file mode 100644 index 00000000..cdf29fa2 --- /dev/null +++ b/src/renderer/features/terminals/components/TerminalInstance/TerminalInstance.tsx @@ -0,0 +1,27 @@ +/** + * TerminalInstance — Single xterm.js terminal + * + * Mounts an xterm.js instance, connects it to IPC for I/O, + * and handles resize/fit. + */ + +import type { TerminalSession } from '@shared/types'; + +import { useTerminalInstance } from './useTerminalInstance'; + +interface TerminalInstanceProps { + session: TerminalSession; + isActive: boolean; +} + +export function TerminalInstance({ session, isActive }: TerminalInstanceProps) { + const { containerRef } = useTerminalInstance({ session, isActive }); + + return ( + <div + ref={containerRef} + className="h-full w-full" + style={{ display: isActive ? 'block' : 'none' }} + /> + ); +} diff --git a/src/renderer/features/terminals/components/TerminalInstance/index.ts b/src/renderer/features/terminals/components/TerminalInstance/index.ts new file mode 100644 index 00000000..86d8e178 --- /dev/null +++ b/src/renderer/features/terminals/components/TerminalInstance/index.ts @@ -0,0 +1,2 @@ +export { TerminalInstance } from './TerminalInstance'; +export { useTerminalInstance } from './useTerminalInstance'; diff --git a/src/renderer/features/terminals/components/TerminalInstance/useTerminalInstance.ts b/src/renderer/features/terminals/components/TerminalInstance/useTerminalInstance.ts new file mode 100644 index 00000000..b6a8418e --- /dev/null +++ b/src/renderer/features/terminals/components/TerminalInstance/useTerminalInstance.ts @@ -0,0 +1,156 @@ +import { useEffect, useRef } from 'react'; + +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import { WebglAddon } from '@xterm/addon-webgl'; +import { Terminal as XTerm } from '@xterm/xterm'; + +import { TERMINALS, TERMINALS_EVENTS } from '@shared/ipc/terminals/channels'; +import type { TerminalSession } from '@shared/types'; + +import { useIpcEvent } from '@renderer/shared/hooks'; +import { ipc } from '@renderer/shared/lib/ipc'; +import { useThemeStore } from '@renderer/shared/stores/theme-store'; + +/** + * Reads a CSS custom property from the document root and returns its value. + * Falls back to the provided default if the property is not set. + */ +function getCssVar(name: string, fallback: string): string { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; +} + +/** + * Builds an xterm.js ITheme from the active CSS custom properties. + * This ensures the terminal adapts to whichever color theme is active. + */ +function getTerminalTheme(): Record<string, string> { + return { + background: getCssVar('--background', '#0a0a0a'), + foreground: getCssVar('--foreground', '#e4e4e7'), + cursor: getCssVar('--foreground', '#e4e4e7'), + cursorAccent: getCssVar('--background', '#0a0a0a'), + selectionBackground: getCssVar('--muted', '#3f3f46'), + black: getCssVar('--secondary', '#18181b'), + red: getCssVar('--destructive', '#f87171'), + green: getCssVar('--success', '#4ade80'), + yellow: getCssVar('--warning', '#facc15'), + blue: getCssVar('--info', '#60a5fa'), + magenta: getCssVar('--accent-foreground', '#c084fc'), + cyan: getCssVar('--info', '#22d3ee'), + white: getCssVar('--foreground', '#e4e4e7'), + brightBlack: getCssVar('--muted-foreground', '#52525b'), + brightRed: getCssVar('--error', '#fca5a5'), + brightGreen: getCssVar('--success', '#86efac'), + brightYellow: getCssVar('--warning', '#fde68a'), + brightBlue: getCssVar('--info', '#93c5fd'), + brightMagenta: getCssVar('--accent-foreground', '#d8b4fe'), + brightCyan: getCssVar('--info', '#67e8f9'), + brightWhite: getCssVar('--card-foreground', '#fafafa'), + }; +} + +interface UseTerminalInstanceProps { + session: TerminalSession; + isActive: boolean; +} + +export function useTerminalInstance({ session, isActive }: UseTerminalInstanceProps) { + const containerRef = useRef<HTMLDivElement>(null); + const xtermRef = useRef<XTerm | null>(null); + const fitAddonRef = useRef<FitAddon | null>(null); + const colorTheme = useThemeStore((s) => s.colorTheme); + const mode = useThemeStore((s) => s.mode); + + // Initialize xterm on mount + useEffect(() => { + if (!containerRef.current) return; + + const term = new XTerm({ + theme: getTerminalTheme(), + fontSize: 13, + fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace", + cursorBlink: true, + cursorStyle: 'bar', + scrollback: 10000, + allowProposedApi: true, + }); + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.loadAddon(new WebLinksAddon()); + + try { + term.loadAddon(new WebglAddon()); + } catch { + // WebGL not available, canvas renderer is fine + } + + term.open(containerRef.current); + fitAddon.fit(); + + term.onData((data) => { + void ipc(TERMINALS.SEND.INPUT, { sessionId: session.id, data }); + }); + + xtermRef.current = term; + fitAddonRef.current = fitAddon; + + return () => { + term.dispose(); + xtermRef.current = null; + fitAddonRef.current = null; + }; + }, [session.id]); + + // Handle resize when tab becomes active or window resizes + useEffect(() => { + if (!isActive || !fitAddonRef.current || !xtermRef.current) return; + + const fit = () => { + try { + fitAddonRef.current?.fit(); + const term = xtermRef.current; + if (term) { + void ipc(TERMINALS.RESIZE.SESSION, { + sessionId: session.id, + cols: term.cols, + rows: term.rows, + }); + } + } catch { + // Ignore fit errors during transitions + } + }; + + fit(); + + const observer = new ResizeObserver(fit); + if (containerRef.current) { + observer.observe(containerRef.current); + } + + return () => observer.disconnect(); + }, [isActive, session.id]); + + // Re-apply theme when color theme or mode changes + useEffect(() => { + if (!xtermRef.current) return; + xtermRef.current.options.theme = getTerminalTheme(); + }, [colorTheme, mode]); + + // Receive output from main process + useIpcEvent(TERMINALS_EVENTS.TERMINAL.OUTPUT, ({ sessionId, data }) => { + if (sessionId === session.id && xtermRef.current) { + xtermRef.current.write(data); + } + }); + + // Handle terminal title changes + useIpcEvent(TERMINALS_EVENTS.TERMINAL['TITLE-CHANGED'], ({ sessionId: _sessionId, title: _title }) => { + // Could update tab title through store if needed + }); + + return { containerRef }; +} diff --git a/src/renderer/features/test-suite/api/index.ts b/src/renderer/features/test-suite/api/index.ts new file mode 100644 index 00000000..ee89437a --- /dev/null +++ b/src/renderer/features/test-suite/api/index.ts @@ -0,0 +1,21 @@ +export { testSuiteKeys } from './testSuiteKeys'; +export { useAttachRunToTask } from './useAttachRunToTask'; +export { useBaselines, useSetBaseline, useDeleteBaselines, useRunDiffs, useCompareDiffs } from './useBaselines'; +export { useParseDataFile, useExecuteDataRun } from './useDataRun'; +export { useDeleteScript } from './useDeleteScript'; +export { useDeleteTestSuiteConfig } from './useDeleteTestSuiteConfig'; +export { useExportRun } from './useExportRun'; +export { useRuns, useRun, useRunScript, useBatchRun } from './useRuns'; +export { useSaveScript } from './useSaveScript'; +export { useSaveTestSuiteConfig } from './useSaveTestSuiteConfig'; +export { useSchedules, useCreateSchedule, useUpdateSchedule, useDeleteSchedule, useTriggerScheduleNow } from './useSchedules'; +export { useSetActiveTestSuiteConfig } from './useSetActiveTestSuiteConfig'; +export { useSharedSteps, useSharedStepDomains, useCreateSharedSteps, useUpdateSharedSteps, useDeleteSharedSteps } from './useSharedSteps'; +export { useStartRecording } from './useStartRecording'; +export { useStopRecording } from './useStopRecording'; +export { useAnalyticsSummary, useAnalyticsTrend, useTopFailures, useSlowestTests, useErrorPatterns, useFlakyTests, useRunHistory } from './useTestSuiteAnalytics'; +export { useTestSuiteConfig } from './useTestSuiteConfig'; +export { useTestSuiteConfigs } from './useTestSuiteConfigs'; +export { useTestSuiteScreenshots } from './useTestSuiteScreenshots'; +export { useTestSuiteScripts } from './useTestSuiteScripts'; +export { useWatchedScripts, useStartWatch, useStopWatch } from './useWatchMode'; diff --git a/src/renderer/features/test-suite/api/testSuiteKeys.ts b/src/renderer/features/test-suite/api/testSuiteKeys.ts new file mode 100644 index 00000000..31e3b0ca --- /dev/null +++ b/src/renderer/features/test-suite/api/testSuiteKeys.ts @@ -0,0 +1,36 @@ +export const testSuiteKeys = { + all: ['test-suite'] as const, + config: (projectId: string) => [...testSuiteKeys.all, 'config', projectId] as const, + configs: (projectId: string) => [...testSuiteKeys.all, 'configs', projectId] as const, + scripts: (projectId: string) => [...testSuiteKeys.all, 'scripts', projectId] as const, + script: (id: string) => [...testSuiteKeys.all, 'script', id] as const, + runs: (scriptId: string) => [...testSuiteKeys.all, 'runs', scriptId] as const, + allRuns: (projectId: string) => [...testSuiteKeys.all, 'runs', 'all', projectId] as const, + run: (runId: string) => [...testSuiteKeys.all, 'run', runId] as const, + screenshots: (runId: string) => [...testSuiteKeys.all, 'screenshots', runId] as const, + + // Baselines & diffs + baselines: (scriptId: string) => [...testSuiteKeys.all, 'baselines', scriptId] as const, + diffs: (runId: string) => [...testSuiteKeys.all, 'diffs', runId] as const, + + // Schedules + schedules: (projectId: string) => [...testSuiteKeys.all, 'schedules', projectId] as const, + + // Shared steps + sharedSteps: (projectId: string) => [...testSuiteKeys.all, 'shared-steps', projectId] as const, + sharedStepDomains: (projectId: string) => [...testSuiteKeys.all, 'shared-steps', 'domains', projectId] as const, + + // Watch mode + watchList: ['test-suite', 'watch', 'list'] as const, + + analytics: { + all: (projectId: string) => [...testSuiteKeys.all, 'analytics', projectId] as const, + summary: (projectId: string) => [...testSuiteKeys.all, 'analytics', 'summary', projectId] as const, + trend: (projectId: string) => [...testSuiteKeys.all, 'analytics', 'trend', projectId] as const, + topFailures: (projectId: string) => [...testSuiteKeys.all, 'analytics', 'top-failures', projectId] as const, + slowest: (projectId: string) => [...testSuiteKeys.all, 'analytics', 'slowest', projectId] as const, + errorPatterns: (projectId: string) => [...testSuiteKeys.all, 'analytics', 'error-patterns', projectId] as const, + flaky: (projectId: string) => [...testSuiteKeys.all, 'analytics', 'flaky', projectId] as const, + runHistory: (scriptId: string) => [...testSuiteKeys.all, 'analytics', 'run-history', scriptId] as const, + }, +}; diff --git a/src/renderer/features/test-suite/api/useAttachRunToTask.ts b/src/renderer/features/test-suite/api/useAttachRunToTask.ts new file mode 100644 index 00000000..d5ab08ba --- /dev/null +++ b/src/renderer/features/test-suite/api/useAttachRunToTask.ts @@ -0,0 +1,22 @@ +/** + * useAttachRunToTask — Mutation hook for attaching a test run to a task + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useAttachRunToTask() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { runId: string; taskId: string }) => + ipc(TEST_SUITE.TASK['ATTACH-RUN'], input), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: testSuiteKeys.all }); + }, + }); +} diff --git a/src/renderer/features/test-suite/api/useBaselines.ts b/src/renderer/features/test-suite/api/useBaselines.ts new file mode 100644 index 00000000..97ee32bc --- /dev/null +++ b/src/renderer/features/test-suite/api/useBaselines.ts @@ -0,0 +1,60 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useBaselines(scriptId: string | undefined) { + return useQuery({ + queryKey: testSuiteKeys.baselines(scriptId ?? ''), + queryFn: () => ipc(TEST_SUITE.BASELINE.LIST, { scriptId: scriptId ?? '' }), + enabled: !!scriptId, + staleTime: 30_000, + }); +} + +export function useSetBaseline() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { scriptId: string; screenshotId: string }) => + ipc(TEST_SUITE.BASELINE.SET, input), + onSuccess: (_data, variables) => { + void qc.invalidateQueries({ queryKey: testSuiteKeys.baselines(variables.scriptId) }); + }, + }); +} + +export function useDeleteBaselines() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (scriptId: string) => ipc(TEST_SUITE.BASELINE.DELETE, { scriptId }), + onSuccess: (_data, scriptId) => { + void qc.invalidateQueries({ queryKey: testSuiteKeys.baselines(scriptId) }); + }, + }); +} + +export function useRunDiffs(runId: string | undefined) { + return useQuery({ + queryKey: testSuiteKeys.diffs(runId ?? ''), + queryFn: () => ipc(TEST_SUITE.DIFF.LIST, { runId: runId ?? '' }), + enabled: !!runId, + staleTime: 10_000, + }); +} + +export function useCompareDiffs() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { runId: string; sensitivity?: 'strict' | 'balanced' | 'relaxed' }) => + ipc(TEST_SUITE.DIFF.COMPARE, { + runId: input.runId, + sensitivity: input.sensitivity ?? 'balanced', + }), + onSuccess: (_data, variables) => { + void qc.invalidateQueries({ queryKey: testSuiteKeys.diffs(variables.runId) }); + }, + }); +} diff --git a/src/renderer/features/test-suite/api/useDataRun.ts b/src/renderer/features/test-suite/api/useDataRun.ts new file mode 100644 index 00000000..8d78db93 --- /dev/null +++ b/src/renderer/features/test-suite/api/useDataRun.ts @@ -0,0 +1,18 @@ +import { useMutation } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +export function useParseDataFile() { + return useMutation({ + mutationFn: (filePath: string) => ipc(TEST_SUITE['DATA-RUN'].PARSE, { filePath }), + }); +} + +export function useExecuteDataRun() { + return useMutation({ + mutationFn: (input: { scriptId: string; dataFilePath: string }) => + ipc(TEST_SUITE['DATA-RUN'].EXECUTE, input), + }); +} diff --git a/src/renderer/features/test-suite/api/useDeleteScript.ts b/src/renderer/features/test-suite/api/useDeleteScript.ts new file mode 100644 index 00000000..f68130f4 --- /dev/null +++ b/src/renderer/features/test-suite/api/useDeleteScript.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useDeleteScript(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => ipc(TEST_SUITE.DELETE.SCRIPT, { id }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: testSuiteKeys.scripts(projectId) }); + }, + }); +} diff --git a/src/renderer/features/test-suite/api/useDeleteTestSuiteConfig.ts b/src/renderer/features/test-suite/api/useDeleteTestSuiteConfig.ts new file mode 100644 index 00000000..04aeb38d --- /dev/null +++ b/src/renderer/features/test-suite/api/useDeleteTestSuiteConfig.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useDeleteTestSuiteConfig(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (configId: string) => + ipc(TEST_SUITE.CONFIG.DELETE, { projectId, configId }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: testSuiteKeys.all }); + }, + }); +} diff --git a/src/renderer/features/test-suite/api/useExportRun.ts b/src/renderer/features/test-suite/api/useExportRun.ts new file mode 100644 index 00000000..95e6b428 --- /dev/null +++ b/src/renderer/features/test-suite/api/useExportRun.ts @@ -0,0 +1,16 @@ +import { useMutation } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { useMutationErrorToast } from '@renderer/shared/hooks'; +import { ipc } from '@renderer/shared/lib/ipc'; + +/** Export a run to file */ +export function useExportRun() { + const { onError } = useMutationErrorToast(); + return useMutation({ + mutationFn: ({ runId, format }: { runId: string; format: 'json' | 'html' | 'csv' }) => + ipc(TEST_SUITE.EXPORT.FILE, { runId, format }), + onError: onError('export run'), + }); +} diff --git a/src/renderer/features/test-suite/api/useRuns.ts b/src/renderer/features/test-suite/api/useRuns.ts new file mode 100644 index 00000000..a999fba2 --- /dev/null +++ b/src/renderer/features/test-suite/api/useRuns.ts @@ -0,0 +1,74 @@ +/** + * React Query hooks for QA run queries and mutations + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { useMutationErrorToast } from '@renderer/shared/hooks'; +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +/** Fetch all runs, optionally filtered by scriptId */ +export function useRuns(scriptId?: string | null) { + const id = scriptId ?? undefined; + return useQuery({ + queryKey: id ? testSuiteKeys.runs(id) : testSuiteKeys.allRuns('all'), + queryFn: () => ipc(TEST_SUITE.LIST.RUNS, { scriptId: id }), + staleTime: 10_000, + }); +} + +/** Fetch a single run by id */ +export function useRun(runId: string | null) { + return useQuery({ + queryKey: testSuiteKeys.run(runId ?? ''), + queryFn: () => ipc(TEST_SUITE.GET.RUN, { runId: runId ?? '' }), + enabled: runId !== null && runId.length > 0, + staleTime: 5_000, + }); +} + +/** Run a QA script */ +export function useRunScript() { + const queryClient = useQueryClient(); + const { onError } = useMutationErrorToast(); + return useMutation({ + mutationFn: ({ + scriptId, + triggeredBy = 'manual', + baseUrlOverride, + }: { + scriptId: string; + triggeredBy?: 'manual' | 'scheduled' | 'ci'; + baseUrlOverride?: string; + }) => ipc(TEST_SUITE.RUN.SCRIPT, { scriptId, triggeredBy, baseUrlOverride }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: testSuiteKeys.all }); + }, + onError: onError('run QA script'), + }); +} + +/** Run multiple QA scripts sequentially as a batch */ +export function useBatchRun() { + const queryClient = useQueryClient(); + const { onError } = useMutationErrorToast(); + return useMutation({ + mutationFn: (input: { + scriptIds: string[]; + triggeredBy?: 'manual' | 'scheduled' | 'ci'; + baseUrlOverride?: string; + }) => ipc(TEST_SUITE.BATCH.RUN, { + scriptIds: input.scriptIds, + triggeredBy: input.triggeredBy ?? 'manual', + baseUrlOverride: input.baseUrlOverride, + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: testSuiteKeys.all }); + }, + onError: onError('batch run scripts'), + }); +} diff --git a/src/renderer/features/test-suite/api/useSaveScript.ts b/src/renderer/features/test-suite/api/useSaveScript.ts new file mode 100644 index 00000000..3a730118 --- /dev/null +++ b/src/renderer/features/test-suite/api/useSaveScript.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import type { InvokeInput } from '@shared/ipc'; +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +type SaveScriptInput = InvokeInput<typeof TEST_SUITE.SAVE.SCRIPT>; + +export function useSaveScript(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: SaveScriptInput) => ipc(TEST_SUITE.SAVE.SCRIPT, input), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: testSuiteKeys.scripts(projectId) }); + }, + }); +} diff --git a/src/renderer/features/test-suite/api/useSaveTestSuiteConfig.ts b/src/renderer/features/test-suite/api/useSaveTestSuiteConfig.ts new file mode 100644 index 00000000..72866466 --- /dev/null +++ b/src/renderer/features/test-suite/api/useSaveTestSuiteConfig.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import type { TestSuiteConfig } from '@shared/ipc/test-suite'; +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useSaveTestSuiteConfig(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (config: TestSuiteConfig) => ipc(TEST_SUITE.CONFIG.SAVE, { projectId, config }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: testSuiteKeys.all }); + }, + }); +} diff --git a/src/renderer/features/test-suite/api/useSchedules.ts b/src/renderer/features/test-suite/api/useSchedules.ts new file mode 100644 index 00000000..72991fe8 --- /dev/null +++ b/src/renderer/features/test-suite/api/useSchedules.ts @@ -0,0 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useSchedules(projectId: string | undefined) { + return useQuery({ + queryKey: testSuiteKeys.schedules(projectId ?? ''), + queryFn: () => ipc(TEST_SUITE.SCHEDULE.LIST, { projectId: projectId ?? '' }), + enabled: !!projectId, + staleTime: 10_000, + }); +} + +export function useCreateSchedule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { scriptId: string; projectId: string; intervalMs: number }) => + ipc(TEST_SUITE.SCHEDULE.CREATE, input), + onSuccess: (_data, variables) => { + void qc.invalidateQueries({ queryKey: testSuiteKeys.schedules(variables.projectId) }); + }, + }); +} + +export function useUpdateSchedule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { id: string; intervalMs?: number; enabled?: boolean }) => + ipc(TEST_SUITE.SCHEDULE.UPDATE, input), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: [...testSuiteKeys.all, 'schedules'] }); + }, + }); +} + +export function useDeleteSchedule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => ipc(TEST_SUITE.SCHEDULE.DELETE, { id }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: [...testSuiteKeys.all, 'schedules'] }); + }, + }); +} + +export function useTriggerScheduleNow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => ipc(TEST_SUITE.SCHEDULE['TRIGGER-NOW'], { id }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: [...testSuiteKeys.all, 'schedules'] }); + }, + }); +} diff --git a/src/renderer/features/test-suite/api/useSetActiveTestSuiteConfig.ts b/src/renderer/features/test-suite/api/useSetActiveTestSuiteConfig.ts new file mode 100644 index 00000000..c50f4d2f --- /dev/null +++ b/src/renderer/features/test-suite/api/useSetActiveTestSuiteConfig.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useSetActiveTestSuiteConfig(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (configId: string) => + ipc(TEST_SUITE.CONFIG['SET-ACTIVE'], { projectId, configId }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: testSuiteKeys.all }); + }, + }); +} diff --git a/src/renderer/features/test-suite/api/useSharedSteps.ts b/src/renderer/features/test-suite/api/useSharedSteps.ts new file mode 100644 index 00000000..9e07ca49 --- /dev/null +++ b/src/renderer/features/test-suite/api/useSharedSteps.ts @@ -0,0 +1,69 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; +import type { TestSuiteStep } from '@shared/types/test-suite'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useSharedSteps(projectId: string | undefined) { + return useQuery({ + queryKey: testSuiteKeys.sharedSteps(projectId ?? ''), + queryFn: () => ipc(TEST_SUITE['SHARED-STEPS'].LIST, { projectId: projectId ?? '' }), + enabled: !!projectId, + staleTime: 30_000, + }); +} + +export function useSharedStepDomains(projectId: string | undefined) { + return useQuery({ + queryKey: testSuiteKeys.sharedStepDomains(projectId ?? ''), + queryFn: () => ipc(TEST_SUITE['SHARED-STEPS'].DOMAINS, { projectId: projectId ?? '' }), + enabled: !!projectId, + staleTime: 30_000, + }); +} + +export function useCreateSharedSteps() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { + projectId: string; + name: string; + domain: string; + description?: string; + steps: TestSuiteStep[]; + }) => ipc(TEST_SUITE['SHARED-STEPS'].CREATE, input), + onSuccess: (_data, variables) => { + void qc.invalidateQueries({ queryKey: testSuiteKeys.sharedSteps(variables.projectId) }); + void qc.invalidateQueries({ queryKey: testSuiteKeys.sharedStepDomains(variables.projectId) }); + }, + }); +} + +export function useUpdateSharedSteps() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { + id: string; + name?: string; + domain?: string; + description?: string; + steps?: TestSuiteStep[]; + }) => ipc(TEST_SUITE['SHARED-STEPS'].UPDATE, input), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: [...testSuiteKeys.all, 'shared-steps'] }); + }, + }); +} + +export function useDeleteSharedSteps() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => ipc(TEST_SUITE['SHARED-STEPS'].DELETE, { id }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: [...testSuiteKeys.all, 'shared-steps'] }); + }, + }); +} diff --git a/src/renderer/features/test-suite/api/useStartRecording.ts b/src/renderer/features/test-suite/api/useStartRecording.ts new file mode 100644 index 00000000..101d4bf9 --- /dev/null +++ b/src/renderer/features/test-suite/api/useStartRecording.ts @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +export function useStartRecording() { + return useMutation({ + mutationFn: ({ url, width, height }: { url: string; width: number; height: number }) => + ipc(TEST_SUITE['BROWSER-VIEW'].CREATE, { + url, + bounds: { x: 0, y: 0, width, height }, + }), + }); +} diff --git a/src/renderer/features/test-suite/api/useStopRecording.ts b/src/renderer/features/test-suite/api/useStopRecording.ts new file mode 100644 index 00000000..570973dd --- /dev/null +++ b/src/renderer/features/test-suite/api/useStopRecording.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +export function useStopRecording() { + return useMutation({ + mutationFn: () => ipc(TEST_SUITE['BROWSER-VIEW'].DESTROY, {}), + }); +} diff --git a/src/renderer/features/test-suite/api/useTestSuiteAnalytics.ts b/src/renderer/features/test-suite/api/useTestSuiteAnalytics.ts new file mode 100644 index 00000000..cc551ea8 --- /dev/null +++ b/src/renderer/features/test-suite/api/useTestSuiteAnalytics.ts @@ -0,0 +1,70 @@ +import { useQuery } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useAnalyticsSummary(projectId: string | undefined) { + return useQuery({ + queryKey: testSuiteKeys.analytics.summary(projectId ?? ''), + queryFn: () => ipc(TEST_SUITE.ANALYTICS.SUMMARY, { projectId: projectId ?? '' }), + enabled: !!projectId, + staleTime: 30_000, + }); +} + +export function useAnalyticsTrend(projectId: string | undefined, days = 30) { + return useQuery({ + queryKey: testSuiteKeys.analytics.trend(projectId ?? ''), + queryFn: () => ipc(TEST_SUITE.ANALYTICS.TREND, { projectId: projectId ?? '', days }), + enabled: !!projectId, + staleTime: 30_000, + }); +} + +export function useTopFailures(projectId: string | undefined, limit = 10) { + return useQuery({ + queryKey: testSuiteKeys.analytics.topFailures(projectId ?? ''), + queryFn: () => ipc(TEST_SUITE.ANALYTICS['TOP-FAILURES'], { projectId: projectId ?? '', limit }), + enabled: !!projectId, + staleTime: 30_000, + }); +} + +export function useSlowestTests(projectId: string | undefined, limit = 10) { + return useQuery({ + queryKey: testSuiteKeys.analytics.slowest(projectId ?? ''), + queryFn: () => ipc(TEST_SUITE.ANALYTICS.SLOWEST, { projectId: projectId ?? '', limit }), + enabled: !!projectId, + staleTime: 30_000, + }); +} + +export function useErrorPatterns(projectId: string | undefined, limit = 10) { + return useQuery({ + queryKey: testSuiteKeys.analytics.errorPatterns(projectId ?? ''), + queryFn: () => ipc(TEST_SUITE.ANALYTICS['ERROR-PATTERNS'], { projectId: projectId ?? '', limit }), + enabled: !!projectId, + staleTime: 30_000, + }); +} + +export function useFlakyTests(projectId: string | undefined) { + return useQuery({ + queryKey: testSuiteKeys.analytics.flaky(projectId ?? ''), + queryFn: () => ipc(TEST_SUITE.ANALYTICS.FLAKY, { projectId: projectId ?? '' }), + enabled: !!projectId, + staleTime: 30_000, + }); +} + +export function useRunHistory(scriptId: string | undefined, limit = 20) { + return useQuery({ + queryKey: testSuiteKeys.analytics.runHistory(scriptId ?? ''), + queryFn: () => ipc(TEST_SUITE.ANALYTICS['RUN-HISTORY'], { scriptId: scriptId ?? '', limit }), + enabled: !!scriptId, + staleTime: 10_000, + }); +} diff --git a/src/renderer/features/test-suite/api/useTestSuiteConfig.ts b/src/renderer/features/test-suite/api/useTestSuiteConfig.ts new file mode 100644 index 00000000..357a3bef --- /dev/null +++ b/src/renderer/features/test-suite/api/useTestSuiteConfig.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useTestSuiteConfig(projectId: string | null | undefined) { + const normalizedProjectId = typeof projectId === 'string' ? projectId : ''; + return useQuery({ + queryKey: testSuiteKeys.config(normalizedProjectId), + queryFn: () => ipc(TEST_SUITE.CONFIG.GET, { projectId: normalizedProjectId }), + enabled: normalizedProjectId.length > 0, + staleTime: 30_000, + }); +} diff --git a/src/renderer/features/test-suite/api/useTestSuiteConfigs.ts b/src/renderer/features/test-suite/api/useTestSuiteConfigs.ts new file mode 100644 index 00000000..5908e5ff --- /dev/null +++ b/src/renderer/features/test-suite/api/useTestSuiteConfigs.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useTestSuiteConfigs(projectId: string) { + return useQuery({ + queryKey: testSuiteKeys.configs(projectId), + queryFn: () => ipc(TEST_SUITE.CONFIG.LIST, { projectId }), + enabled: projectId.length > 0, + staleTime: 30_000, + }); +} diff --git a/src/renderer/features/test-suite/api/useTestSuiteScreenshots.ts b/src/renderer/features/test-suite/api/useTestSuiteScreenshots.ts new file mode 100644 index 00000000..9bda5847 --- /dev/null +++ b/src/renderer/features/test-suite/api/useTestSuiteScreenshots.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useTestSuiteScreenshots(runId: string | null | undefined) { + return useQuery({ + queryKey: testSuiteKeys.screenshots(runId ?? ''), + queryFn: () => ipc(TEST_SUITE.SCREENSHOT.LIST, { runId: runId ?? undefined }), + enabled: typeof runId === 'string' && runId.length > 0, + staleTime: 10_000, + }); +} diff --git a/src/renderer/features/test-suite/api/useTestSuiteScripts.ts b/src/renderer/features/test-suite/api/useTestSuiteScripts.ts new file mode 100644 index 00000000..3d8ceed7 --- /dev/null +++ b/src/renderer/features/test-suite/api/useTestSuiteScripts.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useTestSuiteScripts(projectId: string | null | undefined) { + return useQuery({ + queryKey: testSuiteKeys.scripts(projectId ?? ''), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by `enabled` + queryFn: () => ipc(TEST_SUITE.LIST.SCRIPTS, { projectId: projectId! }), + enabled: typeof projectId === 'string' && projectId.length > 0, + staleTime: 30_000, + }); +} diff --git a/src/renderer/features/test-suite/api/useWatchMode.ts b/src/renderer/features/test-suite/api/useWatchMode.ts new file mode 100644 index 00000000..24b36798 --- /dev/null +++ b/src/renderer/features/test-suite/api/useWatchMode.ts @@ -0,0 +1,35 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { testSuiteKeys } from './testSuiteKeys'; + +export function useWatchedScripts() { + return useQuery({ + queryKey: testSuiteKeys.watchList, + queryFn: () => ipc(TEST_SUITE.WATCH.LIST, {}), + staleTime: 5_000, + }); +} + +export function useStartWatch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (scriptId: string) => ipc(TEST_SUITE.WATCH.START, { scriptId }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: testSuiteKeys.watchList }); + }, + }); +} + +export function useStopWatch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (scriptId: string) => ipc(TEST_SUITE.WATCH.STOP, { scriptId }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: testSuiteKeys.watchList }); + }, + }); +} diff --git a/src/renderer/features/test-suite/components/AnalyticsDetailCards/AnalyticsDetailCards.tsx b/src/renderer/features/test-suite/components/AnalyticsDetailCards/AnalyticsDetailCards.tsx new file mode 100644 index 00000000..474a36c8 --- /dev/null +++ b/src/renderer/features/test-suite/components/AnalyticsDetailCards/AnalyticsDetailCards.tsx @@ -0,0 +1,165 @@ +import { AlertTriangle, Clock, TrendingDown, XCircle } from 'lucide-react'; + +import type { + ErrorPatternSchema, + FlakyTestSchema, + SlowestTestSchema, + TopFailureSchema, +} from '@shared/ipc/test-suite'; + +import { + Badge, + Card, + CardContent, + CardHeader, + CardTitle, + Flex, + Grid, + Stack, + Text, +} from '@ui'; + +import type { z } from 'zod'; + +type TopFailure = z.infer<typeof TopFailureSchema>; +type SlowestTest = z.infer<typeof SlowestTestSchema>; +type FlakyTest = z.infer<typeof FlakyTestSchema>; +type ErrorPattern = z.infer<typeof ErrorPatternSchema>; + +// ─── Severity variant helper ───────────────────────────── + +function severityVariant(s: string) { + if (s === 'high') return 'destructive' as const; + if (s === 'medium') return 'secondary' as const; + return 'outline' as const; +} + +// ─── Props ─────────────────────────────────────────────── + +interface AnalyticsDetailCardsProps { + errorPatterns: ErrorPattern[]; + flakyTests: FlakyTest[]; + slowest: SlowestTest[]; + topFailures: TopFailure[]; +} + +// ─── Component ────────────────────────────────────────── + +export function AnalyticsDetailCards({ + errorPatterns, + flakyTests, + slowest, + topFailures, +}: AnalyticsDetailCardsProps) { + return ( + <Grid cols={2} gap="md"> + {/* Top Failures */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-sm"> + <XCircle className="h-4 w-4 text-destructive" /> Top Failures + </CardTitle> + </CardHeader> + <CardContent> + {topFailures.length === 0 ? ( + <Text variant="muted">No failures recorded</Text> + ) : ( + <Stack gap="sm"> + {topFailures.map((f) => ( + <Flex key={f.scriptId} align="center" justify="between" wrap="nowrap"> + <Text className="truncate">{f.scriptName}</Text> + <Text className="shrink-0" variant="muted"> + {f.failureCount}/{f.totalRuns} ({f.failureRate}%) + </Text> + </Flex> + ))} + </Stack> + )} + </CardContent> + </Card> + + {/* Slowest Tests */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-sm"> + <Clock className="h-4 w-4 text-yellow-500" /> Slowest Tests + </CardTitle> + </CardHeader> + <CardContent> + {slowest.length === 0 ? ( + <Text variant="muted">No duration data</Text> + ) : ( + <Stack gap="sm"> + {slowest.map((s) => ( + <Flex key={s.scriptId} align="center" justify="between" wrap="nowrap"> + <Text className="truncate">{s.scriptName}</Text> + <Text className="shrink-0" variant="muted"> + avg {s.avgDurationMs}ms / max {s.maxDurationMs}ms + </Text> + </Flex> + ))} + </Stack> + )} + </CardContent> + </Card> + + {/* Flaky Tests */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-sm"> + <AlertTriangle className="h-4 w-4 text-yellow-500" /> Flaky Tests + </CardTitle> + </CardHeader> + <CardContent> + {flakyTests.length === 0 ? ( + <Text variant="muted">No flaky tests detected</Text> + ) : ( + <Stack gap="sm"> + {flakyTests.map((f) => ( + <Flex key={f.scriptId} align="center" justify="between" wrap="nowrap"> + <Flex align="center" gap="sm" wrap="nowrap"> + <Badge variant={severityVariant(f.severity)}>{f.severity}</Badge> + <Text className="truncate">{f.scriptName}</Text> + </Flex> + <Text className="shrink-0" variant="muted"> + {f.flakeRate}% flake rate + </Text> + </Flex> + ))} + </Stack> + )} + </CardContent> + </Card> + + {/* Error Patterns */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-sm"> + <TrendingDown className="h-4 w-4 text-destructive" /> Error Patterns + </CardTitle> + </CardHeader> + <CardContent> + {errorPatterns.length === 0 ? ( + <Text variant="muted">No error patterns found</Text> + ) : ( + <Stack gap="md"> + {errorPatterns.map((ep) => ( + <Stack key={ep.pattern} gap="none"> + <Flex align="center" justify="between" wrap="nowrap"> + <Badge variant="secondary">{ep.count}x</Badge> + <Text size="sm" variant="muted"> + {ep.scriptIds.length} test{ep.scriptIds.length > 1 ? 's' : ''} + </Text> + </Flex> + <Text className="truncate font-mono" size="sm" variant="muted"> + {ep.pattern} + </Text> + </Stack> + ))} + </Stack> + )} + </CardContent> + </Card> + </Grid> + ); +} diff --git a/src/renderer/features/test-suite/components/AnalyticsDetailCards/index.ts b/src/renderer/features/test-suite/components/AnalyticsDetailCards/index.ts new file mode 100644 index 00000000..7d9af3a8 --- /dev/null +++ b/src/renderer/features/test-suite/components/AnalyticsDetailCards/index.ts @@ -0,0 +1 @@ +export { AnalyticsDetailCards } from './AnalyticsDetailCards'; diff --git a/src/renderer/features/test-suite/components/AnalyticsPanel/AnalyticsPanel.tsx b/src/renderer/features/test-suite/components/AnalyticsPanel/AnalyticsPanel.tsx new file mode 100644 index 00000000..8c24202e --- /dev/null +++ b/src/renderer/features/test-suite/components/AnalyticsPanel/AnalyticsPanel.tsx @@ -0,0 +1,81 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, + EmptyState, + Grid, + MetricCard, + PageContent, + ScrollArea, + Skeleton, + Stack, +} from '@ui'; + +import { AnalyticsDetailCards } from '../AnalyticsDetailCards'; +import { HealthScoreCard } from '../HealthScoreCard'; +import { TrendChart } from '../TrendChart'; + +import { useAnalyticsPanel } from './useAnalyticsPanel'; + +export function AnalyticsPanel() { + const vm = useAnalyticsPanel(); + + if (!vm.projectId) return null; + + if (vm.summaryLoading) { + return ( + <PageContent> + <Grid className="p-6" cols={4} gap="md"> + {['s1', 's2', 's3', 's4'].map((k) => ( + <Skeleton key={k} className="h-24" /> + ))} + </Grid> + </PageContent> + ); + } + + if (!vm.summary || vm.summary.totalRuns === 0) { + return ( + <PageContent> + <EmptyState + description="Run some tests first to see analytics here." + title="No analytics data" + /> + </PageContent> + ); + } + + return ( + <PageContent> + <ScrollArea className="h-full"> + <Stack className="p-6" gap="lg"> + {vm.health ? <HealthScoreCard health={vm.health} /> : null} + + <Grid cols={4} gap="md"> + <MetricCard label="Total Scripts" value={String(vm.summary.totalScripts)} /> + <MetricCard label="Total Runs" value={String(vm.summary.totalRuns)} /> + <MetricCard label="Pass Rate" value={`${Math.round(vm.summary.passRate)}%`} /> + <MetricCard label="Avg Duration" value={`${vm.summary.avgDurationMs}ms`} /> + </Grid> + + <Card> + <CardHeader> + <CardTitle className="text-sm">Pass / Fail Trend (30 days)</CardTitle> + </CardHeader> + <CardContent> + <TrendChart data={vm.trend} /> + </CardContent> + </Card> + + <AnalyticsDetailCards + errorPatterns={vm.errorPatterns} + flakyTests={vm.flakyTests} + slowest={vm.slowest} + topFailures={vm.topFailures} + /> + </Stack> + </ScrollArea> + </PageContent> + ); +} diff --git a/src/renderer/features/test-suite/components/AnalyticsPanel/index.ts b/src/renderer/features/test-suite/components/AnalyticsPanel/index.ts new file mode 100644 index 00000000..cd64ee39 --- /dev/null +++ b/src/renderer/features/test-suite/components/AnalyticsPanel/index.ts @@ -0,0 +1 @@ +export { AnalyticsPanel } from './AnalyticsPanel'; diff --git a/src/renderer/features/test-suite/components/AnalyticsPanel/useAnalyticsPanel.ts b/src/renderer/features/test-suite/components/AnalyticsPanel/useAnalyticsPanel.ts new file mode 100644 index 00000000..d0400b92 --- /dev/null +++ b/src/renderer/features/test-suite/components/AnalyticsPanel/useAnalyticsPanel.ts @@ -0,0 +1,35 @@ +import { useLooseParams } from '@renderer/shared/hooks'; + +import { + useAnalyticsSummary, + useAnalyticsTrend, + useErrorPatterns, + useFlakyTests, + useSlowestTests, + useTopFailures, +} from '../../api/useTestSuiteAnalytics'; +import { computeHealthScore } from '../../lib/health-score'; + +export function useAnalyticsPanel() { + const { projectId } = useLooseParams(); + const { data: summary, isLoading: summaryLoading } = useAnalyticsSummary(projectId); + const { data: trend = [] } = useAnalyticsTrend(projectId); + const { data: topFailures = [] } = useTopFailures(projectId); + const { data: slowest = [] } = useSlowestTests(projectId); + const { data: errorPatterns = [] } = useErrorPatterns(projectId); + const { data: flakyTests = [] } = useFlakyTests(projectId); + + const health = summary && summary.totalRuns > 0 ? computeHealthScore(summary) : null; + + return { + projectId, + summary, + summaryLoading, + trend, + topFailures, + slowest, + errorPatterns, + flakyTests, + health, + }; +} diff --git a/src/renderer/features/test-suite/components/BrowserViewPanel/BrowserViewPanel.tsx b/src/renderer/features/test-suite/components/BrowserViewPanel/BrowserViewPanel.tsx new file mode 100644 index 00000000..d55918b0 --- /dev/null +++ b/src/renderer/features/test-suite/components/BrowserViewPanel/BrowserViewPanel.tsx @@ -0,0 +1,106 @@ +/** + * BrowserViewPanel — Inline <webview> that displays the user's running app. + * + * Uses Electron's <webview> tag (a DOM element) so the browser preview is + * naturally contained by its parent's CSS layout and provides its own + * internal scroll. The recorder preload is injected via the `preload` + * attribute; captured steps arrive through the webview's `ipc-message` + * event (the preload calls `ipcRenderer.sendToHost`). + */ + +import { AlertCircle, ArrowLeft, ArrowRight, Monitor, RotateCw } from 'lucide-react'; + +import { Button, Flex, Input, Stack, Text } from '@ui'; + +import { useBrowserViewPanel } from './useBrowserViewPanel'; + +// Electron's <webview> is not in React's JSX intrinsic types. +const Webview = 'webview' as unknown as React.ElementType; + +interface BrowserViewPanelProps { + url: string; + width: number; + height: number; + serverRunning: boolean; + recording: boolean; + onUrlChange: (u: string) => void; +} + +export function BrowserViewPanel({ url, width, height, serverRunning, recording, onUrlChange }: BrowserViewPanelProps) { + const vm = useBrowserViewPanel({ url, serverRunning, recording, onUrlChange }); + + return ( + <Stack className="h-full flex-1 overflow-hidden" gap="none"> + {/* Address bar */} + <Flex align="center" className="h-10 shrink-0 border-b border-border px-3" gap="sm" wrap="nowrap"> + <Flex align="center" gap="sm"> + <Button + size="icon-sm" + variant="ghost" + onClick={vm.goBack} + > + <ArrowLeft className="h-3.5 w-3.5" /> + </Button> + <Button + size="icon-sm" + variant="ghost" + onClick={vm.goForward} + > + <ArrowRight className="h-3.5 w-3.5" /> + </Button> + <Button + size="icon-sm" + variant="ghost" + onClick={vm.reload} + > + <RotateCw className="h-3.5 w-3.5" /> + </Button> + </Flex> + <form className="flex-1" onSubmit={vm.handleSubmit}> + <Input + className="h-7 w-full font-mono text-xs" + value={url} + onChange={(e) => { onUrlChange(e.target.value); }} + /> + </form> + <Text className="shrink-0 font-mono" size="sm" variant="muted"> + {width}×{height} + </Text> + </Flex> + + {/* Browser content */} + <div className="relative flex-1 overflow-hidden"> + {vm.browserActive && vm.preloadUrl && vm.committedUrl ? ( + <> + <Webview + ref={vm.webviewRef} + className="absolute inset-0 h-full w-full" + partition="persist:test-suite" + preload={vm.preloadUrl} + src={vm.committedUrl} + webpreferences="sandbox=no, contextIsolation=yes" + /> + {recording ? ( + <div className="pointer-events-none absolute left-3 top-3 z-10 flex items-center gap-2 rounded-full bg-destructive/90 px-3 py-1 text-xs font-semibold text-white shadow-lg"> + <div className="h-2 w-2 animate-pulse rounded-full bg-white" /> REC + </div> + ) : null} + </> + ) : ( + <Flex align="center" className="absolute inset-0 bg-card/50" justify="center"> + <Stack align="center" className="text-center" gap="sm"> + <Monitor className="h-10 w-10 text-text-muted/40" /> + <Text className="text-sm text-text-muted"> + Start your dev server to see the browser preview + </Text> + <Flex align="center" gap="sm"> + <AlertCircle className="h-3.5 w-3.5 text-warning" /> + <Text className="text-xs text-warning">Click "Start Server" in the toolbar above</Text> + </Flex> + </Stack> + </Flex> + )} + </div> + </Stack> + ); +} diff --git a/src/renderer/features/test-suite/components/BrowserViewPanel/index.ts b/src/renderer/features/test-suite/components/BrowserViewPanel/index.ts new file mode 100644 index 00000000..28f42cbb --- /dev/null +++ b/src/renderer/features/test-suite/components/BrowserViewPanel/index.ts @@ -0,0 +1 @@ +export { BrowserViewPanel } from './BrowserViewPanel'; diff --git a/src/renderer/features/test-suite/components/BrowserViewPanel/useBrowserViewPanel.ts b/src/renderer/features/test-suite/components/BrowserViewPanel/useBrowserViewPanel.ts new file mode 100644 index 00000000..434bd4d3 --- /dev/null +++ b/src/renderer/features/test-suite/components/BrowserViewPanel/useBrowserViewPanel.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { TestSuiteStep } from '@shared/types/test-suite'; + +import { useTestSuiteStore } from '../../test-suite-store'; + +/** Minimal typing for the subset of Electron.WebviewTag we use. */ +export interface WebviewElement { + goBack: () => void; + goForward: () => void; + reload: () => void; + loadURL: (url: string) => Promise<void>; + getURL: () => string; + addEventListener: (type: string, listener: (...args: unknown[]) => void) => void; + removeEventListener: (type: string, listener: (...args: unknown[]) => void) => void; +} + +interface UseBrowserViewPanelProps { + url: string; + serverRunning: boolean; + recording: boolean; + onUrlChange: (u: string) => void; +} + +export function useBrowserViewPanel({ url, serverRunning, recording, onUrlChange }: UseBrowserViewPanelProps) { + const browserActive = serverRunning || recording; + const webviewRef = useRef<WebviewElement | null>(null); + const addStep = useTestSuiteStore((s) => s.addStep); + const stepIndexRef = useRef(0); + + // Stable URL for the webview's src attribute — only updated on + // explicit navigation (form submit) or internal webview navigation. + // The address bar text (`url` prop) changes on every keystroke, but + // we must not pass that to `src` or the webview re-navigates. + const [committedUrl, setCommittedUrl] = useState(''); + + // Sync committedUrl when the URL prop first becomes valid (config loads) + useEffect(() => { + if (url && !committedUrl) setCommittedUrl(url); + }, [url, committedUrl]); + + const preloadUrl = window.preloads.testSuiteRecorder; + + // Navigate on address bar submit + const handleSubmit = useCallback( + (e: React.SyntheticEvent) => { + e.preventDefault(); + setCommittedUrl(url); + // Direct imperative call for immediate response + void webviewRef.current?.loadURL(url); + }, + [url], + ); + + // Reset step counter when recording starts + useEffect(() => { + if (recording) stepIndexRef.current = 0; + }, [recording]); + + // Listen for recorder-preload step messages + internal navigations + useEffect(() => { + const wv = webviewRef.current; + if (!wv) return; + + const onIpcMessage = (...args: unknown[]) => { + const event = args[0] as { channel: string; args: unknown[] }; + if (event.channel !== 'adc.test-suite.step' || !recording) return; + addStep({ + stepIndex: stepIndexRef.current++, + step: event.args[0] as TestSuiteStep, + timestamp: new Date().toISOString(), + }); + }; + + const onNavigate = (...args: unknown[]) => { + const event = args[0] as { url: string }; + onUrlChange(event.url); + setCommittedUrl(event.url); + }; + + wv.addEventListener('ipc-message', onIpcMessage); + wv.addEventListener('did-navigate', onNavigate); + wv.addEventListener('did-navigate-in-page', onNavigate); + + return () => { + wv.removeEventListener('ipc-message', onIpcMessage); + wv.removeEventListener('did-navigate', onNavigate); + wv.removeEventListener('did-navigate-in-page', onNavigate); + }; + }, [recording, addStep, onUrlChange]); + + const goBack = () => webviewRef.current?.goBack(); + const goForward = () => webviewRef.current?.goForward(); + const reload = () => webviewRef.current?.reload(); + + return { + browserActive, + webviewRef, + committedUrl, + preloadUrl, + handleSubmit, + goBack, + goForward, + reload, + }; +} diff --git a/src/renderer/features/test-suite/components/ConfigEditDialog/ConfigEditDialog.tsx b/src/renderer/features/test-suite/components/ConfigEditDialog/ConfigEditDialog.tsx new file mode 100644 index 00000000..284a9a3f --- /dev/null +++ b/src/renderer/features/test-suite/components/ConfigEditDialog/ConfigEditDialog.tsx @@ -0,0 +1,277 @@ +/** + * ConfigEditDialog — create / edit a TestSuiteConfig in a modal. + * + * Mirrors the 5 form fields from SetupCard plus a name field, and persists + * via useSaveTestSuiteConfig. Used by TestingSettingsTab's Saved Configurations + * section. + */ + +import { Plus, Trash2 } from 'lucide-react'; + +import type { TestSuiteConfig } from '@shared/ipc/test-suite'; + +import { + Button, + Checkbox, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Flex, + Grid, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, + Text, +} from '@ui'; + +import { + MAX_RETRIES, + MAX_WORKERS, + MIN_VIEWPORT_HEIGHT, + MIN_VIEWPORT_WIDTH, + SCREENSHOT_MODES, +} from '../../lib/constants'; + +import { useConfigEditDialog } from './useConfigEditDialog'; + +type ScreenshotMode = TestSuiteConfig['screenshotMode']; + +interface ConfigEditDialogProps { + projectId: string; + config: TestSuiteConfig | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ConfigEditDialog({ + projectId, + config, + open, + onOpenChange, +}: ConfigEditDialogProps) { + const vm = useConfigEditDialog({ projectId, config, open, onOpenChange }); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>{vm.title}</DialogTitle> + </DialogHeader> + <form className="flex flex-col gap-4" onSubmit={vm.handleSubmit}> + <Stack gap="sm"> + <Label htmlFor="config-edit-name">Name</Label> + <Input + required + id="config-edit-name" + type="text" + value={vm.state.name} + onChange={(event) => vm.update('name', event.target.value)} + /> + </Stack> + + <Stack gap="sm"> + <Label htmlFor="config-edit-target-url">Target URL</Label> + <Input + required + id="config-edit-target-url" + placeholder="http://localhost:3000" + type="url" + value={vm.state.targetUrl} + onChange={(event) => vm.update('targetUrl', event.target.value)} + /> + {vm.urlError ? ( + <Text size="sm" variant="error">{vm.urlError}</Text> + ) : null} + </Stack> + + <Grid cols={2} gap="md"> + <Stack gap="sm"> + <Label htmlFor="config-edit-width">Width</Label> + <Input + required + id="config-edit-width" + min={MIN_VIEWPORT_WIDTH} + type="number" + value={vm.state.viewportWidth} + onChange={(event) => + vm.update('viewportWidth', Number(event.target.value)) + } + /> + </Stack> + <Stack gap="sm"> + <Label htmlFor="config-edit-height">Height</Label> + <Input + required + id="config-edit-height" + min={MIN_VIEWPORT_HEIGHT} + type="number" + value={vm.state.viewportHeight} + onChange={(event) => + vm.update('viewportHeight', Number(event.target.value)) + } + /> + </Stack> + </Grid> + + <Stack gap="sm"> + <Label htmlFor="config-edit-mode">Screenshot Mode</Label> + <Select + value={vm.state.screenshotMode} + onValueChange={(value) => + vm.update('screenshotMode', value as ScreenshotMode) + } + > + <SelectTrigger aria-label="Screenshot mode" id="config-edit-mode"> + <SelectValue placeholder="Select a mode" /> + </SelectTrigger> + <SelectContent> + {SCREENSHOT_MODES.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </Stack> + + <Stack gap="sm"> + <Label>Browsers</Label> + <Flex gap="md"> + {(['chromium', 'firefox', 'webkit'] as const).map((b) => ( + <Label key={b} className="flex items-center gap-1.5 text-sm"> + <Checkbox + checked={vm.state.browsers.includes(b)} + onCheckedChange={(checked) => vm.handleBrowserToggle(b, checked)} + /> + {b.charAt(0).toUpperCase() + b.slice(1)} + </Label> + ))} + </Flex> + </Stack> + + <Stack gap="sm"> + <Label htmlFor="config-edit-workers">Parallel Workers</Label> + <Input + id="config-edit-workers" + max={MAX_WORKERS} + min={1} + type="number" + value={vm.state.workers} + onChange={(e) => vm.update('workers', Number(e.target.value))} + /> + </Stack> + + <Stack gap="sm"> + <Label htmlFor="config-edit-retries">Retries on Failure</Label> + <Input + id="config-edit-retries" + max={MAX_RETRIES} + min={0} + type="number" + value={vm.state.retries} + onChange={(e) => vm.update('retries', Number(e.target.value))} + /> + <Text size="sm" variant="muted">Number of times to retry a failed test (0 = no retries)</Text> + </Stack> + + <Stack gap="sm"> + <Label>Environments</Label> + {vm.state.environments.map((env, i) => ( + // eslint-disable-next-line react/no-array-index-key + <Flex key={`env-${i}`} gap="sm"> + <Input + placeholder="Name (e.g. staging)" + value={env.name} + onChange={(e) => vm.handleEnvironmentNameChange(i, e.target.value)} + /> + <Input + placeholder="https://staging.example.com" + value={env.url} + onChange={(e) => vm.handleEnvironmentUrlChange(i, e.target.value)} + /> + <Button + size="sm" + type="button" + variant="ghost" + onClick={() => vm.handleRemoveEnvironment(i)} + > + <Trash2 className="h-3 w-3" /> + </Button> + </Flex> + ))} + <Button + size="sm" + type="button" + variant="outline" + onClick={vm.handleAddEnvironment} + > + <Plus className="h-3 w-3 mr-1" /> Add Environment + </Button> + </Stack> + + <Stack gap="sm"> + <Label>Auth State (storageState)</Label> + {vm.storageStatePath ? ( + <Flex align="center" gap="sm"> + <Text className="flex-1 truncate rounded-md border border-border bg-bg-surface px-3 py-1.5" size="sm"> + {vm.storageStatePath} + </Text> + <Button + size="sm" + type="button" + variant="ghost" + onClick={vm.handleClearAuth} + > + <Trash2 className="h-3 w-3" /> + </Button> + </Flex> + ) : ( + <Button + size="sm" + type="button" + variant="outline" + onClick={vm.handleCaptureAuth} + > + Capture Auth State + </Button> + )} + <Text size="sm" variant="muted"> + Saves browser cookies and localStorage for authenticated test runs. + </Text> + </Stack> + + <Stack gap="sm"> + <Label htmlFor="config-edit-directory">Test Directory</Label> + <Input + required + id="config-edit-directory" + type="text" + value={vm.state.testDirectory} + onChange={(event) => vm.update('testDirectory', event.target.value)} + /> + </Stack> + + <DialogFooter> + <Button + type="button" + variant="ghost" + onClick={() => onOpenChange(false)} + > + Cancel + </Button> + <Button disabled={vm.saving} type="submit"> + {vm.saving ? 'Saving...' : 'Save'} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/test-suite/components/ConfigEditDialog/index.ts b/src/renderer/features/test-suite/components/ConfigEditDialog/index.ts new file mode 100644 index 00000000..a3957251 --- /dev/null +++ b/src/renderer/features/test-suite/components/ConfigEditDialog/index.ts @@ -0,0 +1 @@ +export { ConfigEditDialog } from './ConfigEditDialog'; diff --git a/src/renderer/features/test-suite/components/ConfigEditDialog/useConfigEditDialog.ts b/src/renderer/features/test-suite/components/ConfigEditDialog/useConfigEditDialog.ts new file mode 100644 index 00000000..0a29f07f --- /dev/null +++ b/src/renderer/features/test-suite/components/ConfigEditDialog/useConfigEditDialog.ts @@ -0,0 +1,167 @@ +import type { TestSuiteConfig } from '@shared/ipc/test-suite'; +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { useSaveTestSuiteConfig } from '../../api/useSaveTestSuiteConfig'; +import { useFormWithReset } from '../../hooks/useFormWithReset'; +import { useUrlValidation } from '../../hooks/useUrlValidation'; +import { + DEFAULT_ACTION_TIMEOUT, + DEFAULT_NAVIGATION_TIMEOUT, + DEFAULT_VIEWPORT_HEIGHT, + DEFAULT_VIEWPORT_WIDTH, +} from '../../lib/constants'; + +type ScreenshotMode = TestSuiteConfig['screenshotMode']; + +interface FormState { + name: string; + targetUrl: string; + viewportWidth: number; + viewportHeight: number; + screenshotMode: ScreenshotMode; + testDirectory: string; + browsers: Array<'chromium' | 'firefox' | 'webkit'>; + workers: number; + retries: number; + environments: Array<{ name: string; url: string }>; +} + +function defaultsFor(config: TestSuiteConfig | null): FormState { + if (config) { + return { + name: config.name, + targetUrl: config.targetUrl, + viewportWidth: config.viewportWidth, + viewportHeight: config.viewportHeight, + screenshotMode: config.screenshotMode, + testDirectory: config.testDirectory, + browsers: config.browsers, + workers: config.workers, + retries: config.retries, + environments: config.environments, + }; + } + return { + name: 'new-config', + targetUrl: 'http://localhost:3000', + viewportWidth: DEFAULT_VIEWPORT_WIDTH, + viewportHeight: DEFAULT_VIEWPORT_HEIGHT, + screenshotMode: 'smart', + testDirectory: 'test-suite/', + browsers: ['chromium'], + workers: 1, + retries: 1, + environments: [], + }; +} + +interface UseConfigEditDialogProps { + projectId: string; + config: TestSuiteConfig | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function useConfigEditDialog({ + projectId, + config, + open, + onOpenChange, +}: UseConfigEditDialogProps) { + const save = useSaveTestSuiteConfig(projectId); + const { values: state, update } = useFormWithReset<FormState>( + defaultsFor(config), + open, + [config], + ); + const { urlError, validate: validateUrl } = useUrlValidation(); + + const handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => { + event.preventDefault(); + + if (!validateUrl(state.targetUrl)) return; + + const now = new Date().toISOString(); + const merged: TestSuiteConfig = { + id: config?.id ?? crypto.randomUUID(), + name: state.name, + targetUrl: state.targetUrl, + viewportWidth: state.viewportWidth, + viewportHeight: state.viewportHeight, + screenshotMode: state.screenshotMode, + testDirectory: state.testDirectory, + saveScreenshotsToTemp: config?.saveScreenshotsToTemp ?? false, + navigationTimeout: config?.navigationTimeout ?? DEFAULT_NAVIGATION_TIMEOUT, + actionTimeout: config?.actionTimeout ?? DEFAULT_ACTION_TIMEOUT, + browsers: state.browsers, + workers: state.workers, + retries: state.retries, + environments: state.environments, + activeEnvironment: config?.activeEnvironment, + storageStatePath: config?.storageStatePath, + isActive: config?.isActive ?? false, + createdAt: config?.createdAt ?? now, + updatedAt: now, + }; + + save.mutate(merged, { + onSuccess: () => { + onOpenChange(false); + }, + }); + }; + + const handleBrowserToggle = (browser: 'chromium' | 'firefox' | 'webkit', checked: boolean | 'indeterminate') => { + if (checked === true) update('browsers', [...state.browsers, browser]); + else update('browsers', state.browsers.filter((x) => x !== browser)); + }; + + const handleEnvironmentNameChange = (index: number, value: string) => { + const envs = [...state.environments]; + envs[index] = { ...envs[index], name: value }; + update('environments', envs); + }; + + const handleEnvironmentUrlChange = (index: number, value: string) => { + const envs = [...state.environments]; + envs[index] = { ...envs[index], url: value }; + update('environments', envs); + }; + + const handleRemoveEnvironment = (index: number) => { + update('environments', state.environments.filter((_, j) => j !== index)); + }; + + const handleAddEnvironment = () => { + update('environments', [...state.environments, { name: '', url: '' }]); + }; + + const handleClearAuth = () => { + void ipc(TEST_SUITE.AUTH.CLEAR, { projectId }); + }; + + const handleCaptureAuth = () => { + void ipc(TEST_SUITE.AUTH.SAVE, { projectId }); + }; + + const title = config ? 'Edit Configuration' : 'New Configuration'; + + return { + state, + urlError, + saving: save.isPending, + title, + storageStatePath: config?.storageStatePath, + update, + handleSubmit, + handleBrowserToggle, + handleEnvironmentNameChange, + handleEnvironmentUrlChange, + handleRemoveEnvironment, + handleAddEnvironment, + handleClearAuth, + handleCaptureAuth, + }; +} diff --git a/src/renderer/features/test-suite/components/CreateSharedStepDialog/CreateSharedStepDialog.tsx b/src/renderer/features/test-suite/components/CreateSharedStepDialog/CreateSharedStepDialog.tsx new file mode 100644 index 00000000..ec755635 --- /dev/null +++ b/src/renderer/features/test-suite/components/CreateSharedStepDialog/CreateSharedStepDialog.tsx @@ -0,0 +1,71 @@ +import { Plus } from 'lucide-react'; + +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + Input, + Label, + Stack, + Text, +} from '@ui'; + +import { useCreateSharedStepDialog } from './useCreateSharedStepDialog'; + +export function CreateSharedStepDialog({ projectId }: { projectId: string }) { + const vm = useCreateSharedStepDialog(projectId); + + return ( + <Dialog open={vm.open} onOpenChange={vm.setOpen}> + <DialogTrigger asChild> + <Button size="sm"> + <Plus className="h-4 w-4" /> Save Current Steps + </Button> + </DialogTrigger> + <DialogContent className="max-w-sm"> + <DialogHeader> + <DialogTitle>Save as Shared Steps</DialogTitle> + </DialogHeader> + <Stack gap="md"> + <Stack gap="sm"> + <Label>Name</Label> + <Input + placeholder="Login flow" + value={vm.name} + onChange={(e) => { vm.setName(e.target.value); }} + /> + </Stack> + <Stack gap="sm"> + <Label>Domain</Label> + <Input + placeholder="Auth" + value={vm.domain} + onChange={(e) => { vm.setDomain(e.target.value); }} + /> + </Stack> + <Stack gap="sm"> + <Label>Description (optional)</Label> + <Input + placeholder="Standard login with email/password" + value={vm.description} + onChange={(e) => { vm.setDescription(e.target.value); }} + /> + </Stack> + <Text size="sm" variant="muted"> + {vm.recordedStepsCount} steps from current recording will be saved. + </Text> + <Button + className="w-full" + disabled={!vm.canCreate} + onClick={vm.handleCreate} + > + Save Shared Steps + </Button> + </Stack> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/test-suite/components/CreateSharedStepDialog/index.ts b/src/renderer/features/test-suite/components/CreateSharedStepDialog/index.ts new file mode 100644 index 00000000..07185419 --- /dev/null +++ b/src/renderer/features/test-suite/components/CreateSharedStepDialog/index.ts @@ -0,0 +1 @@ +export { CreateSharedStepDialog } from './CreateSharedStepDialog'; diff --git a/src/renderer/features/test-suite/components/CreateSharedStepDialog/useCreateSharedStepDialog.ts b/src/renderer/features/test-suite/components/CreateSharedStepDialog/useCreateSharedStepDialog.ts new file mode 100644 index 00000000..231f7928 --- /dev/null +++ b/src/renderer/features/test-suite/components/CreateSharedStepDialog/useCreateSharedStepDialog.ts @@ -0,0 +1,53 @@ +import { useCallback, useState } from 'react'; + +import { useCreateSharedSteps } from '../../api/useSharedSteps'; +import { useMutationWithDialogClose } from '../../hooks/useMutationWithDialogClose'; +import { useTestSuiteStore } from '../../test-suite-store'; + +export function useCreateSharedStepDialog(projectId: string) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [domain, setDomain] = useState(''); + const [description, setDescription] = useState(''); + const createSharedSteps = useCreateSharedSteps(); + const recordedSteps = useTestSuiteStore((s) => s.recordedSteps); + + const resetFields = useCallback(() => { + setName(''); + setDomain(''); + setDescription(''); + }, []); + + const { handleMutate } = useMutationWithDialogClose( + createSharedSteps, + () => setOpen(false), + resetFields, + ); + + const handleCreate = () => { + if (!name.trim() || !domain.trim() || recordedSteps.length === 0) return; + handleMutate({ + projectId, + name: name.trim(), + domain: domain.trim(), + description: description.trim() || undefined, + steps: recordedSteps.map((r) => r.step), + }); + }; + + const canCreate = !!name.trim() && !!domain.trim() && recordedSteps.length > 0; + + return { + open, + setOpen, + name, + setName, + domain, + setDomain, + description, + setDescription, + recordedStepsCount: recordedSteps.length, + canCreate, + handleCreate, + }; +} diff --git a/src/renderer/features/test-suite/components/CreateTaskFromRunDialog/CreateTaskFromRunDialog.tsx b/src/renderer/features/test-suite/components/CreateTaskFromRunDialog/CreateTaskFromRunDialog.tsx new file mode 100644 index 00000000..c765a5fa --- /dev/null +++ b/src/renderer/features/test-suite/components/CreateTaskFromRunDialog/CreateTaskFromRunDialog.tsx @@ -0,0 +1,105 @@ +import { ListPlus } from 'lucide-react'; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, + Textarea, +} from '@ui'; + +import { useCreateTaskFromRunDialog } from './useCreateTaskFromRunDialog'; + +import type { RunRecord } from '../../lib/types'; + +interface CreateTaskFromRunDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + runRecord: RunRecord; + scriptName: string; + projectId: string; + runId: string; +} + +export function CreateTaskFromRunDialog({ + open, + onOpenChange, + runRecord, + scriptName, + projectId, + runId, +}: CreateTaskFromRunDialogProps) { + const vm = useCreateTaskFromRunDialog({ open, onOpenChange, runRecord, scriptName, projectId, runId }); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl max-h-[85vh] flex flex-col"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <ListPlus className="h-5 w-5" /> + Create Task from Test Run + </DialogTitle> + </DialogHeader> + + <Stack className="overflow-y-auto flex-1 py-2" gap="md"> + <Stack gap="sm"> + <Label htmlFor="task-title">Title</Label> + <Input + id="task-title" + value={vm.title} + onChange={(e) => vm.setTitle(e.target.value)} + /> + </Stack> + + <Stack gap="sm"> + <Label htmlFor="task-priority">Priority</Label> + <Select value={vm.priority} onValueChange={(v) => vm.setPriority(v as typeof vm.priority)}> + <SelectTrigger className="w-40" id="task-priority"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {vm.priorities.map((p) => ( + <SelectItem key={p} value={p}> + {p.charAt(0).toUpperCase() + p.slice(1)} + </SelectItem> + ))} + </SelectContent> + </Select> + </Stack> + + <Stack className="flex-1" gap="sm"> + <Label htmlFor="task-description">Description</Label> + <Textarea + className="min-h-[300px] flex-1 font-mono text-xs" + id="task-description" + value={vm.description} + onChange={(e) => vm.setDescription(e.target.value)} + /> + </Stack> + </Stack> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button + disabled={!vm.canCreate || vm.creating} + onClick={vm.handleCreate} + > + {vm.creating ? 'Creating...' : 'Create Task'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/test-suite/components/CreateTaskFromRunDialog/index.ts b/src/renderer/features/test-suite/components/CreateTaskFromRunDialog/index.ts new file mode 100644 index 00000000..00053b5e --- /dev/null +++ b/src/renderer/features/test-suite/components/CreateTaskFromRunDialog/index.ts @@ -0,0 +1 @@ +export { CreateTaskFromRunDialog } from './CreateTaskFromRunDialog'; diff --git a/src/renderer/features/test-suite/components/CreateTaskFromRunDialog/useCreateTaskFromRunDialog.ts b/src/renderer/features/test-suite/components/CreateTaskFromRunDialog/useCreateTaskFromRunDialog.ts new file mode 100644 index 00000000..e33a16a9 --- /dev/null +++ b/src/renderer/features/test-suite/components/CreateTaskFromRunDialog/useCreateTaskFromRunDialog.ts @@ -0,0 +1,67 @@ +import { + useCreateProgressTask, +} from '@renderer/features/tasks/api/useProgressMutations'; + +import { useAttachRunToTask } from '../../api/useAttachRunToTask'; +import { useFormWithReset } from '../../hooks/useFormWithReset'; +import { buildDefaultDescription } from '../../lib/run-description'; + +import type { RunRecord } from '../../lib/types'; + +const PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const; + +interface UseCreateTaskFromRunDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + runRecord: RunRecord; + scriptName: string; + projectId: string; + runId: string; +} + +export function useCreateTaskFromRunDialog({ + open, + onOpenChange, + runRecord, + scriptName, + projectId, + runId, +}: UseCreateTaskFromRunDialogProps) { + const createTask = useCreateProgressTask(); + const attachRunToTask = useAttachRunToTask(); + + const { values, update } = useFormWithReset( + { + title: `Fix: ${scriptName} test failure`, + description: buildDefaultDescription(scriptName, runRecord), + priority: 'high' as (typeof PRIORITIES)[number], + }, + open, + [scriptName, runRecord], + ); + + const handleCreate = () => { + createTask.mutate( + { title: values.title, description: values.description, priority: values.priority, projectId }, + { + onSuccess: (task) => { + attachRunToTask.mutate({ runId, taskId: task.slug }); + onOpenChange(false); + }, + }, + ); + }; + + return { + title: values.title, + setTitle: (v: string) => update('title', v), + description: values.description, + setDescription: (v: string) => update('description', v), + priority: values.priority, + setPriority: (v: (typeof PRIORITIES)[number]) => update('priority', v), + priorities: PRIORITIES, + creating: createTask.isPending, + canCreate: !!values.title.trim(), + handleCreate, + }; +} diff --git a/src/renderer/features/test-suite/components/DataRunDialog/DataRunDialog.tsx b/src/renderer/features/test-suite/components/DataRunDialog/DataRunDialog.tsx new file mode 100644 index 00000000..66a4f718 --- /dev/null +++ b/src/renderer/features/test-suite/components/DataRunDialog/DataRunDialog.tsx @@ -0,0 +1,118 @@ +import { FileSpreadsheet } from 'lucide-react'; + +import { + Badge, + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Flex, + Input, + Label, + ScrollArea, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@ui'; + +import { useDataRunDialog } from './useDataRunDialog'; + +interface DataRunDialogProps { + scriptId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DataRunDialog({ scriptId, open, onOpenChange }: DataRunDialogProps) { + const vm = useDataRunDialog({ scriptId, onOpenChange }); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileSpreadsheet className="h-4 w-4" /> Data-Driven Run + </DialogTitle> + </DialogHeader> + <Stack gap="md"> + <Stack gap="sm"> + <Label>Data File (CSV or JSON)</Label> + <Flex gap="sm" wrap="nowrap"> + <Input + placeholder="/path/to/data.csv" + value={vm.filePath} + onChange={(e) => vm.setFilePath(e.target.value)} + /> + <Button variant="outline" onClick={vm.handleParse}> + Parse + </Button> + </Flex> + </Stack> + + {vm.parsed ? ( + <> + <Flex align="center" gap="sm"> + <Badge variant="secondary">{vm.parsed.rowCount} rows</Badge> + <Badge variant="secondary">{vm.parsed.headers.length} columns</Badge> + </Flex> + + <ScrollArea className="max-h-48 rounded border border-border"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-8">#</TableHead> + {vm.parsed.headers.map((h) => ( + <TableHead key={h}>{h}</TableHead> + ))} + </TableRow> + </TableHeader> + <TableBody> + {vm.parsed.rows.slice(0, 5).map((row, i) => ( + <TableRow key={`row-${String(i)}`}> + <TableCell className="text-text-muted">{i + 1}</TableCell> + {vm.parsed?.headers.map((h) => ( + <TableCell key={h} className="text-xs"> + {row[h] ?? ''} + </TableCell> + ))} + </TableRow> + ))} + {vm.parsed.rowCount > 5 ? ( + <TableRow> + <TableCell + className="text-center text-text-muted" + colSpan={vm.parsed.headers.length + 1} + > + ... and {vm.parsed.rowCount - 5} more rows + </TableCell> + </TableRow> + ) : null} + </TableBody> + </Table> + </ScrollArea> + + <Text size="sm" variant="muted"> + Use {'{{columnName}}'} in fill step values to substitute data from each row. + Test will run once per row. + </Text> + + <Button + className="w-full" + disabled={vm.executing} + onClick={vm.handleExecute} + > + Run {vm.parsed.rowCount} Iterations + </Button> + </> + ) : null} + </Stack> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/test-suite/components/DataRunDialog/index.ts b/src/renderer/features/test-suite/components/DataRunDialog/index.ts new file mode 100644 index 00000000..57edd7a0 --- /dev/null +++ b/src/renderer/features/test-suite/components/DataRunDialog/index.ts @@ -0,0 +1 @@ +export { DataRunDialog } from './DataRunDialog'; diff --git a/src/renderer/features/test-suite/components/DataRunDialog/useDataRunDialog.ts b/src/renderer/features/test-suite/components/DataRunDialog/useDataRunDialog.ts new file mode 100644 index 00000000..dc670222 --- /dev/null +++ b/src/renderer/features/test-suite/components/DataRunDialog/useDataRunDialog.ts @@ -0,0 +1,37 @@ +import { useState } from 'react'; + +import { useExecuteDataRun, useParseDataFile } from '../../api/useDataRun'; +import { useMutationWithDialogClose } from '../../hooks/useMutationWithDialogClose'; + +interface UseDataRunDialogProps { + scriptId: string; + onOpenChange: (open: boolean) => void; +} + +export function useDataRunDialog({ scriptId, onOpenChange }: UseDataRunDialogProps) { + const [filePath, setFilePath] = useState(''); + const parseFile = useParseDataFile(); + const executeRun = useExecuteDataRun(); + const { handleMutate, isPending } = useMutationWithDialogClose( + executeRun, + () => onOpenChange(false), + ); + + const handleParse = () => { + if (!filePath.trim()) return; + parseFile.mutate(filePath.trim()); + }; + + const handleExecute = () => handleMutate({ scriptId, dataFilePath: filePath.trim() }); + + const parsed = parseFile.data; + + return { + filePath, + setFilePath, + parsed, + executing: isPending, + handleParse, + handleExecute, + }; +} diff --git a/src/renderer/features/test-suite/components/DiffViewer/DiffViewer.tsx b/src/renderer/features/test-suite/components/DiffViewer/DiffViewer.tsx new file mode 100644 index 00000000..c5da5baa --- /dev/null +++ b/src/renderer/features/test-suite/components/DiffViewer/DiffViewer.tsx @@ -0,0 +1,139 @@ +import { + Badge, + Flex, + Grid, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Slider, + Stack, + Text, +} from '@ui'; + +import { fileUrl } from '../../lib/screenshot-utils'; + +import { useDiffViewer } from './useDiffViewer'; + +type ViewMode = 'side-by-side' | 'slider' | 'diff-only'; + +interface DiffViewerProps { + baselinePath: string; + actualPath: string; + diffPath: string; + mismatchPercentage: number; + status: string; +} + +export function DiffViewer({ + baselinePath, + actualPath, + diffPath, + mismatchPercentage, + status, +}: DiffViewerProps) { + const vm = useDiffViewer({ status }); + + return ( + <Stack gap="md"> + {/* Controls */} + <Flex align="center" gap="md"> + <Badge className={vm.statusColor}> + {status === 'match' ? 'Match' : `${mismatchPercentage}% diff`} + </Badge> + <Select value={vm.viewMode} onValueChange={(v) => vm.setViewMode(v as ViewMode)}> + <SelectTrigger className="w-36"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="side-by-side">Side by Side</SelectItem> + <SelectItem value="slider">Slider</SelectItem> + <SelectItem value="diff-only">Diff Only</SelectItem> + </SelectContent> + </Select> + </Flex> + + {/* View modes */} + {vm.viewMode === 'side-by-side' && ( + <Grid cols={3} gap="sm"> + <Stack gap="sm"> + <Text className="mb-1 font-semibold" size="sm" variant="muted">Baseline</Text> + <img + alt="Baseline" + className="rounded border border-border" + src={fileUrl(baselinePath)} + /> + </Stack> + <Stack gap="sm"> + <Text className="mb-1 font-semibold" size="sm" variant="muted">Actual</Text> + <img + alt="Actual" + className="rounded border border-border" + src={fileUrl(actualPath)} + /> + </Stack> + <Stack gap="sm"> + <Text className="mb-1 font-semibold" size="sm" variant="muted">Diff</Text> + {diffPath ? ( + <img + alt="Diff" + className="rounded border border-border" + src={fileUrl(diffPath)} + /> + ) : ( + <Flex align="center" className="h-full rounded border border-border" justify="center"> + <Text size="sm" variant="muted">No diff image</Text> + </Flex> + )} + </Stack> + </Grid> + )} + + {vm.viewMode === 'slider' && ( + <Stack gap="sm"> + <div className="relative overflow-hidden rounded border border-border"> + <img alt="Actual" className="block w-full" src={fileUrl(actualPath)} /> + <div + className="absolute inset-0 overflow-hidden" + style={{ width: `${vm.sliderValue}%` }} + > + <img alt="Baseline" className="block w-full" src={fileUrl(baselinePath)} /> + </div> + <div + className="absolute inset-y-0 w-0.5 bg-accent" + style={{ left: `${vm.sliderValue}%` }} + /> + </div> + <Slider + max={100} + min={0} + step={1} + value={vm.sliderPosition} + onValueChange={vm.setSliderPosition} + /> + <Flex justify="between"> + <Text size="sm" variant="muted">Baseline</Text> + <Text size="sm" variant="muted">Actual</Text> + </Flex> + </Stack> + )} + + {vm.viewMode === 'diff-only' && ( + <Stack gap="sm"> + {diffPath ? ( + <img + alt="Diff" + className="rounded border border-border" + src={fileUrl(diffPath)} + /> + ) : ( + <Flex align="center" className="h-48 rounded border border-border" justify="center"> + <Text size="sm" variant="muted">No diff image available</Text> + </Flex> + )} + </Stack> + )} + </Stack> + ); +} diff --git a/src/renderer/features/test-suite/components/DiffViewer/index.ts b/src/renderer/features/test-suite/components/DiffViewer/index.ts new file mode 100644 index 00000000..5414141f --- /dev/null +++ b/src/renderer/features/test-suite/components/DiffViewer/index.ts @@ -0,0 +1 @@ +export { DiffViewer } from './DiffViewer'; diff --git a/src/renderer/features/test-suite/components/DiffViewer/useDiffViewer.ts b/src/renderer/features/test-suite/components/DiffViewer/useDiffViewer.ts new file mode 100644 index 00000000..da952d49 --- /dev/null +++ b/src/renderer/features/test-suite/components/DiffViewer/useDiffViewer.ts @@ -0,0 +1,30 @@ +import { useState } from 'react'; + +type ViewMode = 'side-by-side' | 'slider' | 'diff-only'; + +function getStatusColor(status: string): string { + if (status === 'match') return 'bg-green-600'; + if (status === 'mismatch') return 'bg-destructive'; + return 'bg-yellow-600'; +} + +interface UseDiffViewerProps { + status: string; +} + +export function useDiffViewer({ status }: UseDiffViewerProps) { + const [viewMode, setViewMode] = useState<ViewMode>('side-by-side'); + const [sliderPosition, setSliderPosition] = useState<number[]>([50]); + + const statusColor = getStatusColor(status); + const sliderValue = sliderPosition[0] ?? 50; + + return { + viewMode, + setViewMode, + sliderPosition, + setSliderPosition, + statusColor, + sliderValue, + }; +} diff --git a/src/renderer/features/test-suite/components/ExportPanel/ExportPanel.tsx b/src/renderer/features/test-suite/components/ExportPanel/ExportPanel.tsx new file mode 100644 index 00000000..5a34842b --- /dev/null +++ b/src/renderer/features/test-suite/components/ExportPanel/ExportPanel.tsx @@ -0,0 +1,123 @@ +import { Download, Eye, FileCode, FolderTree } from 'lucide-react'; + +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Code, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Flex, + Grid, + MetadataItem, + MetadataList, + PageContent, + Text, +} from '@ui'; + +import { useExportPanel } from './useExportPanel'; + +export function ExportPanel() { + const vm = useExportPanel(); + + return ( + <PageContent> + <Grid className="p-6" cols={2} gap="lg"> + {/* Left column: File tree + actions */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FolderTree className="size-4" /> + Export Files + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="rounded-md border border-border bg-surface-base p-3 font-mono text-sm"> + <Flex align="center" className="text-text-muted" gap="sm"> + <FolderTree className="size-3" /> + .github/ + </Flex> + <Flex align="center" className="ml-4 text-text-muted" gap="sm"> + <FolderTree className="size-3" /> + workflows/ + </Flex> + <Flex align="center" className="ml-8 text-text-primary" gap="sm"> + <FileCode className="size-3" /> + test-suite.yml + {vm.fileExists ? ( + <Text className="text-text-warning" size="sm">(exists)</Text> + ) : null} + </Flex> + </div> + + {vm.committed ? ( + <Text className="rounded-md border border-border-success bg-surface-success/10 p-3" size="sm" variant="success"> + Workflow file written to {vm.filePath} + </Text> + ) : null} + + <Flex gap="sm"> + <Button + disabled={vm.loading || !vm.projectId} + size="sm" + variant="outline" + onClick={vm.handlePreview} + > + <Eye className="mr-1.5 size-3.5" /> + Preview YAML + </Button> + <Button + disabled={vm.loading || !vm.projectId} + size="sm" + variant="primary" + onClick={vm.handleCommit} + > + <Download className="mr-1.5 size-3.5" /> + Export & Commit + </Button> + </Flex> + </CardContent> + </Card> + + {/* Right column: Workflow summary */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileCode className="size-4" /> + Workflow Summary + </CardTitle> + </CardHeader> + <CardContent> + <MetadataList className="text-sm" variant="stacked"> + <MetadataItem className="justify-between" label="Trigger" value="pull_request" variant="inline" /> + <MetadataItem className="justify-between" label="Paths" value={`${vm.testDir}/**, src/**`} variant="inline" /> + <MetadataItem className="justify-between" label="Runner" value="ubuntu-latest" variant="inline" /> + <MetadataItem className="justify-between" label="Node Version" value="22" variant="inline" /> + <MetadataItem className="justify-between" label="Artifacts" value={`${vm.testDir}/screenshots/, playwright-report/`} variant="inline" /> + <MetadataItem + className="justify-between" + label="Tests Included" + value={`${vm.scriptCount} ${vm.scriptCount === 1 ? 'script' : 'scripts'}`} + variant="inline" + /> + </MetadataList> + </CardContent> + </Card> + </Grid> + + {/* YAML Preview Dialog */} + <Dialog open={vm.dialogOpen} onOpenChange={vm.setDialogOpen}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>Workflow YAML Preview</DialogTitle> + </DialogHeader> + <Code className="max-h-96 overflow-auto">{vm.yamlPreview}</Code> + </DialogContent> + </Dialog> + </PageContent> + ); +} diff --git a/src/renderer/features/test-suite/components/ExportPanel/index.ts b/src/renderer/features/test-suite/components/ExportPanel/index.ts new file mode 100644 index 00000000..750e2bd9 --- /dev/null +++ b/src/renderer/features/test-suite/components/ExportPanel/index.ts @@ -0,0 +1 @@ +export { ExportPanel } from './ExportPanel'; diff --git a/src/renderer/features/test-suite/components/ExportPanel/useExportPanel.ts b/src/renderer/features/test-suite/components/ExportPanel/useExportPanel.ts new file mode 100644 index 00000000..da275743 --- /dev/null +++ b/src/renderer/features/test-suite/components/ExportPanel/useExportPanel.ts @@ -0,0 +1,78 @@ +import { useCallback, useState } from 'react'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { useLooseParams } from '@renderer/shared/hooks'; +import { ipc } from '@renderer/shared/lib/ipc'; +import { useToastStore } from '@renderer/shared/stores/toast-store'; + +import { useTestSuiteConfig } from '../../api/useTestSuiteConfig'; +import { useTestSuiteScripts } from '../../api/useTestSuiteScripts'; + +export function useExportPanel() { + const { projectId } = useLooseParams(); + const { data: config } = useTestSuiteConfig(projectId); + const { data: scripts } = useTestSuiteScripts(projectId); + + const [yamlPreview, setYamlPreview] = useState(''); + const [filePath, setFilePath] = useState(''); + const [fileExists, setFileExists] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [committed, setCommitted] = useState(false); + const [loading, setLoading] = useState(false); + + const rawTestDir = config?.testDirectory ?? 'tests/e2e'; + const testDir = rawTestDir.replace(/\/+$/, ''); + const scriptCount = scripts?.length ?? 0; + + const addToast = useToastStore((s) => s.addToast); + + const handlePreview = useCallback(async () => { + if (!projectId) return; + setLoading(true); + try { + const result = await ipc(TEST_SUITE.EXPORT['CI-PREVIEW'], { projectId }); + setYamlPreview(result.yaml); + setFilePath(result.filePath); + setFileExists(result.exists); + setDialogOpen(true); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + addToast(`Failed to preview CI workflow: ${message}`, 'error'); + } finally { + setLoading(false); + } + }, [projectId, addToast]); + + const handleCommit = useCallback(async () => { + if (!projectId) return; + setLoading(true); + try { + const result = await ipc(TEST_SUITE.EXPORT['CI-COMMIT'], { projectId }); + if (result.committed) { + setCommitted(true); + setFilePath(result.filePath); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + addToast(`Failed to commit CI workflow: ${message}`, 'error'); + } finally { + setLoading(false); + } + }, [projectId, addToast]); + + return { + projectId, + testDir, + scriptCount, + yamlPreview, + filePath, + fileExists, + dialogOpen, + setDialogOpen, + committed, + loading, + handlePreview, + handleCommit, + }; +} diff --git a/src/renderer/features/test-suite/components/HealthScoreCard.tsx b/src/renderer/features/test-suite/components/HealthScoreCard.tsx new file mode 100644 index 00000000..e0431750 --- /dev/null +++ b/src/renderer/features/test-suite/components/HealthScoreCard.tsx @@ -0,0 +1,57 @@ +import { Card, CardContent, Flex, Progress, Stack, Text } from '@ui'; + +import { HEALTH_WEIGHTS } from '../lib/constants'; + +import type { HealthScore } from '../lib/health-score'; + +// ─── ScoreBar ──────────────────────────────────────────── + +interface ScoreBarProps { + label: string; + max: number; + value: number; +} + +function ScoreBar({ label, max, value }: ScoreBarProps) { + const pct = max > 0 ? (value / max) * 100 : 0; + return ( + <Flex align="center" gap="sm" wrap="nowrap"> + <Text className="w-16" size="sm" variant="muted"> + {label} + </Text> + <Progress className="flex-1" size="sm" value={pct} /> + <Text className="w-8 text-right" size="sm" variant="muted"> + {value}/{max} + </Text> + </Flex> + ); +} + +// ─── HealthScoreCard ───────────────────────────────────── + +interface HealthScoreCardProps { + health: HealthScore; +} + +export function HealthScoreCard({ health }: HealthScoreCardProps) { + return ( + <Card> + <CardContent className="p-6"> + <Flex align="center" gap="lg" wrap="nowrap"> + <Text className={`text-6xl font-bold ${health.color}`}>{health.grade}</Text> + <Stack className="flex-1" gap="sm"> + <Flex align="center" justify="between" wrap="nowrap"> + <Text className="font-medium">Test Health Score</Text> + <Text className={`text-lg font-semibold ${health.color}`}>{health.score}/100</Text> + </Flex> + <Stack gap="none"> + <ScoreBar label="Pass Rate" max={HEALTH_WEIGHTS.passRate} value={health.breakdown.passRate} /> + <ScoreBar label="Stability" max={HEALTH_WEIGHTS.stability} value={health.breakdown.stability} /> + <ScoreBar label="Speed" max={HEALTH_WEIGHTS.speed} value={health.breakdown.speed} /> + </Stack> + </Stack> + </Flex> + </CardContent> + </Card> + ); +} diff --git a/src/renderer/features/test-suite/components/LibraryBulkActions.tsx b/src/renderer/features/test-suite/components/LibraryBulkActions.tsx new file mode 100644 index 00000000..8bb33143 --- /dev/null +++ b/src/renderer/features/test-suite/components/LibraryBulkActions.tsx @@ -0,0 +1,46 @@ +import { Play, Trash2 } from 'lucide-react'; + +import { Button, Flex, Text } from '@ui'; + +interface LibraryBulkActionsProps { + batchRunPending: boolean; + selectedCount: number; + onDeleteSelected: () => void; + onRunSelected: () => void; +} + +export function LibraryBulkActions({ + batchRunPending, + selectedCount, + onDeleteSelected, + onRunSelected, +}: LibraryBulkActionsProps) { + if (selectedCount === 0) return null; + + return ( + <Flex + align="center" + className="border-t border-border bg-bg-surface px-4 py-2" + gap="md" + wrap="nowrap" + > + <Text variant="muted">{selectedCount} selected</Text> + <Button + disabled={batchRunPending} + size="sm" + variant="ghost" + onClick={onRunSelected} + > + <Play className="h-3 w-3" /> Run Selected + </Button> + <Button + className="text-destructive" + size="sm" + variant="ghost" + onClick={onDeleteSelected} + > + <Trash2 className="h-3 w-3" /> Delete + </Button> + </Flex> + ); +} diff --git a/src/renderer/features/test-suite/components/LibraryPanel/LibraryPanel.tsx b/src/renderer/features/test-suite/components/LibraryPanel/LibraryPanel.tsx new file mode 100644 index 00000000..227ce59f --- /dev/null +++ b/src/renderer/features/test-suite/components/LibraryPanel/LibraryPanel.tsx @@ -0,0 +1,134 @@ +import { Plus } from 'lucide-react'; + +import { + Button, Checkbox, PageContent, ScrollArea, Stack, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Text, +} from '@ui'; + +import { SPARKLINE_RUN_LIMIT } from '../../lib/constants'; +import { DataRunDialog } from '../DataRunDialog'; +import { LibraryBulkActions } from '../LibraryBulkActions'; +import { LibraryScriptRow } from '../LibraryScriptRow'; +import { LibraryTagFilter } from '../LibraryTagFilter'; +import { LibraryToolbar } from '../LibraryToolbar'; +import { RunSparkline } from '../RunSparkline'; +import { ScheduleDialog } from '../ScheduleDialog'; + +import { useLibraryPanel, useRunHistory } from './useLibraryPanel'; + +function ScriptSparkline({ scriptId }: { scriptId: string }) { + const { data: history = [] } = useRunHistory(scriptId, SPARKLINE_RUN_LIMIT); + return <RunSparkline results={history} />; +} + +export function LibraryPanel() { + const vm = useLibraryPanel(); + + return ( + <PageContent> + <LibraryToolbar + search={vm.search} + statusCounts={vm.statusCounts} + statusFilter={vm.statusFilter} + onNewTest={vm.onNewTest} + onSearchChange={vm.setSearch} + onStatusFilterChange={vm.setStatusFilter} + /> + <LibraryTagFilter + allTags={vm.allTags} + batchRunPending={vm.batchRunPending} + scripts={vm.scripts} + selectedTags={vm.selectedTags} + onRunTagged={vm.onRunTagged} + onToggleTag={vm.toggleTag} + /> + + <ScrollArea className="flex-1"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-10"> + <Checkbox + checked={vm.selected.size === vm.filtered.length && vm.filtered.length > 0} + onCheckedChange={vm.toggleAll} + /> + </TableHead> + <TableHead>Name</TableHead> + <TableHead className="w-20 text-center">Steps</TableHead> + <TableHead className="w-28">Created</TableHead> + <TableHead className="w-28">Updated</TableHead> + <TableHead className="w-32">History</TableHead> + <TableHead className="w-24">Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {vm.filtered.map((script) => ( + <LibraryScriptRow + key={script.id} + formatDate={vm.formatDate} + isFlaky={vm.flakySet.has(script.id)} + isSelected={vm.selected.has(script.id)} + isWatched={vm.watchedSet.has(script.id)} + script={script} + sparkline={<ScriptSparkline scriptId={script.id} />} + onDataRun={() => vm.setDataRunScriptId(script.id)} + onDelete={() => vm.onDeleteScript(script.id)} + onEdit={() => vm.onEdit(script.id)} + onRun={() => vm.onRun(script.id)} + onSchedule={() => vm.setScheduleScriptId(script.id)} + onToggleSelect={() => vm.toggleSelect(script.id)} + onToggleWatch={() => vm.onToggleWatch(script.id)} + /> + ))} + {vm.filtered.length === 0 ? ( + <TableRow> + <TableCell className="py-8 text-center text-text-muted" colSpan={7}> + {vm.search ? ( + 'No matching tests' + ) : ( + <Stack align="center" gap="md"> + <Text>No tests recorded yet</Text> + <Button + data-testid="create-starter-test" + disabled={vm.saveScriptPending || vm.saveConfigPending} + size="sm" + variant="outline" + onClick={() => void vm.onCreateStarterTest()} + > + <Plus className="h-4 w-4" /> + {vm.config ? 'Create Starter Test' : 'Create Default Config + Starter Test'} + </Button> + </Stack> + )} + </TableCell> + </TableRow> + ) : null} + </TableBody> + </Table> + </ScrollArea> + + <LibraryBulkActions + batchRunPending={vm.batchRunPending} + selectedCount={vm.selected.size} + onDeleteSelected={vm.onDeleteSelected} + onRunSelected={vm.onRunSelected} + /> + + {vm.scheduleScriptId && vm.projectId ? ( + <ScheduleDialog + open={!!vm.scheduleScriptId} + projectId={vm.projectId} + scriptId={vm.scheduleScriptId} + onOpenChange={(open) => { if (!open) vm.setScheduleScriptId(null); }} + /> + ) : null} + {vm.dataRunScriptId ? ( + <DataRunDialog + open={!!vm.dataRunScriptId} + scriptId={vm.dataRunScriptId} + onOpenChange={(open) => { if (!open) vm.setDataRunScriptId(null); }} + /> + ) : null} + </PageContent> + ); +} diff --git a/src/renderer/features/test-suite/components/LibraryPanel/index.ts b/src/renderer/features/test-suite/components/LibraryPanel/index.ts new file mode 100644 index 00000000..3172992b --- /dev/null +++ b/src/renderer/features/test-suite/components/LibraryPanel/index.ts @@ -0,0 +1 @@ +export { LibraryPanel } from './LibraryPanel'; diff --git a/src/renderer/features/test-suite/components/LibraryPanel/useLibraryPanel.ts b/src/renderer/features/test-suite/components/LibraryPanel/useLibraryPanel.ts new file mode 100644 index 00000000..2ff9519d --- /dev/null +++ b/src/renderer/features/test-suite/components/LibraryPanel/useLibraryPanel.ts @@ -0,0 +1,130 @@ +import { useState } from 'react'; + +import { useLooseParams } from '@renderer/shared/hooks'; + +import { useDeleteScript } from '../../api/useDeleteScript'; +import { useBatchRun, useRuns, useRunScript } from '../../api/useRuns'; +import { useSaveScript } from '../../api/useSaveScript'; +import { useSaveTestSuiteConfig } from '../../api/useSaveTestSuiteConfig'; +import { useFlakyTests, useRunHistory } from '../../api/useTestSuiteAnalytics'; +import { useTestSuiteConfig } from '../../api/useTestSuiteConfig'; +import { useTestSuiteScripts } from '../../api/useTestSuiteScripts'; +import { useStartWatch, useStopWatch, useWatchedScripts } from '../../api/useWatchMode'; +import { useLibraryFilters } from '../../hooks/useLibraryFilters'; +import { TAB } from '../../lib/constants'; +import { buildDefaultConfig, buildStarterTest } from '../../lib/starter-test'; +import { useTestSuiteStore } from '../../test-suite-store'; + +export { useRunHistory }; + +export function useLibraryPanel() { + const { projectId } = useLooseParams(); + const { data: scripts = [] } = useTestSuiteScripts(projectId); + const { data: flakyTests = [] } = useFlakyTests(projectId); + const flakySet = new Set(flakyTests.map((f) => f.scriptId)); + const { data: watchedScripts = [] } = useWatchedScripts(); + const { data: allRuns = [] } = useRuns(); + const { data: config } = useTestSuiteConfig(projectId); + + const startWatch = useStartWatch(); + const stopWatch = useStopWatch(); + const runScript = useRunScript(); + const batchRun = useBatchRun(); + const deleteScript = useDeleteScript(projectId ?? ''); + const saveScript = useSaveScript(projectId ?? ''); + const saveConfig = useSaveTestSuiteConfig(projectId ?? ''); + + const watchedSet = new Set(watchedScripts); + const setActiveTab = useTestSuiteStore((s) => s.setActiveTab); + const setSelectedScriptId = useTestSuiteStore((s) => s.setSelectedScriptId); + const statusFilter = useTestSuiteStore((s) => s.libraryStatusFilter); + const setStatusFilter = useTestSuiteStore((s) => s.setLibraryStatusFilter); + + const [scheduleScriptId, setScheduleScriptId] = useState<string | null>(null); + const [dataRunScriptId, setDataRunScriptId] = useState<string | null>(null); + + const { + allTags, filtered, formatDate, search, selected, selectedTags, + setSearch, statusCounts, toggleAll, toggleSelect, toggleTag, + } = useLibraryFilters({ allRuns, flakySet, scripts, statusFilter }); + + const onNewTest = () => { + setSelectedScriptId(null); + setActiveTab(TAB.RECORDING); + }; + + const onCreateStarterTest = async () => { + if (!projectId) return; + const resolvedConfig = config ?? (await saveConfig.mutateAsync(buildDefaultConfig())); + await saveScript.mutateAsync( + buildStarterTest({ projectId, targetUrl: resolvedConfig.targetUrl }), + ); + }; + + const onEdit = (id: string) => { + setSelectedScriptId(id); + setActiveTab(TAB.RECORDING); + }; + + const onRun = (scriptId: string) => { + setSelectedScriptId(scriptId); + runScript.mutate({ scriptId }, { onSuccess: () => setActiveTab(TAB.RESULTS) }); + }; + + const onToggleWatch = (scriptId: string) => { + if (watchedSet.has(scriptId)) { + stopWatch.mutate(scriptId); + } else { + startWatch.mutate(scriptId); + } + }; + + const onDeleteSelected = () => { + for (const id of selected) deleteScript.mutate(id); + }; + + const onRunSelected = () => { + batchRun.mutate({ scriptIds: Array.from(selected) }); + }; + + const onRunTagged = (ids: string[]) => { + batchRun.mutate({ scriptIds: ids }); + }; + + return { + projectId, + scripts, + flakySet, + watchedSet, + config, + batchRunPending: batchRun.isPending, + saveScriptPending: saveScript.isPending, + saveConfigPending: saveConfig.isPending, + scheduleScriptId, + setScheduleScriptId, + dataRunScriptId, + setDataRunScriptId, + allTags, + filtered, + formatDate, + search, + selected, + selectedTags, + setSearch, + statusCounts, + statusFilter, + setStatusFilter, + toggleAll, + toggleSelect, + toggleTag, + onNewTest, + onCreateStarterTest, + onEdit, + onRun, + onToggleWatch, + onDeleteSelected, + onRunSelected, + onRunTagged, + onDeleteScript: (id: string) => deleteScript.mutate(id), + }; +} diff --git a/src/renderer/features/test-suite/components/LibraryScriptRow/LibraryScriptRow.tsx b/src/renderer/features/test-suite/components/LibraryScriptRow/LibraryScriptRow.tsx new file mode 100644 index 00000000..94fa787a --- /dev/null +++ b/src/renderer/features/test-suite/components/LibraryScriptRow/LibraryScriptRow.tsx @@ -0,0 +1,144 @@ +import { + Calendar, + Eye, + EyeOff, + FileSpreadsheet, + MoreHorizontal, + Play, +} from 'lucide-react'; + +import type { QaScriptSchema } from '@shared/ipc/test-suite'; + +import { + Badge, + Button, + Checkbox, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Flex, + TableCell, + TableRow, + Text, +} from '@ui'; + +import type { z } from 'zod'; + +type QaScript = z.infer<typeof QaScriptSchema>; + +interface LibraryScriptRowProps { + formatDate: (dateStr: string) => string; + isFlaky: boolean; + isSelected: boolean; + isWatched: boolean; + script: QaScript; + sparkline: React.ReactNode; + onDataRun: () => void; + onDelete: () => void; + onEdit: () => void; + onRun: () => void; + onSchedule: () => void; + onToggleSelect: () => void; + onToggleWatch: () => void; +} + +export function LibraryScriptRow({ + formatDate, + isFlaky, + isSelected, + isWatched, + script, + sparkline, + onDataRun, + onDelete, + onEdit, + onRun, + onSchedule, + onToggleSelect, + onToggleWatch, +}: LibraryScriptRowProps) { + return ( + <TableRow> + <TableCell> + <Checkbox checked={isSelected} onCheckedChange={onToggleSelect} /> + </TableCell> + <TableCell className="font-medium"> + {script.name} + {isFlaky ? ( + <Badge className="ml-2" variant="secondary"> + flaky + </Badge> + ) : null} + {script.description ? ( + <Text className="ml-2" variant="muted"> + {script.description} + </Text> + ) : null} + {script.tags.map((t) => ( + <Badge key={t} className="ml-1 text-[10px]" variant="secondary"> + {t} + </Badge> + ))} + </TableCell> + <TableCell className="text-center"> + <Badge size="sm" variant="secondary"> + {script.steps.length} + </Badge> + </TableCell> + <TableCell className="text-sm text-text-muted"> + {formatDate(script.createdAt)} + </TableCell> + <TableCell className="text-sm text-text-muted"> + {formatDate(script.updatedAt)} + </TableCell> + <TableCell>{sparkline}</TableCell> + <TableCell> + <Flex align="center" gap="sm"> + <Button + size="icon" + title="Run" + variant="ghost" + onClick={onRun} + > + <Play className="h-4 w-4" /> + </Button> + <Button + size="icon" + title={isWatched ? 'Stop watching' : 'Watch for changes'} + variant="ghost" + onClick={onToggleWatch} + > + {isWatched ? ( + <Eye className="h-4 w-4 text-green-500" /> + ) : ( + <EyeOff className="h-4 w-4" /> + )} + </Button> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button size="icon" variant="ghost"> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={onEdit}>Edit</DropdownMenuItem> + <DropdownMenuItem onClick={onSchedule}> + <Calendar className="mr-2 h-4 w-4" /> Schedule + </DropdownMenuItem> + <DropdownMenuItem onClick={onDataRun}> + <FileSpreadsheet className="mr-2 h-4 w-4" /> Data-Driven Run + </DropdownMenuItem> + <DropdownMenuItem + className="text-destructive" + onClick={onDelete} + > + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </Flex> + </TableCell> + </TableRow> + ); +} diff --git a/src/renderer/features/test-suite/components/LibraryScriptRow/index.ts b/src/renderer/features/test-suite/components/LibraryScriptRow/index.ts new file mode 100644 index 00000000..0d1fbcb9 --- /dev/null +++ b/src/renderer/features/test-suite/components/LibraryScriptRow/index.ts @@ -0,0 +1 @@ +export { LibraryScriptRow } from './LibraryScriptRow'; diff --git a/src/renderer/features/test-suite/components/LibraryTagFilter.tsx b/src/renderer/features/test-suite/components/LibraryTagFilter.tsx new file mode 100644 index 00000000..13df8422 --- /dev/null +++ b/src/renderer/features/test-suite/components/LibraryTagFilter.tsx @@ -0,0 +1,73 @@ +import { Play } from 'lucide-react'; + +import type { QaScriptSchema } from '@shared/ipc/test-suite'; + +import { Badge, Button, Flex } from '@ui'; + +import type { z } from 'zod'; + +type QaScript = z.infer<typeof QaScriptSchema>; + +interface LibraryTagFilterProps { + allTags: string[]; + batchRunPending: boolean; + scripts: QaScript[]; + selectedTags: Set<string>; + onRunTagged: (scriptIds: string[]) => void; + onToggleTag: (tag: string) => void; +} + +export function LibraryTagFilter({ + allTags, + batchRunPending, + scripts, + selectedTags, + onRunTagged, + onToggleTag, +}: LibraryTagFilterProps) { + if (allTags.length === 0) return null; + + const handleRunTagged = () => { + const taggedScriptIds = scripts + .filter((s) => { + const scriptTags = new Set(s.tags); + for (const t of selectedTags) { + if (!scriptTags.has(t)) return false; + } + return true; + }) + .map((s) => s.id); + onRunTagged(taggedScriptIds); + }; + + return ( + <Flex + align="center" + className="px-4 py-1.5 border-b border-border" + gap="sm" + wrap="wrap" + > + {allTags.map((tag) => ( + <Badge + key={tag} + className="cursor-pointer" + variant={selectedTags.has(tag) ? 'default' : 'outline'} + onClick={() => onToggleTag(tag)} + > + {tag} + </Badge> + ))} + {selectedTags.size > 0 && ( + <Button + className="ml-2" + disabled={batchRunPending} + size="sm" + variant="ghost" + onClick={handleRunTagged} + > + <Play className="h-3 w-3 mr-1" /> Run Tagged + </Button> + )} + </Flex> + ); +} diff --git a/src/renderer/features/test-suite/components/LibraryToolbar.tsx b/src/renderer/features/test-suite/components/LibraryToolbar.tsx new file mode 100644 index 00000000..5a1f435e --- /dev/null +++ b/src/renderer/features/test-suite/components/LibraryToolbar.tsx @@ -0,0 +1,69 @@ +import { Plus } from 'lucide-react'; + +import { Button, Flex, SearchInput, Stack } from '@ui'; + +import type { StatusFilter } from '../test-suite-store'; + +const FILTER_LABELS: Record<StatusFilter, string> = { + all: 'All', + passed: 'Passed', + failed: 'Failed', + flaky: 'Flaky', + 'no-runs': 'No runs', +}; + +const FILTER_OPTIONS: StatusFilter[] = [ + 'all', + 'passed', + 'failed', + 'flaky', + 'no-runs', +]; + +interface LibraryToolbarProps { + search: string; + statusCounts: Record<StatusFilter, number>; + statusFilter: StatusFilter; + onNewTest: () => void; + onSearchChange: (value: string) => void; + onStatusFilterChange: (filter: StatusFilter) => void; +} + +export function LibraryToolbar({ + search, + statusCounts, + statusFilter, + onNewTest, + onSearchChange, + onStatusFilterChange, +}: LibraryToolbarProps) { + return ( + <Stack className="border-b border-border px-4 py-3" gap="sm"> + <Flex align="center" gap="sm" wrap="nowrap"> + <SearchInput + className="flex-1" + data-testid="test-suite-search" + placeholder="Search tests..." + value={search} + onChange={(e) => onSearchChange(e.target.value)} + onClear={() => onSearchChange('')} + /> + <Button size="sm" onClick={onNewTest}> + <Plus className="h-4 w-4" /> New Test + </Button> + </Flex> + <Flex align="center" gap="sm"> + {FILTER_OPTIONS.map((filter) => ( + <Button + key={filter} + size="sm" + variant={statusFilter === filter ? 'primary' : 'outline'} + onClick={() => onStatusFilterChange(filter)} + > + {`${FILTER_LABELS[filter]} (${statusCounts[filter]})`} + </Button> + ))} + </Flex> + </Stack> + ); +} diff --git a/src/renderer/features/test-suite/components/RecordingPanel/RecordingPanel.tsx b/src/renderer/features/test-suite/components/RecordingPanel/RecordingPanel.tsx new file mode 100644 index 00000000..d8a514ed --- /dev/null +++ b/src/renderer/features/test-suite/components/RecordingPanel/RecordingPanel.tsx @@ -0,0 +1,159 @@ +import { Circle, Play, Save, Square } from 'lucide-react'; + +import { + Badge, + Button, + Flex, + Input, + PageContent, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Separator, + Stack, + Text, +} from '@ui'; + +import { BrowserViewPanel } from '../BrowserViewPanel'; +import { SaveRecordingDialog } from '../SaveRecordingDialog'; +import { StepList } from '../StepList'; + +import { useDevServerButton, useRecordingPanel } from './useRecordingPanel'; + +interface DevServerButtonProps { + projectId: string; + serverRunning: boolean; + activeInstanceId: string | undefined; +} + +function DevServerButton({ projectId, serverRunning, activeInstanceId }: DevServerButtonProps) { + const vm = useDevServerButton({ projectId, serverRunning, activeInstanceId }); + + return ( + <> + <div className={`h-2 w-2 shrink-0 rounded-full ${vm.serverRunning ? 'bg-green-500 animate-pulse' : 'bg-text-muted/30'}`} /> + {vm.serverRunning && vm.activeInstanceId ? ( + <Button className="h-7" size="sm" variant="destructive" onClick={vm.handleStop}> + <Square className="h-3 w-3" /> Stop Server + </Button> + ) : ( + <Button + className="h-7" + disabled={vm.startPending} + size="sm" + onClick={vm.handleStart} + > + <Play className="h-3 w-3" /> Start Server + </Button> + )} + </> + ); +} + +export function RecordingPanel() { + const vm = useRecordingPanel(); + + if (!vm.projectId) return null; + + return ( + <PageContent className="flex h-full flex-col overflow-hidden p-1"> + <Stack className="h-full overflow-hidden rounded-md border border-border" gap="none"> + {/* Toolbar */} + <Flex align="center" className="shrink-0 border-b border-border px-3 py-1.5" gap="sm" wrap="nowrap"> + {vm.configs.length > 0 && vm.activeConfig ? ( + <Select + value={vm.activeConfig.id} + onValueChange={vm.onConfigChange} + > + <SelectTrigger className="h-7 w-44 text-xs"> + <SelectValue placeholder="Select config..." /> + </SelectTrigger> + <SelectContent> + {vm.configs.map((c) => ( + <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Text size="sm" variant="muted">No configs — add one in Settings</Text> + )} + + {vm.activeConfig ? ( + <Badge className="shrink-0 text-xs" variant="outline"> + {vm.activeConfig.screenshotMode === 'manual' ? 'No Screenshots' : `Screenshots: ${vm.activeConfig.screenshotMode}`} + </Badge> + ) : null} + + <DevServerButton + activeInstanceId={vm.activeInstance?.id} + projectId={vm.projectId} + serverRunning={vm.serverRunning} + /> + + <Separator className="mx-1 h-4" orientation="vertical" /> + + {vm.recording ? ( + <Button size="sm" variant="destructive" onClick={vm.onStopRecording}> + <Circle className="h-3.5 w-3.5 fill-current" /> Stop + </Button> + ) : ( + <div title={vm.recordTooltip || undefined}> + <Button disabled={!vm.canRecord} size="sm" onClick={vm.onStartRecording}> + <Circle className="h-3.5 w-3.5 fill-destructive text-destructive" /> Record + </Button> + </div> + )} + + <Input + className="h-7 max-w-[200px] flex-1 text-xs" + placeholder="Test name (optional)..." + readOnly={vm.recording} + value={vm.scriptName} + onChange={(e) => vm.setScriptName(e.target.value)} + /> + + <Flex align="center" className="ml-auto" gap="sm" wrap="nowrap"> + <Button + disabled={!vm.canSave || vm.saveScriptPending} + size="sm" + variant="outline" + onClick={() => vm.setSaveDialogOpen(true)} + > + <Save className="h-3.5 w-3.5" /> Save ({vm.recordedSteps.length}) + </Button> + </Flex> + </Flex> + + {/* Main split: steps + browser */} + <Flex align="stretch" className="flex-1 overflow-hidden" gap="none" wrap="nowrap"> + <Stack className="w-64 shrink-0 overflow-y-auto border-r border-border" gap="none"> + <Flex align="center" className="h-10 shrink-0 border-b border-border px-3" gap="none"> + <Text className="text-xs font-semibold uppercase text-text-muted"> + Steps ({vm.recordedSteps.length}) + </Text> + </Flex> + <StepList /> + </Stack> + <BrowserViewPanel + height={vm.vh} + recording={vm.recording} + serverRunning={vm.serverRunning} + url={vm.url} + width={vm.vw} + onUrlChange={vm.setUrlOverride} + /> + </Flex> + </Stack> + <SaveRecordingDialog + defaultName={vm.scriptName} + open={vm.saveDialogOpen} + projectId={vm.projectId} + steps={vm.recordedSteps} + testDirectory={vm.activeConfig?.testDirectory} + onOpenChange={vm.setSaveDialogOpen} + /> + </PageContent> + ); +} diff --git a/src/renderer/features/test-suite/components/RecordingPanel/index.ts b/src/renderer/features/test-suite/components/RecordingPanel/index.ts new file mode 100644 index 00000000..6d12503c --- /dev/null +++ b/src/renderer/features/test-suite/components/RecordingPanel/index.ts @@ -0,0 +1 @@ +export { RecordingPanel } from './RecordingPanel'; diff --git a/src/renderer/features/test-suite/components/RecordingPanel/useRecordingPanel.ts b/src/renderer/features/test-suite/components/RecordingPanel/useRecordingPanel.ts new file mode 100644 index 00000000..e84af173 --- /dev/null +++ b/src/renderer/features/test-suite/components/RecordingPanel/useRecordingPanel.ts @@ -0,0 +1,144 @@ +import { useMemo, useState } from 'react'; + +import type { ScopeRef } from '@shared/ipc/runners/schemas'; +import type { TestSuiteConfig } from '@shared/ipc/test-suite'; + +import { useLooseParams } from '@renderer/shared/hooks'; + +import { + newRunnerProfile, + useRunnerInstances, + useRunnerProfiles, + useSaveRunnerProfile, + useStartRunnerInstance, + useStopRunnerInstance, +} from '@features/runners'; + +import { useSaveScript } from '../../api/useSaveScript'; +import { useTestSuiteConfigs } from '../../api/useTestSuiteConfigs'; +import { DEFAULT_VIEWPORT_HEIGHT, DEFAULT_VIEWPORT_WIDTH } from '../../lib/constants'; +import { useTestSuiteStore } from '../../test-suite-store'; + +export function useRecordingPanel() { + const { projectId } = useLooseParams(); + const { data: configs = [] } = useTestSuiteConfigs(projectId ?? ''); + const [selectedConfigId, setSelectedConfigId] = useState<string>(); + const [scriptName, setScriptName] = useState(''); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const recording = useTestSuiteStore((s) => s.recordingActive); + const setRecordingActive = useTestSuiteStore((s) => s.setRecordingActive); + const recordedSteps = useTestSuiteStore((s) => s.recordedSteps); + const clearSteps = useTestSuiteStore((s) => s.clearSteps); + const saveScript = useSaveScript(projectId ?? ''); + + // Runner state — shared between DevServerButton and BrowserViewPanel + const scope: ScopeRef = useMemo( + () => ({ kind: 'project' as const, projectId: projectId ?? '' }), + [projectId], + ); + const { data: instances = [] } = useRunnerInstances(scope); + const activeInstance = instances.find( + (i) => i.status === 'running' || i.status === 'ready' || i.status === 'starting', + ); + const serverRunning = Boolean(activeInstance); + + const activeConfig = useMemo((): TestSuiteConfig | null => { + if (configs.length === 0) return null; + return configs.find((c) => c.id === selectedConfigId) + ?? configs.find((c) => c.isActive) + ?? configs[0]; + }, [configs, selectedConfigId]); + + const vw = activeConfig ? activeConfig.viewportWidth : DEFAULT_VIEWPORT_WIDTH; + const vh = activeConfig ? activeConfig.viewportHeight : DEFAULT_VIEWPORT_HEIGHT; + + // URL state — initialized from active config, editable via address bar + const [urlOverride, setUrlOverride] = useState<string>(); + const url = urlOverride ?? activeConfig?.targetUrl ?? ''; + + const canRecord = serverRunning; + const canSave = !recording && recordedSteps.length > 0; + const recordTooltip = serverRunning ? '' : 'Start the dev server first'; + + const onStartRecording = () => { + clearSteps(); + setRecordingActive(true); + }; + + const onStopRecording = () => { + setRecordingActive(false); + if (recordedSteps.length > 0) { + setSaveDialogOpen(true); + } + }; + + const onConfigChange = (id: string) => { + setSelectedConfigId(id); + setUrlOverride(undefined); + }; + + return { + projectId, + configs, + activeConfig, + selectedConfigId, + scriptName, + setScriptName, + saveDialogOpen, + setSaveDialogOpen, + recording, + recordedSteps, + saveScriptPending: saveScript.isPending, + serverRunning, + activeInstance, + vw, + vh, + url, + setUrlOverride, + canRecord, + canSave, + recordTooltip, + onStartRecording, + onStopRecording, + onConfigChange, + }; +} + +interface UseDevServerButtonProps { + projectId: string; + serverRunning: boolean; + activeInstanceId: string | undefined; +} + +export function useDevServerButton({ projectId, serverRunning, activeInstanceId }: UseDevServerButtonProps) { + const scope: ScopeRef = useMemo(() => ({ kind: 'project' as const, projectId }), [projectId]); + const { data: profiles = [] } = useRunnerProfiles(projectId); + const startRunner = useStartRunnerInstance(scope); + const stopRunner = useStopRunnerInstance(scope); + const saveProfile = useSaveRunnerProfile(projectId); + + const handleStart = () => { + if (profiles.length > 0) { + startRunner.mutate(profiles[0].id); + } else { + const profile = newRunnerProfile(projectId); + saveProfile.mutate(profile, { + onSuccess: () => startRunner.mutate(profile.id), + }); + } + }; + + const handleStop = () => { + if (activeInstanceId) { + stopRunner.mutate(activeInstanceId); + } + }; + + return { + serverRunning, + activeInstanceId, + startPending: startRunner.isPending || saveProfile.isPending, + handleStart, + handleStop, + }; +} diff --git a/src/renderer/features/test-suite/components/ResultsOutputLog/ResultsOutputLog.tsx b/src/renderer/features/test-suite/components/ResultsOutputLog/ResultsOutputLog.tsx new file mode 100644 index 00000000..017a9fb1 --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsOutputLog/ResultsOutputLog.tsx @@ -0,0 +1,211 @@ +import type { RefObject } from 'react'; + +import { + Code, + Flex, + Grid, + InlineAlert, + MetricCard, + ScrollArea, + SectionHeader, + Stack, + Text, +} from '@ui'; + +import { getOutputLineClass } from '../../lib/format'; +import { ScreenshotGallery } from '../ScreenshotGallery'; + +import { useResultsOutputLog } from './useResultsOutputLog'; + +import type { RunRecord, StreamOutputLine } from '../../lib/types'; + +interface ScreenshotItem { + id: string; + stepIndex: number; + stepLabel: string; + trigger: string; + filePath: string; + capturedAt: string; +} + +interface ResultsOutputLogProps { + displayLines: StreamOutputLine[]; + outputRef: RefObject<HTMLDivElement | null>; + activeRunId: string | null; + view: string; + errorMessage?: string; + runRecord?: RunRecord | null; + screenshots?: ScreenshotItem[]; + scriptName?: string; +} + +export function ResultsOutputLog({ + displayLines, + outputRef, + activeRunId, + view, + errorMessage, + runRecord, + screenshots = [], + scriptName, +}: ResultsOutputLogProps) { + const vm = useResultsOutputLog({ displayLines, runRecord }); + + return ( + <ScrollArea ref={outputRef} className="flex-1 bg-bg-surface"> + {errorMessage ? ( + <InlineAlert className="mx-3 mt-3" variant="error"> + {errorMessage} + </InlineAlert> + ) : null} + + {vm.hasContent ? ( + <div className="flex flex-1 flex-col overflow-hidden"> + {view === 'summary' && ( + <div className="flex-1 overflow-y-auto p-3"> + <SummaryView + displayLines={displayLines} + runRecord={runRecord} + screenshots={screenshots} + scriptName={scriptName} + /> + </div> + )} + + {view === 'screenshots' && ( + <div className="flex-1 overflow-y-auto p-3"> + <ScreenshotGallery screenshots={screenshots} /> + </div> + )} + + {view === 'json' && ( + <div className="flex-1 overflow-y-auto p-3"> + <Code className="text-xs whitespace-pre-wrap"> + {vm.jsonText} + </Code> + </div> + )} + + {view === 'raw' && ( + <div className="flex-1 overflow-y-auto p-3"> + <Code className="text-xs whitespace-pre-wrap"> + {displayLines.map((l, i) => ( + <Text + key={l.timestamp === `stored-${i}` ? `stored-${i}` : l.timestamp} + className={getOutputLineClass(l.line)} + > + {l.line} + {'\n'} + </Text> + ))} + </Code> + </div> + )} + </div> + ) : ( + <Flex align="center" className="h-full p-3" justify="center"> + <Text size="sm" variant="muted"> + {activeRunId ? 'No output captured for this run.' : 'Run a test to see output here.'} + </Text> + </Flex> + )} + </ScrollArea> + ); +} + +// ─── Summary View ──────────────────────────────────────────── + +function SummaryView({ + runRecord, + scriptName, + displayLines, + screenshots = [], +}: { + runRecord?: RunRecord | null; + scriptName?: string; + displayLines: StreamOutputLine[]; + screenshots?: ScreenshotItem[]; +}) { + if (!runRecord) { + return <Text variant="muted">Waiting for results...</Text>; + } + + const totalSteps = (runRecord.stepsPassed ?? 0) + (runRecord.stepsFailed ?? 0); + const passRate = totalSteps > 0 ? Math.round(((runRecord.stepsPassed ?? 0) / totalSteps) * 100) : 0; + const durationSec = ((runRecord.durationMs ?? 0) / 1000).toFixed(1); + const isPassed = runRecord.status === 'passed'; + + const errorLines = displayLines + .map((l) => l.line) + .filter((l) => l.includes('Error') || l.includes('\u2717') || l.includes('FAIL')); + + return ( + <Stack gap="md"> + {/* Status header */} + <Flex gap="md" wrap="nowrap"> + <Flex + align="center" + justify="center" + className={`h-10 w-10 rounded-full text-lg ${ + isPassed + ? 'bg-green-500/15 text-green-500' + : 'bg-destructive/15 text-destructive' + }`} + > + {isPassed ? '\u2713' : '\u2717'} + </Flex> + <Stack gap="none"> + <Text className="font-semibold" size="lg"> + {scriptName ?? 'Test Run'} — {runRecord.status.toUpperCase()} + </Text> + <Text size="sm" variant="muted"> + {runRecord.startedAt ? new Date(runRecord.startedAt).toLocaleString() : 'N/A'} \u00b7 {durationSec}s + </Text> + </Stack> + </Flex> + + {/* Metrics grid */} + <Grid cols={4} gap="sm"> + <MetricCard label="Pass Rate" value={`${passRate}%`} variant="compact" /> + <MetricCard label="Passed" subtitle="steps" value={String(runRecord.stepsPassed ?? 0)} variant="compact" /> + <MetricCard label="Failed" subtitle="steps" value={String(runRecord.stepsFailed ?? 0)} variant="compact" /> + <MetricCard label="Duration" value={`${durationSec}s`} variant="compact" /> + </Grid> + + {/* Error details */} + {runRecord.error ? ( + <InlineAlert variant="error"> + <Stack gap="sm"> + <Text className="font-semibold" size="sm">Error</Text> + <Code className="text-xs whitespace-pre-wrap"> + {runRecord.error} + </Code> + </Stack> + </InlineAlert> + ) : null} + + {/* Failure output */} + {errorLines.length > 0 && !runRecord.error ? ( + <InlineAlert variant="error"> + <Stack gap="sm"> + <Text className="font-semibold" size="sm"> + Failures ({errorLines.length}) + </Text> + <Code className="text-xs whitespace-pre-wrap"> + {errorLines.slice(0, 20).join('\n')} + {errorLines.length > 20 ? `\n... and ${errorLines.length - 20} more` : ''} + </Code> + </Stack> + </InlineAlert> + ) : null} + + {/* Screenshot preview strip */} + {screenshots.length > 0 ? ( + <Stack gap="sm"> + <SectionHeader title={`Screenshots (${screenshots.length})`} /> + <ScreenshotGallery screenshots={screenshots} /> + </Stack> + ) : null} + </Stack> + ); +} diff --git a/src/renderer/features/test-suite/components/ResultsOutputLog/index.ts b/src/renderer/features/test-suite/components/ResultsOutputLog/index.ts new file mode 100644 index 00000000..2a9eeee5 --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsOutputLog/index.ts @@ -0,0 +1 @@ +export { ResultsOutputLog } from './ResultsOutputLog'; diff --git a/src/renderer/features/test-suite/components/ResultsOutputLog/useResultsOutputLog.ts b/src/renderer/features/test-suite/components/ResultsOutputLog/useResultsOutputLog.ts new file mode 100644 index 00000000..7975b117 --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsOutputLog/useResultsOutputLog.ts @@ -0,0 +1,24 @@ +import { useMemo } from 'react'; + +import { buildJsonOutput } from '../../lib/format'; + +import type { RunRecord, StreamOutputLine } from '../../lib/types'; + +interface UseResultsOutputLogProps { + displayLines: StreamOutputLine[]; + runRecord?: RunRecord | null; +} + +export function useResultsOutputLog({ displayLines, runRecord }: UseResultsOutputLogProps) { + const jsonText = useMemo( + () => (runRecord ? buildJsonOutput(runRecord, displayLines) : '{}'), + [runRecord, displayLines], + ); + + const hasContent = displayLines.length > 0 || runRecord !== undefined; + + return { + jsonText, + hasContent, + }; +} diff --git a/src/renderer/features/test-suite/components/ResultsPanel/ResultsPanel.tsx b/src/renderer/features/test-suite/components/ResultsPanel/ResultsPanel.tsx new file mode 100644 index 00000000..e9f13fba --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsPanel/ResultsPanel.tsx @@ -0,0 +1,85 @@ +import { CheckCircle2, Clock, XCircle } from 'lucide-react'; + +import { useLooseParams } from '@renderer/shared/hooks'; + +import { Flex, PageContent, Stack, Text } from '@ui'; + +import { ResultsOutputLog } from '../ResultsOutputLog'; +import { ResultsToolbar } from '../ResultsToolbar'; +import { StepTimeline } from '../StepTimeline'; + +import { formatDuration, useResultsPanel } from './useResultsPanel'; + +export function ResultsPanel() { + const { projectId } = useLooseParams(); + if (!projectId) return null; + + return <ResultsPanelInner projectId={projectId} />; +} + +function ResultsPanelInner({ projectId }: { projectId: string }) { + const vm = useResultsPanel(projectId); + + return ( + <PageContent className="flex h-full flex-col overflow-hidden p-1"> + <Stack className="flex h-full flex-col overflow-hidden rounded-md border border-border" gap="none"> + <ResultsToolbar + activeEnv={vm.activeEnv} + activeRunId={vm.activeRunId} + config={vm.config} + copyText={vm.copyText} + isRunning={vm.isRunning} + outputView={vm.outputView} + runRecord={vm.runRecord} + runs={vm.runs} + screenshotCount={vm.screenshots.length} + scriptId={vm.scriptId} + scripts={vm.scripts} + onEnvChange={vm.setActiveEnv} + onRun={vm.handleRun} + onRunChange={vm.setSelectedRunId} + onScriptChange={vm.setSelectedScriptId} + onViewChange={vm.setOutputView} + /> + + <Flex align="stretch" className="flex-1 min-h-0" gap="none" wrap="nowrap"> + <Stack className="w-80 shrink-0 border-r border-border overflow-y-auto" gap="none"> + <Flex align="center" className="h-10 shrink-0 border-b border-border px-3" gap="sm" justify="between" wrap="nowrap"> + <Text className="text-xs font-semibold uppercase text-text-muted"> + Steps ({vm.displaySteps.length}) + </Text> + {vm.runRecord && vm.runRecord.status !== 'running' ? ( + <Flex align="center" gap="md" wrap="nowrap"> + <span className="flex items-center gap-1 text-xs leading-none text-muted-foreground"> + <CheckCircle2 className="h-3 w-3 shrink-0 text-green-500" /> + {vm.runRecord.stepsPassed} + </span> + <span className="flex items-center gap-1 text-xs leading-none text-muted-foreground"> + <XCircle className="h-3 w-3 shrink-0 text-destructive" /> + {vm.runRecord.stepsFailed} + </span> + <span className="flex items-center gap-1 text-xs leading-none text-muted-foreground"> + <Clock className="h-3 w-3 shrink-0" /> + {formatDuration(vm.runRecord.durationMs)} + </span> + </Flex> + ) : null} + </Flex> + <StepTimeline runStatus={vm.runStatus} steps={vm.displaySteps} /> + </Stack> + + <ResultsOutputLog + activeRunId={vm.activeRunId} + displayLines={vm.displayLines} + errorMessage={vm.runRecord?.error} + outputRef={vm.outputRef} + runRecord={vm.runRecord} + screenshots={vm.screenshots} + scriptName={vm.activeScript?.name} + view={vm.outputView} + /> + </Flex> + </Stack> + </PageContent> + ); +} diff --git a/src/renderer/features/test-suite/components/ResultsPanel/index.ts b/src/renderer/features/test-suite/components/ResultsPanel/index.ts new file mode 100644 index 00000000..6bb6f98d --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsPanel/index.ts @@ -0,0 +1 @@ +export { ResultsPanel } from './ResultsPanel'; diff --git a/src/renderer/features/test-suite/components/ResultsPanel/useResultsPanel.ts b/src/renderer/features/test-suite/components/ResultsPanel/useResultsPanel.ts new file mode 100644 index 00000000..272d6778 --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsPanel/useResultsPanel.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react'; + +import { useRun, useRuns, useRunScript } from '../../api/useRuns'; +import { useTestSuiteConfig } from '../../api/useTestSuiteConfig'; +import { useTestSuiteScreenshots } from '../../api/useTestSuiteScreenshots'; +import { useTestSuiteScripts } from '../../api/useTestSuiteScripts'; +import { useDisplayLines } from '../../hooks/useDisplayLines'; +import { useDisplaySteps } from '../../hooks/useDisplaySteps'; +import { useRunCompletionToast } from '../../hooks/useRunCompletionToast'; +import { formatDuration, getCopyText } from '../../lib/format'; +import { useTestSuiteStore } from '../../test-suite-store'; + +export { formatDuration }; + +export function useResultsPanel(projectId: string) { + const { data: scripts = [] } = useTestSuiteScripts(projectId); + const selectedScriptId = useTestSuiteStore((s) => s.selectedScriptId); + const setSelectedScriptId = useTestSuiteStore((s) => s.setSelectedScriptId); + const selectedRunId = useTestSuiteStore((s) => s.selectedRunId); + const setSelectedRunId = useTestSuiteStore((s) => s.setSelectedRunId); + + const firstScript = scripts.length > 0 ? scripts[0] : undefined; + const scriptId = selectedScriptId ?? firstScript?.id ?? null; + const activeScript = scripts.find((s) => s.id === scriptId); + + const { data: config } = useTestSuiteConfig(projectId); + const { data: runs = [] } = useRuns(scriptId); + const [activeEnv, setActiveEnv] = useState('default'); + const [outputView, setOutputView] = useState('summary'); + + const firstRun = runs.length > 0 ? runs[0] : undefined; + const activeRunId = selectedRunId ?? firstRun?.id ?? null; + const { data: runRecord } = useRun(activeRunId); + + const displayLines = useDisplayLines(activeRunId); + const displaySteps = useDisplaySteps(activeRunId, activeScript?.steps); + const { data: screenshots } = useTestSuiteScreenshots(activeRunId); + + const runStatus = runRecord?.status ?? (activeRunId ? 'running' : 'pending'); + const runScript = useRunScript(); + + useRunCompletionToast( + runRecord?.status, + activeScript?.name, + runRecord?.stepsFailed, + runRecord?.durationMs, + ); + + const outputRef = useRef<HTMLDivElement>(null); + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [displayLines.length]); + + const handleRun = () => { + if (!scriptId) return; + const baseUrlOverride = + activeEnv !== 'default' && config?.environments + ? config.environments.find((e) => e.name === activeEnv)?.url + : undefined; + runScript.mutate({ scriptId, baseUrlOverride }, { + onSuccess: (data) => setSelectedRunId(data.runId), + }); + }; + + const copyText = getCopyText(outputView, runRecord, displayLines, activeScript?.name); + + return { + scripts, + scriptId, + activeScript, + config, + runs, + activeEnv, + setActiveEnv, + outputView, + setOutputView, + activeRunId, + runRecord, + displayLines, + displaySteps, + screenshots: screenshots ?? [], + runStatus, + isRunning: runScript.isPending, + outputRef, + handleRun, + copyText, + setSelectedScriptId, + setSelectedRunId, + }; +} diff --git a/src/renderer/features/test-suite/components/ResultsToolbar/ResultsToolbar.tsx b/src/renderer/features/test-suite/components/ResultsToolbar/ResultsToolbar.tsx new file mode 100644 index 00000000..897183e5 --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsToolbar/ResultsToolbar.tsx @@ -0,0 +1,231 @@ +import { + AlertTriangle, + Check, + ClipboardCopy, + ExternalLink, + Play, +} from 'lucide-react'; + +import type { TestSuiteConfig } from '@shared/ipc/test-suite/schemas'; + +import { + Badge, + Button, + Flex, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, + Text, +} from '@ui'; + +import { RunStatusDot } from '../RunStatusBadge'; + +import { useResultsToolbar } from './useResultsToolbar'; + +import type { RunRecord } from '../../lib/types'; + +interface Script { + id: string; + name: string; +} + +interface Run { + id: string; + status: string; + startedAt: string; +} + +const OUTPUT_VIEW = { + SUMMARY: 'summary', + SCREENSHOTS: 'screenshots', + JSON: 'json', + RAW: 'raw', +} as const; + +const VIEW_LABELS: Record<string, string> = { + [OUTPUT_VIEW.SUMMARY]: 'Summary', + [OUTPUT_VIEW.SCREENSHOTS]: 'Screenshots', + [OUTPUT_VIEW.JSON]: 'JSON', + [OUTPUT_VIEW.RAW]: 'Raw Log', +}; + +interface ResultsToolbarProps { + scripts: Script[]; + runs: Run[]; + scriptId: string | null; + activeRunId: string | null; + config: TestSuiteConfig | null | undefined; + activeEnv: string; + isRunning: boolean; + runRecord: RunRecord | null | undefined; + screenshotCount: number; + outputView: string; + onScriptChange: (id: string) => void; + onRunChange: (id: string) => void; + onEnvChange: (env: string) => void; + onRun: () => void; + onViewChange: (view: string) => void; + copyText: string; +} + +function FieldLabel({ children }: { children: React.ReactNode }) { + return ( + <Text className="text-[10px] uppercase tracking-wider" variant="muted"> + {children} + </Text> + ); +} + +export function ResultsToolbar({ + scripts, + runs, + scriptId, + activeRunId, + config, + activeEnv, + isRunning, + runRecord, + screenshotCount, + outputView, + onScriptChange, + onRunChange, + onEnvChange, + onRun, + onViewChange, + copyText, +}: ResultsToolbarProps) { + const vm = useResultsToolbar({ copyText, runRecord }); + + return ( + <Flex align="end" className="border-b border-border px-3 py-2" gap="sm" wrap="nowrap"> + {/* Script selector */} + <Stack gap="none"> + <FieldLabel>Script</FieldLabel> + <Select value={scriptId ?? ''} onValueChange={onScriptChange}> + <SelectTrigger className="w-48"> + <SelectValue placeholder="Select test..." /> + </SelectTrigger> + <SelectContent> + {scripts.map((s) => ( + <SelectItem key={s.id} value={s.id}> + {s.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </Stack> + + {/* Run selector */} + {runs.length > 0 ? ( + <Stack gap="none"> + <FieldLabel>Run</FieldLabel> + <Select value={activeRunId ?? ''} onValueChange={onRunChange}> + <SelectTrigger className="w-44"> + <SelectValue placeholder="Select run..." /> + </SelectTrigger> + <SelectContent> + {runs.map((r) => ( + <SelectItem key={r.id} value={r.id}> + <Text className="flex items-center gap-1.5"> + <RunStatusDot status={r.status} /> + {new Date(r.startedAt).toLocaleTimeString()} + </Text> + </SelectItem> + ))} + </SelectContent> + </Select> + </Stack> + ) : null} + + {/* Environment selector */} + {config?.environments.length !== undefined && config.environments.length > 0 ? ( + <Stack gap="none"> + <FieldLabel>Environment</FieldLabel> + <Select value={activeEnv} onValueChange={onEnvChange}> + <SelectTrigger className="w-36"> + <SelectValue placeholder="Environment" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="default">Default ({config.targetUrl})</SelectItem> + {config.environments.map((env) => ( + <SelectItem key={env.name} value={env.name}>{env.name}</SelectItem> + ))} + </SelectContent> + </Select> + </Stack> + ) : null} + + {/* Output view selector */} + {activeRunId ? ( + <Stack gap="none"> + <FieldLabel>View</FieldLabel> + <Select value={outputView} onValueChange={onViewChange}> + <SelectTrigger className="w-40"> + <SelectValue> + {outputView === OUTPUT_VIEW.SCREENSHOTS && screenshotCount > 0 + ? `Screenshots (${screenshotCount})` + : VIEW_LABELS[outputView] ?? 'Summary'} + </SelectValue> + </SelectTrigger> + <SelectContent> + <SelectItem value={OUTPUT_VIEW.SUMMARY}>Summary</SelectItem> + <SelectItem value={OUTPUT_VIEW.SCREENSHOTS}> + Screenshots{screenshotCount > 0 ? ` (${screenshotCount})` : ''} + </SelectItem> + <SelectItem value={OUTPUT_VIEW.JSON}>JSON</SelectItem> + <SelectItem value={OUTPUT_VIEW.RAW}>Raw Log</SelectItem> + </SelectContent> + </Select> + </Stack> + ) : null} + + {/* Run button */} + <Button + disabled={!scriptId || isRunning} + size="sm" + variant="control" + onClick={onRun} + > + <Play className="h-3 w-3" /> Run + </Button> + + {runRecord?.error ? ( + <Badge variant="destructive"> + <AlertTriangle className="mr-1 h-3 w-3" /> Error + </Badge> + ) : null} + + {/* Spacer */} + <div className="flex-1" /> + + {/* Copy button */} + {activeRunId ? ( + <Button size="sm" variant="control" onClick={vm.handleCopy}> + {vm.copied ? ( + <> + <Check className="h-3 w-3 text-green-500" /> Copied + </> + ) : ( + <> + <ClipboardCopy className="h-3 w-3" /> Copy + </> + )} + </Button> + ) : null} + + {/* View Report — far right */} + {runRecord?.reportPath ? ( + <Button + size="sm" + variant="control" + onClick={vm.handleOpenReport} + > + <ExternalLink className="h-3 w-3" /> View Report + </Button> + ) : null} + </Flex> + ); +} diff --git a/src/renderer/features/test-suite/components/ResultsToolbar/index.ts b/src/renderer/features/test-suite/components/ResultsToolbar/index.ts new file mode 100644 index 00000000..8c15d7a5 --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsToolbar/index.ts @@ -0,0 +1 @@ +export { ResultsToolbar } from './ResultsToolbar'; diff --git a/src/renderer/features/test-suite/components/ResultsToolbar/useResultsToolbar.ts b/src/renderer/features/test-suite/components/ResultsToolbar/useResultsToolbar.ts new file mode 100644 index 00000000..cbe72a7a --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsToolbar/useResultsToolbar.ts @@ -0,0 +1,25 @@ +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { useClipboardCopy } from '@renderer/shared/hooks/useClipboardCopy'; +import { ipc } from '@renderer/shared/lib/ipc'; + +interface UseResultsToolbarProps { + copyText: string; + runRecord?: { reportPath?: string } | null; +} + +export function useResultsToolbar({ copyText, runRecord }: UseResultsToolbarProps) { + const { copied, handleCopy: copy } = useClipboardCopy(); + + const handleCopy = () => copy(copyText); + + const handleOpenReport = () => { + void ipc(TEST_SUITE.OPEN.REPORT, { reportPath: runRecord?.reportPath ?? '' }); + }; + + return { + copied, + handleCopy, + handleOpenReport, + }; +} diff --git a/src/renderer/features/test-suite/components/ResultsWorkflowActions/ResultsWorkflowActions.tsx b/src/renderer/features/test-suite/components/ResultsWorkflowActions/ResultsWorkflowActions.tsx new file mode 100644 index 00000000..e7da2ccc --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsWorkflowActions/ResultsWorkflowActions.tsx @@ -0,0 +1,77 @@ +import { ListPlus, Zap } from 'lucide-react'; + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Flex, +} from '@ui'; + +import { useResultsWorkflowActions } from './useResultsWorkflowActions'; + +import type { RunRecord } from '../../lib/types'; + +interface ActiveScript { + id: string; + name: string; +} + +interface ResultsWorkflowActionsProps { + activeScript: ActiveScript | undefined; + runRecord: RunRecord | null | undefined; + projectId: string; + activeRunId: string | null; +} + +export function ResultsWorkflowActions({ + activeScript, + runRecord, + projectId, + activeRunId, +}: ResultsWorkflowActionsProps) { + const vm = useResultsWorkflowActions({ activeScript, runRecord, projectId, activeRunId }); + + if (!vm.isFailed) return null; + + return ( + <Flex align="center" className="ml-auto" gap="sm" wrap="nowrap"> + {vm.createdTaskSlug ? ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button size="sm" variant="outline"> + <Zap className="mr-1 h-3 w-3" /> Start Workflow + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent> + <DropdownMenuItem onClick={vm.handleRunWorkflow}> + Full Pipeline (Research → Plan → Team) + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={vm.handleStartResearch}> + Research Only + </DropdownMenuItem> + <DropdownMenuItem onClick={vm.handleCreatePlan}> + Plan Only + </DropdownMenuItem> + <DropdownMenuItem onClick={vm.handleSpinUpTeam}> + Team Only + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) : ( + <Button + disabled={vm.creating} + size="sm" + variant="outline" + onClick={vm.handleCreateTask} + > + <ListPlus className="mr-1 h-3 w-3" /> + {vm.creating ? 'Creating...' : 'Create Task'} + </Button> + )} + </Flex> + ); +} diff --git a/src/renderer/features/test-suite/components/ResultsWorkflowActions/index.ts b/src/renderer/features/test-suite/components/ResultsWorkflowActions/index.ts new file mode 100644 index 00000000..07c2220d --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsWorkflowActions/index.ts @@ -0,0 +1 @@ +export { ResultsWorkflowActions } from './ResultsWorkflowActions'; diff --git a/src/renderer/features/test-suite/components/ResultsWorkflowActions/useResultsWorkflowActions.ts b/src/renderer/features/test-suite/components/ResultsWorkflowActions/useResultsWorkflowActions.ts new file mode 100644 index 00000000..3a8d7df7 --- /dev/null +++ b/src/renderer/features/test-suite/components/ResultsWorkflowActions/useResultsWorkflowActions.ts @@ -0,0 +1,93 @@ +import { useState } from 'react'; + +import { + useCreateProgressTask, + useCreatePlan, + useRunWorkflow, + useSpinUpTeam, + useStartResearch, +} from '@renderer/features/tasks/api/useProgressMutations'; + +import { useAttachRunToTask } from '../../api/useAttachRunToTask'; + +import type { RunRecord } from '../../lib/types'; + +interface ActiveScript { + id: string; + name: string; +} + +interface UseResultsWorkflowActionsProps { + activeScript: ActiveScript | undefined; + runRecord: RunRecord | null | undefined; + projectId: string; + activeRunId: string | null; +} + +export function useResultsWorkflowActions({ + activeScript, + runRecord, + projectId, + activeRunId, +}: UseResultsWorkflowActionsProps) { + const [createdTaskSlug, setCreatedTaskSlug] = useState<string | null>(null); + const createTask = useCreateProgressTask(); + const attachRunToTask = useAttachRunToTask(); + const runWorkflow = useRunWorkflow(); + const startResearch = useStartResearch(); + const createPlan = useCreatePlan(); + const spinUpTeam = useSpinUpTeam(); + + const isFailed = runRecord?.status === 'failed'; + + const handleCreateTask = () => { + if (!activeScript || !runRecord) return; + const title = `Fix: ${activeScript.name} test failure`; + const errorLines = (runRecord.outputLines ?? []) + .filter((l) => l.includes('Error') || l.includes('\u2717')) + .join('\n'); + const errorSummary = + runRecord.error + ?? (errorLines.length > 0 ? errorLines : 'Test failed — see run output for details'); + const description = `## Test Failure\n\n**Script:** ${activeScript.name}\n**Status:** ${runRecord.status}\n**Steps passed:** ${runRecord.stepsPassed ?? 0}\n**Steps failed:** ${runRecord.stepsFailed ?? 0}\n\n### Error Output\n\n\`\`\`\n${errorSummary}\n\`\`\``; + + createTask.mutate( + { title, description, priority: 'high', projectId }, + { + onSuccess: (task) => { + setCreatedTaskSlug(task.slug); + if (activeRunId) { + attachRunToTask.mutate({ runId: activeRunId, taskId: task.slug }); + } + }, + }, + ); + }; + + const handleRunWorkflow = () => { + if (createdTaskSlug) runWorkflow.mutate({ slug: createdTaskSlug }); + }; + + const handleStartResearch = () => { + if (createdTaskSlug) startResearch.mutate({ slug: createdTaskSlug }); + }; + + const handleCreatePlan = () => { + if (createdTaskSlug) createPlan.mutate({ slug: createdTaskSlug }); + }; + + const handleSpinUpTeam = () => { + if (createdTaskSlug) spinUpTeam.mutate({ slug: createdTaskSlug }); + }; + + return { + isFailed, + createdTaskSlug, + creating: createTask.isPending, + handleCreateTask, + handleRunWorkflow, + handleStartResearch, + handleCreatePlan, + handleSpinUpTeam, + }; +} diff --git a/src/renderer/features/test-suite/components/RunLogDialog/RunLogDialog.tsx b/src/renderer/features/test-suite/components/RunLogDialog/RunLogDialog.tsx new file mode 100644 index 00000000..7e279a78 --- /dev/null +++ b/src/renderer/features/test-suite/components/RunLogDialog/RunLogDialog.tsx @@ -0,0 +1,111 @@ +/** + * RunLogDialog — Full-screen dialog for inspecting test run output in detail. + * + * Shows run summary stats, error banner, and scrollable log output + * with syntax-highlighted lines (pass/fail coloring). + */ + +import { CheckCircle2, Clock, Copy, Maximize2, XCircle } from 'lucide-react'; + +import { + Badge, + Button, + Code, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + Flex, + ScrollArea, + Text, +} from '@ui'; + +import { formatDuration, getOutputLineClass } from '../../lib/format'; + +import { useRunLogDialog } from './useRunLogDialog'; + +import type { RunRecord } from '../../lib/types'; + +interface RunLogDialogProps { + lines: Array<{ line: string; timestamp: string }>; + runRecord: RunRecord | null; + scriptName?: string; +} + +export function RunLogDialog({ lines, runRecord, scriptName }: RunLogDialogProps) { + const vm = useRunLogDialog({ lines }); + + return ( + <Dialog> + <DialogTrigger asChild> + <Button size="sm" title="Full output" variant="ghost"> + <Maximize2 className="h-3 w-3" /> + </Button> + </DialogTrigger> + <DialogContent className="flex h-[80vh] max-w-4xl flex-col"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + {scriptName ?? 'Test'} — Run Output + {runRecord?.status === 'passed' && ( + <Badge variant="success">Passed</Badge> + )} + {runRecord?.status === 'failed' && ( + <Badge variant="destructive">Failed</Badge> + )} + </DialogTitle> + </DialogHeader> + + {/* Summary stats */} + {runRecord ? ( + <Flex align="center" className="border-b pb-2" gap="lg" wrap="nowrap"> + <Text className="flex items-center gap-1" size="sm" variant="muted"> + <CheckCircle2 className="h-3.5 w-3.5 text-green-500" /> + {runRecord.stepsPassed ?? 0} passed + </Text> + <Text className="flex items-center gap-1" size="sm" variant="muted"> + <XCircle className="h-3.5 w-3.5 text-destructive" /> + {runRecord.stepsFailed ?? 0} failed + </Text> + <Text className="flex items-center gap-1" size="sm" variant="muted"> + <Clock className="h-3.5 w-3.5" /> + {formatDuration(runRecord.durationMs ?? 0)} + </Text> + <Button + className="ml-auto" + size="sm" + variant="ghost" + onClick={vm.handleCopy} + > + {vm.copied ? ( + 'Copied!' + ) : ( + <> + <Copy className="mr-1 h-3 w-3" /> Copy + </> + )} + </Button> + </Flex> + ) : null} + + {/* Error banner */} + {runRecord?.error ? ( + <Text className="rounded-md border border-destructive/30 bg-destructive/10 p-3" size="sm" variant="error"> + {runRecord.error} + </Text> + ) : null} + + {/* Log output */} + <ScrollArea className="min-h-0 flex-1"> + <Code className="block whitespace-pre-wrap p-4"> + {lines.map((l) => ( + <span key={l.timestamp} className={`block ${getOutputLineClass(l.line)}`}> + {l.line} + </span> + ))} + </Code> + </ScrollArea> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/test-suite/components/RunLogDialog/index.ts b/src/renderer/features/test-suite/components/RunLogDialog/index.ts new file mode 100644 index 00000000..34833dab --- /dev/null +++ b/src/renderer/features/test-suite/components/RunLogDialog/index.ts @@ -0,0 +1 @@ +export { RunLogDialog } from './RunLogDialog'; diff --git a/src/renderer/features/test-suite/components/RunLogDialog/useRunLogDialog.ts b/src/renderer/features/test-suite/components/RunLogDialog/useRunLogDialog.ts new file mode 100644 index 00000000..da42639d --- /dev/null +++ b/src/renderer/features/test-suite/components/RunLogDialog/useRunLogDialog.ts @@ -0,0 +1,16 @@ +import { useClipboardCopy } from '@renderer/shared/hooks/useClipboardCopy'; + +interface UseRunLogDialogProps { + lines: Array<{ line: string; timestamp: string }>; +} + +export function useRunLogDialog({ lines }: UseRunLogDialogProps) { + const { copied, handleCopy: copy } = useClipboardCopy(); + + const handleCopy = () => copy(lines.map((l) => l.line).join('\n')); + + return { + copied, + handleCopy, + }; +} diff --git a/src/renderer/features/test-suite/components/RunSparkline.tsx b/src/renderer/features/test-suite/components/RunSparkline.tsx new file mode 100644 index 00000000..07c2e776 --- /dev/null +++ b/src/renderer/features/test-suite/components/RunSparkline.tsx @@ -0,0 +1,50 @@ +import { Flex, Text, Tooltip, TooltipContent, TooltipTrigger } from '@ui'; + +interface RunSparklineProps { + results: Array<{ status: string; startedAt: string; durationMs: number }>; +} + +function isFlaky(results: Array<{ status: string }>, index: number): boolean { + if (results.length < 3 || index === 0 || index === results.length - 1) return false; + const prev = results[index - 1].status; + const curr = results[index].status; + const next = results[index + 1].status; + return curr !== prev && curr !== next; +} + +function getBarColor(status: string, flaky: boolean): string { + if (flaky) return 'bg-yellow-500'; + if (status === 'passed') return 'bg-green-500'; + if (status === 'failed') return 'bg-destructive'; + return 'bg-muted'; +} + +export function RunSparkline({ results }: RunSparklineProps) { + if (results.length === 0) return <Text size="sm" variant="muted">—</Text>; + + const reversed = [...results].reverse(); + + return ( + <Flex align="center" gap="none"> + {reversed.map((r, i) => { + const flaky = isFlaky(reversed, i); + const color = getBarColor(r.status, flaky); + const label = flaky + ? `Flaky — ${new Date(r.startedAt).toLocaleString()} (${r.durationMs}ms)` + : `${r.status} — ${new Date(r.startedAt).toLocaleString()} (${r.durationMs}ms)`; + return ( + <Tooltip key={`${r.startedAt}-${r.durationMs}-${r.status}`}> + <TooltipTrigger asChild> + <div + className={`inline-block h-2.5 w-2.5 rounded-full ${color}`} + /> + </TooltipTrigger> + <TooltipContent side="top"> + <Text size="sm">{label}</Text> + </TooltipContent> + </Tooltip> + ); + })} + </Flex> + ); +} diff --git a/src/renderer/features/test-suite/components/RunStatusBadge.tsx b/src/renderer/features/test-suite/components/RunStatusBadge.tsx new file mode 100644 index 00000000..a636eaff --- /dev/null +++ b/src/renderer/features/test-suite/components/RunStatusBadge.tsx @@ -0,0 +1,26 @@ +import { Badge, StatusIndicator } from '@ui'; + +const STATUS_VARIANT: Record<string, 'success' | 'error' | 'info' | 'warning' | 'neutral'> = { + passed: 'success', + failed: 'error', + running: 'info', + cancelled: 'neutral', +}; + +export function RunStatusDot({ status }: { status: string }) { + const variant = STATUS_VARIANT[status] ?? 'neutral'; + return ( + <StatusIndicator + className={status === 'running' ? 'animate-pulse' : undefined} + variant={variant} + /> + ); +} + +export function RunStatusBadge({ status }: { status: string }) { + if (status === 'passed') return <Badge className="bg-green-600">Passed</Badge>; + if (status === 'failed') return <Badge variant="destructive">Failed</Badge>; + if (status === 'running') return <Badge variant="secondary">Running...</Badge>; + if (status === 'cancelled') return <Badge variant="secondary">Cancelled</Badge>; + return <Badge variant="secondary">No runs</Badge>; +} diff --git a/src/renderer/features/test-suite/components/SaveRecordingDialog/SaveRecordingDialog.tsx b/src/renderer/features/test-suite/components/SaveRecordingDialog/SaveRecordingDialog.tsx new file mode 100644 index 00000000..98a998e8 --- /dev/null +++ b/src/renderer/features/test-suite/components/SaveRecordingDialog/SaveRecordingDialog.tsx @@ -0,0 +1,291 @@ +import type { AssertMethod } from '@shared/types/test-suite'; + +import { + Button, + Checkbox, + Code, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Flex, + Input, + Label, + ScrollArea, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Text, + Textarea, +} from '@ui'; + +import { describeStep } from '../../lib/describe-step'; + +import { useSaveRecordingDialog } from './useSaveRecordingDialog'; + +import type { RecordedStep } from '../../test-suite-store'; + +interface SaveRecordingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultName: string; + steps: RecordedStep[]; + projectId: string; + testDirectory?: string; +} + +export function SaveRecordingDialog({ + open, + onOpenChange, + defaultName, + steps, + projectId, + testDirectory, +}: SaveRecordingDialogProps) { + const vm = useSaveRecordingDialog({ open, onOpenChange, defaultName, steps, projectId, testDirectory }); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="flex h-[85vh] max-w-4xl flex-col"> + <DialogHeader className="shrink-0"> + <DialogTitle>Save Recording</DialogTitle> + <DialogDescription>Review and save your recorded test steps.</DialogDescription> + </DialogHeader> + + <Tabs className="flex min-h-0 flex-1 flex-col" defaultValue="info"> + <TabsList className="w-full shrink-0"> + <TabsTrigger className="flex-1" value="info">Info</TabsTrigger> + <TabsTrigger className="flex-1" value="spec">Spec File</TabsTrigger> + <TabsTrigger className="flex-1" value="preview">Preview</TabsTrigger> + <TabsTrigger className="flex-1" value="steps">Steps</TabsTrigger> + </TabsList> + + {/* Tab 1: Info */} + <TabsContent className="flex-1 overflow-auto pt-4" value="info"> + <Stack gap="md"> + <Stack gap="sm"> + <Label>Test Name *</Label> + <Input + placeholder="e.g. Login flow" + value={vm.name} + onChange={(e) => vm.setName(e.target.value)} + /> + </Stack> + <Stack gap="sm"> + <Label>Description</Label> + <Textarea + placeholder="Optional description..." + rows={3} + value={vm.description} + onChange={(e) => vm.setDescription(e.target.value)} + /> + </Stack> + <Stack gap="sm"> + <Label>Tags</Label> + <Input + placeholder="smoke, regression, login (comma-separated)" + value={vm.tagsInput} + onChange={(e) => vm.setTagsInput(e.target.value)} + /> + </Stack> + <Stack gap="sm"> + <Flex align="center" justify="between"> + <Label>Assertions</Label> + <Button + size="sm" + variant="outline" + onClick={() => vm.setShowAddAssertion(!vm.showAddAssertion)} + > + {vm.showAddAssertion ? 'Cancel' : 'Add Assertion'} + </Button> + </Flex> + + {vm.showAddAssertion ? ( + <Stack className="rounded-md border border-border p-3" gap="sm"> + <Flex gap="sm" wrap="wrap"> + <Select value={vm.newMethod} onValueChange={(v) => vm.setNewMethod(v as AssertMethod)}> + <SelectTrigger className="h-7 w-40 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="toHaveText">Has Text</SelectItem> + <SelectItem value="toContainText">Contains Text</SelectItem> + <SelectItem value="toBeVisible">Is Visible</SelectItem> + <SelectItem value="toBeHidden">Is Hidden</SelectItem> + <SelectItem value="toHaveCount">Has Count</SelectItem> + <SelectItem value="toHaveAttribute">Has Attribute</SelectItem> + <SelectItem value="toHaveURL">Has URL</SelectItem> + <SelectItem value="toHaveTitle">Has Title</SelectItem> + </SelectContent> + </Select> + {!['toHaveURL', 'toHaveTitle'].includes(vm.newMethod) && ( + <Input + className="h-7 flex-1 text-xs" + placeholder="CSS selector (e.g. table tbody tr)" + value={vm.newSelector} + onChange={(e) => vm.setNewSelector(e.target.value)} + /> + )} + {vm.newMethod === 'toHaveAttribute' && ( + <Input + className="h-7 w-32 text-xs" + placeholder="Attribute name" + value={vm.newAttribute} + onChange={(e) => vm.setNewAttribute(e.target.value)} + /> + )} + {!['toBeVisible', 'toBeHidden'].includes(vm.newMethod) && ( + <Input + className="h-7 flex-1 text-xs" + placeholder="Expected value" + value={vm.newExpected} + onChange={(e) => vm.setNewExpected(e.target.value)} + /> + )} + </Flex> + <Button + size="sm" + onClick={vm.handleAddAssertion} + > + Add + </Button> + </Stack> + ) : null} + + {vm.suggestions.length > 0 ? ( + <Stack gap="sm"> + <Text size="sm" variant="muted"> + Check the assertions to include. Click to edit selector or expected value. + </Text> + {vm.suggestions.map((s, i) => ( + <Flex + // eslint-disable-next-line react/no-array-index-key + key={`${s.selector}-${s.assertMethod}-${i}`} + align="start" + className="gap-2 text-sm" + > + <Checkbox + checked={s.accepted} + className="mt-0.5" + onCheckedChange={(checked) => vm.toggleSuggestionAccepted(i, checked)} + /> + <Stack className="flex-1" gap="sm"> + <Text variant="muted">{s.description}</Text> + {s.accepted ? ( + <Flex gap="sm" wrap="wrap"> + {!['toHaveURL', 'toHaveTitle'].includes(s.assertMethod) && ( + <Input + className="h-6 w-48 text-xs" + placeholder="Selector" + value={s.selector} + onChange={(e) => vm.updateSuggestionSelector(i, e.target.value)} + /> + )} + {!['toBeVisible', 'toBeHidden'].includes(s.assertMethod) && ( + <Input + className="h-6 flex-1 text-xs" + placeholder="Expected" + value={s.expected} + onChange={(e) => vm.updateSuggestionExpected(i, e.target.value)} + /> + )} + </Flex> + ) : null} + </Stack> + </Flex> + ))} + </Stack> + ) : null} + </Stack> + <Stack gap="sm"> + <Label>Test Directory</Label> + <Input + placeholder="tests" + value={vm.testDir} + onChange={(e) => vm.setTestDir(e.target.value)} + /> + </Stack> + </Stack> + </TabsContent> + + {/* Tab 2: Spec File */} + <TabsContent className="min-h-0 flex-1 pt-4" value="spec"> + <ScrollArea className="h-full"> + <Code className="block whitespace-pre-wrap break-words rounded-md border border-border bg-bg-surface p-4"> + {vm.specPreview} + </Code> + </ScrollArea> + </TabsContent> + + {/* Tab 3: Preview */} + <TabsContent className="min-h-0 flex-1 overflow-auto pt-4" value="preview"> + <Stack gap="sm"> + {steps.map((s, i) => ( + <Flex key={s.stepIndex} align="baseline" gap="sm"> + <Text className="w-6 shrink-0 text-right text-text-dim">{i + 1}.</Text> + <Text>{describeStep(s.step)}</Text> + </Flex> + ))} + </Stack> + </TabsContent> + + {/* Tab 4: Steps (raw data) */} + <TabsContent className="min-h-0 flex-1 overflow-auto pt-4" value="steps"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-10">#</TableHead> + <TableHead className="w-20">Type</TableHead> + <TableHead>Selector</TableHead> + <TableHead>Value</TableHead> + <TableHead className="w-16">Tag</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {steps.map((s, i) => ( + <TableRow key={s.stepIndex}> + <TableCell className="text-text-dim">{i + 1}</TableCell> + <TableCell className="font-mono uppercase text-accent">{s.step.type}</TableCell> + <TableCell className="max-w-[200px] truncate"> + {'selector' in s.step ? s.step.selector : '\u2014'} + </TableCell> + <TableCell className="max-w-[150px] truncate"> + {'value' in s.step ? s.step.value : '\u2014'} + </TableCell> + <TableCell> + {'context' in s.step && s.step.context ? s.step.context.tagName : '\u2014'} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </TabsContent> + </Tabs> + + <DialogFooter className="shrink-0"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button disabled={!vm.name.trim() || vm.saving} onClick={vm.handleSave}> + {vm.saving ? 'Saving...' : 'Save Test'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/test-suite/components/SaveRecordingDialog/index.ts b/src/renderer/features/test-suite/components/SaveRecordingDialog/index.ts new file mode 100644 index 00000000..9704b4cd --- /dev/null +++ b/src/renderer/features/test-suite/components/SaveRecordingDialog/index.ts @@ -0,0 +1 @@ +export { SaveRecordingDialog } from './SaveRecordingDialog'; diff --git a/src/renderer/features/test-suite/components/SaveRecordingDialog/useSaveRecordingDialog.ts b/src/renderer/features/test-suite/components/SaveRecordingDialog/useSaveRecordingDialog.ts new file mode 100644 index 00000000..5c9fe091 --- /dev/null +++ b/src/renderer/features/test-suite/components/SaveRecordingDialog/useSaveRecordingDialog.ts @@ -0,0 +1,148 @@ +import { useState } from 'react'; + +import type { AssertMethod } from '@shared/types/test-suite'; + +import { useSaveScript } from '../../api/useSaveScript'; +import { generateAssertionSuggestions, type AssertionSuggestion } from '../../lib/assertion-suggestions'; +import { generateSpecPreview } from '../../lib/generate-spec-preview'; +import { useTestSuiteStore } from '../../test-suite-store'; + +import type { RecordedStep } from '../../test-suite-store'; + +interface UseSaveRecordingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultName: string; + steps: RecordedStep[]; + projectId: string; + testDirectory?: string; +} + +export function useSaveRecordingDialog({ + onOpenChange, + defaultName, + steps, + projectId, +}: UseSaveRecordingDialogProps) { + const [name, setName] = useState(defaultName); + const [description, setDescription] = useState(''); + const [tagsInput, setTagsInput] = useState(''); + const [testDir, setTestDir] = useState('tests'); + const [suggestions, setSuggestions] = useState<AssertionSuggestion[]>(() => + generateAssertionSuggestions(steps), + ); + const [showAddAssertion, setShowAddAssertion] = useState(false); + const [newSelector, setNewSelector] = useState(''); + const [newExpected, setNewExpected] = useState(''); + const [newMethod, setNewMethod] = useState<AssertMethod>('toHaveText'); + const [newAttribute, setNewAttribute] = useState(''); + const saveScript = useSaveScript(projectId); + const clearSteps = useTestSuiteStore((s) => s.clearSteps); + const saving = saveScript.isPending; + + const specPreview = generateSpecPreview({ + name: name || 'Untitled', + steps: steps.map((s) => s.step), + }); + + const handleSave = () => { + if (!name.trim()) return; + const tags = tagsInput.split(',').map((t) => t.trim()).filter(Boolean); + const assertSteps = suggestions + .filter((s) => s.accepted) + .map((s) => ({ + type: 'assert' as const, + selector: s.selector || 'body', + expected: s.expected, + assertMethod: s.assertMethod, + ...(s.attribute ? { attribute: s.attribute } : {}), + })); + + const allSteps = [...steps.map((s) => s.step), ...assertSteps]; + + saveScript.mutate( + { + projectId, + name: name.trim(), + description: description.trim() || undefined, + steps: allSteps, + tags, + }, + { + onSuccess: () => { + clearSteps(); + onOpenChange(false); + }, + onError: (err) => { + console.error('[SaveRecordingDialog] save failed:', err); + onOpenChange(false); + }, + }, + ); + }; + + const toggleSuggestionAccepted = (index: number, checked: boolean | 'indeterminate') => { + const next = [...suggestions]; + next[index] = { ...next[index], accepted: checked === true }; + setSuggestions(next); + }; + + const updateSuggestionSelector = (index: number, value: string) => { + const next = [...suggestions]; + next[index] = { ...next[index], selector: value }; + setSuggestions(next); + }; + + const updateSuggestionExpected = (index: number, value: string) => { + const next = [...suggestions]; + next[index] = { ...next[index], expected: value }; + setSuggestions(next); + }; + + const handleAddAssertion = () => { + let desc = `Verify ${newSelector} ${newMethod} "${newExpected}"`; + if (newMethod === 'toBeVisible') desc = `Verify ${newSelector} is visible`; + if (newMethod === 'toBeHidden') desc = `Verify ${newSelector} is hidden`; + setSuggestions((prev) => [...prev, { + selector: newSelector, + expected: newExpected, + assertMethod: newMethod, + attribute: newAttribute || undefined, + description: desc, + accepted: true, + }]); + setNewSelector(''); + setNewExpected(''); + setNewAttribute(''); + setShowAddAssertion(false); + }; + + return { + name, + setName, + description, + setDescription, + tagsInput, + setTagsInput, + testDir, + setTestDir, + suggestions, + showAddAssertion, + setShowAddAssertion, + newSelector, + setNewSelector, + newExpected, + setNewExpected, + newMethod, + setNewMethod, + newAttribute, + setNewAttribute, + saving, + specPreview, + handleSave, + toggleSuggestionAccepted, + updateSuggestionSelector, + updateSuggestionExpected, + handleAddAssertion, + }; +} diff --git a/src/renderer/features/test-suite/components/ScheduleDialog/ScheduleDialog.tsx b/src/renderer/features/test-suite/components/ScheduleDialog/ScheduleDialog.tsx new file mode 100644 index 00000000..0753f792 --- /dev/null +++ b/src/renderer/features/test-suite/components/ScheduleDialog/ScheduleDialog.tsx @@ -0,0 +1,68 @@ +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, + Text, +} from '@ui'; + +import { useScheduleDialog } from './useScheduleDialog'; + +interface ScheduleDialogProps { + scriptId: string; + projectId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ScheduleDialog({ scriptId, projectId, open, onOpenChange }: ScheduleDialogProps) { + const vm = useScheduleDialog({ scriptId, projectId, onOpenChange }); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-sm"> + <DialogHeader> + <DialogTitle>Schedule Test Run</DialogTitle> + </DialogHeader> + <Stack gap="md"> + <Stack gap="sm"> + <Label>Run Interval</Label> + <Select + value={String(vm.intervalMs)} + onValueChange={(v) => vm.setIntervalMs(Number(v))} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {vm.intervals.map((i) => ( + <SelectItem key={i.value} value={String(i.value)}> + {i.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </Stack> + <Text size="sm" variant="muted"> + Test will run automatically at the selected interval. Desktop notifications will alert on failures. + </Text> + <Button + className="w-full" + disabled={vm.creating} + onClick={vm.handleCreate} + > + Create Schedule + </Button> + </Stack> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/test-suite/components/ScheduleDialog/index.ts b/src/renderer/features/test-suite/components/ScheduleDialog/index.ts new file mode 100644 index 00000000..5ddd3323 --- /dev/null +++ b/src/renderer/features/test-suite/components/ScheduleDialog/index.ts @@ -0,0 +1 @@ +export { ScheduleDialog } from './ScheduleDialog'; diff --git a/src/renderer/features/test-suite/components/ScheduleDialog/useScheduleDialog.ts b/src/renderer/features/test-suite/components/ScheduleDialog/useScheduleDialog.ts new file mode 100644 index 00000000..e810e8bd --- /dev/null +++ b/src/renderer/features/test-suite/components/ScheduleDialog/useScheduleDialog.ts @@ -0,0 +1,39 @@ +import { useState } from 'react'; + +import { useCreateSchedule } from '../../api/useSchedules'; +import { useMutationWithDialogClose } from '../../hooks/useMutationWithDialogClose'; + +const INTERVALS = [ + { label: 'Every 5 minutes', value: 300_000 }, + { label: 'Every 15 minutes', value: 900_000 }, + { label: 'Every 30 minutes', value: 1_800_000 }, + { label: 'Every hour', value: 3_600_000 }, + { label: 'Every 6 hours', value: 21_600_000 }, + { label: 'Every 12 hours', value: 43_200_000 }, + { label: 'Every day', value: 86_400_000 }, +] as const; + +interface UseScheduleDialogProps { + scriptId: string; + projectId: string; + onOpenChange: (open: boolean) => void; +} + +export function useScheduleDialog({ scriptId, projectId, onOpenChange }: UseScheduleDialogProps) { + const [intervalMs, setIntervalMs] = useState(3_600_000); + const createSchedule = useCreateSchedule(); + const { handleMutate, isPending } = useMutationWithDialogClose( + createSchedule, + () => onOpenChange(false), + ); + + const handleCreate = () => handleMutate({ scriptId, projectId, intervalMs }); + + return { + intervalMs, + setIntervalMs, + intervals: INTERVALS, + creating: isPending, + handleCreate, + }; +} diff --git a/src/renderer/features/test-suite/components/ScreenshotGallery/ScreenshotGallery.tsx b/src/renderer/features/test-suite/components/ScreenshotGallery/ScreenshotGallery.tsx new file mode 100644 index 00000000..3c2686f8 --- /dev/null +++ b/src/renderer/features/test-suite/components/ScreenshotGallery/ScreenshotGallery.tsx @@ -0,0 +1,162 @@ +/** + * ScreenshotGallery — Step-mapped screenshot gallery for the Results panel. + * + * Displays screenshots as a grid of cards, each labeled with the step number, + * type, and label. Clicking a thumbnail opens a full-size lightbox overlay. + */ + +import { ImageIcon, X } from 'lucide-react'; + +import { + Badge, + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + EmptyState, + Flex, + Grid, + ScrollArea, + Stack, + Text, +} from '@ui'; + +import { fileUrl } from '../../lib/screenshot-utils'; + +import { useScreenshotGallery } from './useScreenshotGallery'; + +// ─── Types ──────────────────────────────────────────────────── + +interface ScreenshotItem { + id: string; + stepIndex: number; + stepLabel: string; + trigger: string; + filePath: string; + capturedAt: string; +} + +interface ScreenshotGalleryProps { + screenshots: ScreenshotItem[]; +} + +// ─── Trigger badge variant ──────────────────────────────────── + +const TRIGGER_VARIANT: Record<string, 'default' | 'secondary' | 'outline'> = { + nav: 'default', + click: 'secondary', + fill: 'secondary', + assert: 'outline', + manual: 'outline', +}; + +// ─── Component ──────────────────────────────────────────────── + +export function ScreenshotGallery({ screenshots }: ScreenshotGalleryProps) { + const vm = useScreenshotGallery(screenshots); + + if (screenshots.length === 0) { + return ( + <EmptyState + description="No screenshots were captured during this run." + icon={ImageIcon} + title="No screenshots" + /> + ); + } + + return ( + <> + <Grid className="gap-3" cols={3}> + {screenshots.map((ss) => ( + <Button + key={ss.id} + className="group flex h-auto flex-col overflow-hidden rounded-md border border-border bg-bg-surface p-0 transition-colors hover:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + variant="ghost" + onClick={() => vm.setSelectedId(ss.id)} + > + <div className="relative aspect-video w-full overflow-hidden bg-bg-muted"> + <img + alt={ss.stepLabel} + className="h-full w-full object-cover transition-transform group-hover:scale-105" + loading="lazy" + src={fileUrl(ss.filePath)} + /> + </div> + <Flex align="center" className="gap-2 px-2 py-1.5" wrap="nowrap"> + <Text className="shrink-0 font-mono tabular-nums text-text-dim" size="sm"> + {ss.stepIndex + 1} + </Text> + <Badge className="shrink-0 text-[10px]" variant={TRIGGER_VARIANT[ss.trigger] ?? 'outline'}> + {ss.trigger.toUpperCase()} + </Badge> + <Text className="flex-1 truncate" size="sm" variant="muted"> + {ss.stepLabel} + </Text> + </Flex> + </Button> + ))} + </Grid> + + {/* Lightbox dialog */} + <Dialog open={vm.selected !== null} onOpenChange={vm.clearSelection}> + <DialogContent className="flex h-[85vh] max-w-5xl flex-col"> + <DialogHeader className="shrink-0"> + <Flex align="center" justify="between"> + <DialogTitle> + Step {(vm.selected?.stepIndex ?? 0) + 1}: {vm.selected?.stepLabel} + </DialogTitle> + <Button size="icon" variant="ghost" onClick={vm.clearSelection}> + <X className="h-4 w-4" /> + </Button> + </Flex> + </DialogHeader> + <ScrollArea className="flex-1"> + {vm.selected ? ( + <Stack className="p-4" gap="md"> + <img + alt={vm.selected.stepLabel} + className="mx-auto max-w-full rounded-md border border-border" + src={fileUrl(vm.selected.filePath)} + /> + <Flex align="center" className="rounded bg-bg-surface px-3 py-2" gap="lg" wrap="nowrap"> + <Badge variant={TRIGGER_VARIANT[vm.selected.trigger] ?? 'outline'}> + {vm.selected.trigger.toUpperCase()} + </Badge> + <Text size="sm" variant="muted">{vm.selected.stepLabel}</Text> + <Text className="ml-auto" size="sm" variant="muted"> + {new Date(vm.selected.capturedAt).toLocaleTimeString()} + </Text> + </Flex> + </Stack> + ) : null} + </ScrollArea> + + {/* Thumbnail nav strip */} + {screenshots.length > 1 ? ( + <Flex className="shrink-0 gap-1 overflow-x-auto border-t border-border p-2" wrap="nowrap"> + {screenshots.map((ss) => ( + <Button + key={ss.id} + variant="ghost" + className={`h-12 w-16 shrink-0 overflow-hidden rounded border-2 p-0 transition-colors ${ + ss.id === vm.selectedId ? 'border-accent' : 'border-transparent hover:border-border-hover' + }`} + onClick={() => vm.setSelectedId(ss.id)} + > + <img + alt={ss.stepLabel} + className="h-full w-full object-cover" + loading="lazy" + src={fileUrl(ss.filePath)} + /> + </Button> + ))} + </Flex> + ) : null} + </DialogContent> + </Dialog> + </> + ); +} diff --git a/src/renderer/features/test-suite/components/ScreenshotGallery/index.ts b/src/renderer/features/test-suite/components/ScreenshotGallery/index.ts new file mode 100644 index 00000000..58b4f192 --- /dev/null +++ b/src/renderer/features/test-suite/components/ScreenshotGallery/index.ts @@ -0,0 +1 @@ +export { ScreenshotGallery } from './ScreenshotGallery'; diff --git a/src/renderer/features/test-suite/components/ScreenshotGallery/useScreenshotGallery.ts b/src/renderer/features/test-suite/components/ScreenshotGallery/useScreenshotGallery.ts new file mode 100644 index 00000000..0f21c990 --- /dev/null +++ b/src/renderer/features/test-suite/components/ScreenshotGallery/useScreenshotGallery.ts @@ -0,0 +1,24 @@ +import { useState } from 'react'; + +interface ScreenshotItem { + id: string; + stepIndex: number; + stepLabel: string; + trigger: string; + filePath: string; + capturedAt: string; +} + +export function useScreenshotGallery(screenshots: ScreenshotItem[]) { + const [selectedId, setSelectedId] = useState<string | null>(null); + const selected = screenshots.find((s) => s.id === selectedId) ?? null; + + const clearSelection = () => setSelectedId(null); + + return { + selectedId, + setSelectedId, + selected, + clearSelection, + }; +} diff --git a/src/renderer/features/test-suite/components/ScreenshotPreview.tsx b/src/renderer/features/test-suite/components/ScreenshotPreview.tsx new file mode 100644 index 00000000..d92ecc1c --- /dev/null +++ b/src/renderer/features/test-suite/components/ScreenshotPreview.tsx @@ -0,0 +1,85 @@ +/** + * ScreenshotPreview — Large preview pane for a selected screenshot. + * + * Renders the full-size screenshot image, an optional diff viewer when a + * baseline comparison exists, and a metadata bar at the bottom. + */ + +import { Flex, ScrollArea, Stack, Text } from '@ui'; + +import { fileUrl } from '../lib/screenshot-utils'; + +import { DiffViewer } from './DiffViewer'; + +// ─── Types ──────────────────────────────────────────────────── + +interface ScreenshotPreviewProps { + baseline: { filePath: string; stepIndex: number } | null; + diff: { + diffFilePath: string; + mismatchPercentage: number; + screenshotId: string; + status: string; + } | null; + screenshot: { + capturedAt: string; + filePath: string; + id: string; + scriptId: string; + stepIndex: number; + stepLabel: string; + trigger: string; + }; +} + +// ─── Component ──────────────────────────────────────────────── + +export function ScreenshotPreview({ baseline, diff, screenshot }: ScreenshotPreviewProps) { + return ( + <Stack + className="flex-1 overflow-hidden rounded-md border border-border bg-surface-raised p-3" + gap="sm" + > + <ScrollArea className="flex-1"> + <Stack gap="md"> + <img + alt={screenshot.stepLabel} + className="mx-auto max-w-full rounded object-contain" + src={fileUrl(screenshot.filePath)} + /> + + {diff && baseline ? ( + <DiffViewer + actualPath={screenshot.filePath} + baselinePath={baseline.filePath} + diffPath={diff.diffFilePath} + mismatchPercentage={diff.mismatchPercentage} + status={diff.status} + /> + ) : null} + </Stack> + </ScrollArea> + + {/* Metadata bar */} + <Flex + align="center" + className="rounded bg-surface px-3 py-2" + gap="lg" + wrap="nowrap" + > + <Text size="sm" variant="muted"> + Step {screenshot.stepIndex} + </Text> + <Text size="sm" variant="muted"> + {screenshot.trigger} + </Text> + <Text size="sm" variant="muted"> + {screenshot.stepLabel} + </Text> + <Text className="ml-auto" size="sm" variant="muted"> + {new Date(screenshot.capturedAt).toLocaleTimeString()} + </Text> + </Flex> + </Stack> + ); +} diff --git a/src/renderer/features/test-suite/components/ScreenshotThumbnailStrip.tsx b/src/renderer/features/test-suite/components/ScreenshotThumbnailStrip.tsx new file mode 100644 index 00000000..fb741176 --- /dev/null +++ b/src/renderer/features/test-suite/components/ScreenshotThumbnailStrip.tsx @@ -0,0 +1,59 @@ +/** + * ScreenshotThumbnailStrip — Scrollable sidebar of screenshot thumbnails. + * + * Renders a vertical list of clickable thumbnails for each screenshot in a run. + * Highlights the currently selected index with an accent border. + */ + +import { Button, ScrollArea, Stack, Text } from '@ui'; + +import { fileUrl } from '../lib/screenshot-utils'; + +// ─── Types ──────────────────────────────────────────────────── + +interface ScreenshotThumbnailStripProps { + onSelect: (index: number) => void; + screenshots: Array<{ id: string; filePath: string; stepLabel: string }>; + selectedIndex: number; +} + +// ─── Component ──────────────────────────────────────────────── + +export function ScreenshotThumbnailStrip({ + onSelect, + screenshots, + selectedIndex, +}: ScreenshotThumbnailStripProps) { + return ( + <ScrollArea className="w-40 shrink-0 rounded-md border border-border bg-surface-raised"> + <Stack className="p-2" gap="sm"> + {screenshots.map((ss, idx) => ( + <Button + key={ss.id} + variant="ghost" + className={`h-auto w-full flex-col overflow-hidden rounded border-2 p-0 transition-colors ${ + idx === selectedIndex + ? 'border-accent' + : 'border-transparent hover:border-border-hover' + }`} + onClick={() => onSelect(idx)} + > + <img + alt={ss.stepLabel} + className="h-auto w-full object-cover" + loading="lazy" + src={fileUrl(ss.filePath)} + /> + <Text + className="w-full truncate px-1 py-0.5 text-center" + size="sm" + variant="muted" + > + {ss.stepLabel} + </Text> + </Button> + ))} + </Stack> + </ScrollArea> + ); +} diff --git a/src/renderer/features/test-suite/components/ScreenshotsPanel/ScreenshotsPanel.tsx b/src/renderer/features/test-suite/components/ScreenshotsPanel/ScreenshotsPanel.tsx new file mode 100644 index 00000000..4925857d --- /dev/null +++ b/src/renderer/features/test-suite/components/ScreenshotsPanel/ScreenshotsPanel.tsx @@ -0,0 +1,107 @@ +/** + * ScreenshotsPanel — Thumbnail strip + large preview + Copy/Export actions. + * + * Shows screenshots captured during a test run. Select a run from the dropdown, + * browse thumbnails in the left strip, and view a large preview on the right. + * Copy and Open Folder actions available in the toolbar. + */ + +import { ImageIcon } from 'lucide-react'; + +import { EmptyState, Flex, PageContent, Spinner, Stack } from '@ui'; + +import { ScreenshotPreview } from '../ScreenshotPreview'; +import { ScreenshotsToolbar } from '../ScreenshotsToolbar'; +import { ScreenshotThumbnailStrip } from '../ScreenshotThumbnailStrip'; + +import { useScreenshotsPanel } from './useScreenshotsPanel'; + +export function ScreenshotsPanel() { + const vm = useScreenshotsPanel(); + + // ── Loading state ───────────────────────────────────────────── + + if (vm.runsLoading) { + return ( + <PageContent> + <Flex align="center" className="p-12" justify="center"> + <Spinner size="md" /> + </Flex> + </PageContent> + ); + } + + // ── Content area (extracted to avoid nested ternaries) ──────── + + function renderContent() { + if (vm.selectedRunId === null) { + return ( + <EmptyState + description="Select a test run from the dropdown to view its screenshots." + icon={ImageIcon} + title="No run selected" + /> + ); + } + + if (vm.screenshotsLoading) { + return ( + <Flex align="center" className="flex-1" justify="center"> + <Spinner size="md" /> + </Flex> + ); + } + + if (!vm.hasScreenshots || vm.selected === null) { + return ( + <EmptyState + description="This run has no screenshots captured." + icon={ImageIcon} + title="No screenshots" + /> + ); + } + + return ( + <Flex className="flex-1 overflow-hidden" gap="md" wrap="nowrap"> + <ScreenshotThumbnailStrip + screenshots={vm.screenshots ?? []} + selectedIndex={vm.selectedIndex} + onSelect={vm.setSelectedIndex} + /> + <ScreenshotPreview + baseline={vm.currentBaseline} + diff={vm.currentDiff} + screenshot={vm.selected} + /> + </Flex> + ); + } + + // ── Render ──────────────────────────────────────────────────── + + return ( + <PageContent> + <Stack className="h-full p-4" gap="md"> + <ScreenshotsToolbar + compareDisabled={!vm.selectedRunId || !vm.hasScreenshots} + copyDisabled={vm.selected === null} + isComparePending={vm.isComparePending} + isSetBaselinePending={vm.isSetBaselinePending} + openFolderDisabled={!vm.selectedRunId || !vm.hasScreenshots} + runs={vm.runOptions} + selectedRunId={vm.selectedRunId} + sensitivity={vm.sensitivity} + setBaselineDisabled={vm.selected === null} + onCompare={vm.handleCompare} + onCopy={() => void vm.handleCopy()} + onOpenFolder={() => void vm.handleOpenFolder()} + onRunChange={vm.handleRunChange} + onSensitivityChange={vm.setSensitivity} + onSetBaseline={vm.handleSetBaseline} + /> + {renderContent()} + </Stack> + </PageContent> + ); +} diff --git a/src/renderer/features/test-suite/components/ScreenshotsPanel/index.ts b/src/renderer/features/test-suite/components/ScreenshotsPanel/index.ts new file mode 100644 index 00000000..bdf41bbc --- /dev/null +++ b/src/renderer/features/test-suite/components/ScreenshotsPanel/index.ts @@ -0,0 +1 @@ +export { ScreenshotsPanel } from './ScreenshotsPanel'; diff --git a/src/renderer/features/test-suite/components/ScreenshotsPanel/useScreenshotsPanel.ts b/src/renderer/features/test-suite/components/ScreenshotsPanel/useScreenshotsPanel.ts new file mode 100644 index 00000000..5e392793 --- /dev/null +++ b/src/renderer/features/test-suite/components/ScreenshotsPanel/useScreenshotsPanel.ts @@ -0,0 +1,102 @@ +import { useState } from 'react'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { useLooseParams } from '@renderer/shared/hooks'; +import { ipc } from '@renderer/shared/lib/ipc'; + +import { + useBaselines, + useCompareDiffs, + useRunDiffs, + useSetBaseline, +} from '../../api/useBaselines'; +import { useRuns } from '../../api/useRuns'; +import { useTestSuiteScreenshots } from '../../api/useTestSuiteScreenshots'; +import { useTestSuiteScripts } from '../../api/useTestSuiteScripts'; +import { useTestSuiteStore } from '../../test-suite-store'; + +type Sensitivity = 'strict' | 'balanced' | 'relaxed'; + +export function useScreenshotsPanel() { + const { projectId } = useLooseParams(); + const selectedRunId = useTestSuiteStore((s) => s.selectedRunId); + const setSelectedRunId = useTestSuiteStore((s) => s.setSelectedRunId); + + const { data: runs, isLoading: runsLoading } = useRuns(); + const { data: scripts } = useTestSuiteScripts(projectId); + const { data: screenshots, isLoading: screenshotsLoading } = useTestSuiteScreenshots(selectedRunId); + + const [selectedIndex, setSelectedIndex] = useState(0); + const [sensitivity, setSensitivity] = useState<Sensitivity>('balanced'); + + const selected = screenshots?.[selectedIndex] ?? null; + const hasScreenshots = Array.isArray(screenshots) && screenshots.length > 0; + + const { data: baselines } = useBaselines(selected?.scriptId); + const { data: diffs } = useRunDiffs(selectedRunId ?? undefined); + const setBaseline = useSetBaseline(); + const compareDiffs = useCompareDiffs(); + + const currentDiff = selected ? (diffs?.find((d) => d.screenshotId === selected.id) ?? null) : null; + const currentBaseline = selected ? (baselines?.find((b) => b.stepIndex === selected.stepIndex) ?? null) : null; + + // ── Actions ────────────────────────────────────────────────── + + async function handleOpenFolder() { + if (!selectedRunId) return; + await ipc(TEST_SUITE.SCREENSHOT['EXPORT-ZIP'], { runId: selectedRunId }); + } + + async function handleCopy() { + if (!selected) return; + const tempDest = `${selected.filePath}.clipboard.png`; + await ipc(TEST_SUITE.SCREENSHOT.COPY, { id: selected.id, destPath: tempDest }); + } + + function handleSetBaseline() { + if (!selected) return; + setBaseline.mutate({ scriptId: selected.scriptId, screenshotId: selected.id }); + } + + function handleCompare() { + if (!selectedRunId) return; + compareDiffs.mutate({ runId: selectedRunId, sensitivity }); + } + + function handleRunChange(runId: string | null) { + setSelectedRunId(runId); + setSelectedIndex(0); + } + + // Build run options for toolbar + const runOptions = (runs ?? []).map((r) => ({ + id: r.id, + status: r.status, + startedAt: r.startedAt, + scriptName: scripts?.find((s) => s.id === r.scriptId)?.name, + })); + + return { + runsLoading, + screenshotsLoading, + selectedRunId, + screenshots, + selected, + hasScreenshots, + selectedIndex, + setSelectedIndex, + sensitivity, + setSensitivity, + currentDiff, + currentBaseline, + runOptions, + isComparePending: compareDiffs.isPending, + isSetBaselinePending: setBaseline.isPending, + handleOpenFolder, + handleCopy, + handleSetBaseline, + handleCompare, + handleRunChange, + }; +} diff --git a/src/renderer/features/test-suite/components/ScreenshotsToolbar.tsx b/src/renderer/features/test-suite/components/ScreenshotsToolbar.tsx new file mode 100644 index 00000000..fac354fc --- /dev/null +++ b/src/renderer/features/test-suite/components/ScreenshotsToolbar.tsx @@ -0,0 +1,148 @@ +/** + * ScreenshotsToolbar — Run selector + baseline/compare/copy/open-folder actions. + * + * Owns no state; all values and callbacks are passed as props from ScreenshotsPanel. + */ + +import { Copy, FolderOpen, GitCompare, Target } from 'lucide-react'; + +import { + Button, + Flex, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@ui'; + +// ─── Types ──────────────────────────────────────────────────── + +type Sensitivity = 'strict' | 'balanced' | 'relaxed'; + +interface RunItem { + id: string; + status: string; + startedAt?: string; + scriptName?: string; +} + +interface ScreenshotsToolbarProps { + compareDisabled: boolean; + copyDisabled: boolean; + isComparePending: boolean; + isSetBaselinePending: boolean; + onCompare: () => void; + onCopy: () => void; + onOpenFolder: () => void; + onRunChange: (runId: string | null) => void; + onSensitivityChange: (value: Sensitivity) => void; + onSetBaseline: () => void; + openFolderDisabled: boolean; + runs: RunItem[]; + selectedRunId: string | null; + sensitivity: Sensitivity; + setBaselineDisabled: boolean; +} + +// ─── Component ──────────────────────────────────────────────── + +export function ScreenshotsToolbar({ + compareDisabled, + copyDisabled, + isComparePending, + isSetBaselinePending, + onCompare, + onCopy, + onOpenFolder, + onRunChange, + onSensitivityChange, + onSetBaseline, + openFolderDisabled, + runs, + selectedRunId, + sensitivity, + setBaselineDisabled, +}: ScreenshotsToolbarProps) { + return ( + <Flex align="center" gap="md" wrap="nowrap"> + <Select + value={selectedRunId ?? ''} + onValueChange={(val) => onRunChange(val || null)} + > + <SelectTrigger className="w-64"> + <SelectValue placeholder="Select a run..." /> + </SelectTrigger> + <SelectContent> + {runs.map((run) => { + const date = run.startedAt + ? new Date(run.startedAt).toLocaleString(undefined, { + month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + }) + : run.id.slice(0, 8); + const label = run.scriptName + ? `${run.scriptName} — ${date} (${run.status})` + : `${date} — ${run.status}`; + return ( + <SelectItem key={run.id} value={run.id}> + {label} + </SelectItem> + ); + })} + </SelectContent> + </Select> + + <Flex align="center" className="ml-auto" gap="sm" wrap="nowrap"> + <Button + disabled={setBaselineDisabled || isSetBaselinePending} + size="sm" + variant="outline" + onClick={onSetBaseline} + > + <Target className="mr-1.5 h-4 w-4" /> + Set as Baseline + </Button> + <Button + disabled={compareDisabled || isComparePending} + size="sm" + variant="outline" + onClick={onCompare} + > + <GitCompare className="mr-1.5 h-4 w-4" /> + Compare to Baseline + </Button> + <Select + value={sensitivity} + onValueChange={(val) => onSensitivityChange(val as Sensitivity)} + > + <SelectTrigger className="w-32"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="strict">Strict</SelectItem> + <SelectItem value="balanced">Balanced</SelectItem> + <SelectItem value="relaxed">Relaxed</SelectItem> + </SelectContent> + </Select> + <Button + disabled={copyDisabled} + size="sm" + variant="outline" + onClick={onCopy} + > + <Copy className="mr-1.5 h-4 w-4" /> + Copy + </Button> + <Button + disabled={openFolderDisabled} + size="sm" + variant="outline" + onClick={onOpenFolder} + > + <FolderOpen className="mr-1.5 h-4 w-4" /> + Open Folder + </Button> + </Flex> + </Flex> + ); +} diff --git a/src/renderer/features/test-suite/components/SetupCard/SetupCard.tsx b/src/renderer/features/test-suite/components/SetupCard/SetupCard.tsx new file mode 100644 index 00000000..86494bc5 --- /dev/null +++ b/src/renderer/features/test-suite/components/SetupCard/SetupCard.tsx @@ -0,0 +1,136 @@ +import type { TestSuiteConfig } from '@shared/ipc/test-suite'; + +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Flex, + Grid, + Input, + Label, + PageContent, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, + Text, +} from '@ui'; + +import { + MIN_VIEWPORT_HEIGHT, + MIN_VIEWPORT_WIDTH, + SCREENSHOT_MODES, +} from '../../lib/constants'; + +import { useSetupCard } from './useSetupCard'; + +type ScreenshotMode = TestSuiteConfig['screenshotMode']; + +interface SetupCardProps { + projectId: string; +} + +export function SetupCard({ projectId }: SetupCardProps) { + const vm = useSetupCard(projectId); + + return ( + <PageContent> + <Card className="mx-auto w-full max-w-[560px]"> + <CardHeader> + <CardTitle>Set up your test suite</CardTitle> + <CardDescription> + Configure the target app and viewport for recording tests. + </CardDescription> + </CardHeader> + <CardContent> + <form className="flex flex-col gap-5" onSubmit={vm.handleSubmit}> + <Stack gap="sm"> + <Label htmlFor="setup-target-url">Target URL</Label> + <Input + required + id="setup-target-url" + placeholder="http://localhost:3000" + type="url" + value={vm.targetUrl} + onChange={(event) => vm.setTargetUrl(event.target.value)} + /> + {vm.urlError ? ( + <Text size="sm" variant="error">{vm.urlError}</Text> + ) : null} + </Stack> + + <Grid cols={2} gap="md"> + <Stack gap="sm"> + <Label htmlFor="setup-viewport-width">Width</Label> + <Input + required + id="setup-viewport-width" + min={MIN_VIEWPORT_WIDTH} + type="number" + value={vm.width} + onChange={(event) => vm.setWidth(Number(event.target.value))} + /> + </Stack> + <Stack gap="sm"> + <Label htmlFor="setup-viewport-height">Height</Label> + <Input + required + id="setup-viewport-height" + min={MIN_VIEWPORT_HEIGHT} + type="number" + value={vm.height} + onChange={(event) => vm.setHeight(Number(event.target.value))} + /> + </Stack> + </Grid> + + <Stack gap="sm"> + <Label htmlFor="setup-screenshot-mode">Screenshot Mode</Label> + <Select + value={vm.mode} + onValueChange={(value) => vm.setMode(value as ScreenshotMode)} + > + <SelectTrigger aria-label="Screenshot mode" id="setup-screenshot-mode"> + <SelectValue placeholder="Select a mode" /> + </SelectTrigger> + <SelectContent> + {SCREENSHOT_MODES.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </Stack> + + <Stack gap="sm"> + <Label htmlFor="setup-test-directory">Test Directory</Label> + <Input + required + id="setup-test-directory" + type="text" + value={vm.testDirectory} + onChange={(event) => vm.setTestDirectory(event.target.value)} + /> + </Stack> + + {vm.setupStatus ? ( + <Text variant="muted">{vm.setupStatus}</Text> + ) : null} + + <Flex justify="end"> + <Button disabled={vm.isSettingUp || vm.saving} type="submit"> + {vm.buttonLabel} + </Button> + </Flex> + </form> + </CardContent> + </Card> + </PageContent> + ); +} diff --git a/src/renderer/features/test-suite/components/SetupCard/index.ts b/src/renderer/features/test-suite/components/SetupCard/index.ts new file mode 100644 index 00000000..ee4805b0 --- /dev/null +++ b/src/renderer/features/test-suite/components/SetupCard/index.ts @@ -0,0 +1 @@ +export { SetupCard } from './SetupCard'; diff --git a/src/renderer/features/test-suite/components/SetupCard/useSetupCard.ts b/src/renderer/features/test-suite/components/SetupCard/useSetupCard.ts new file mode 100644 index 00000000..22da3593 --- /dev/null +++ b/src/renderer/features/test-suite/components/SetupCard/useSetupCard.ts @@ -0,0 +1,115 @@ +import { useState } from 'react'; + +import type { TestSuiteConfig } from '@shared/ipc/test-suite'; +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +import { useSaveTestSuiteConfig } from '../../api/useSaveTestSuiteConfig'; +import { useUrlValidation } from '../../hooks/useUrlValidation'; +import { + DEFAULT_ACTION_TIMEOUT, + DEFAULT_NAVIGATION_TIMEOUT, + DEFAULT_VIEWPORT_HEIGHT, + DEFAULT_VIEWPORT_WIDTH, +} from '../../lib/constants'; +import { + DEFAULT_CONFIG_SCREENSHOT_MODE, + DEFAULT_CONFIG_TARGET_URL, + DEFAULT_CONFIG_TEST_DIRECTORY, +} from '../../lib/starter-test'; + +type ScreenshotMode = TestSuiteConfig['screenshotMode']; + +function buttonLabel(settingUp: boolean, saving: boolean): string { + if (settingUp) return 'Setting up...'; + if (saving) return 'Saving...'; + return 'Save & Start Recording'; +} + +export function useSetupCard(projectId: string) { + const save = useSaveTestSuiteConfig(projectId); + + const [targetUrl, setTargetUrl] = useState(DEFAULT_CONFIG_TARGET_URL); + const [width, setWidth] = useState(DEFAULT_VIEWPORT_WIDTH); + const [height, setHeight] = useState(DEFAULT_VIEWPORT_HEIGHT); + const [mode, setMode] = useState<ScreenshotMode>(DEFAULT_CONFIG_SCREENSHOT_MODE); + const [testDirectory, setTestDirectory] = useState(DEFAULT_CONFIG_TEST_DIRECTORY); + const { urlError, validate: validateUrl } = useUrlValidation(); + const [setupStatus, setSetupStatus] = useState<string | null>(null); + const [isSettingUp, setIsSettingUp] = useState(false); + + const handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => { + event.preventDefault(); + + if (!validateUrl(targetUrl)) return; + const { hostname: parsedHostname } = new URL(targetUrl); + setIsSettingUp(true); + setSetupStatus('Installing Playwright dependencies...'); + + void (async () => { + try { + const result = await ipc(TEST_SUITE.SETUP['ENSURE-DEPS'], { projectId }); + if (!result.installed) { + setSetupStatus(`Failed to install Playwright: ${result.error ?? 'Unknown error'}`); + setIsSettingUp(false); + return; + } + setSetupStatus(result.alreadyInstalled ? 'Playwright already installed. Saving config...' : 'Playwright installed. Saving config...'); + } catch (err) { + setSetupStatus(`Setup error: ${err instanceof Error ? err.message : String(err)}`); + setIsSettingUp(false); + return; + } + + const now = new Date().toISOString(); + const config: TestSuiteConfig = { + id: crypto.randomUUID(), + name: `${parsedHostname}-default`, + targetUrl, + viewportWidth: width, + viewportHeight: height, + screenshotMode: mode, + testDirectory, + saveScreenshotsToTemp: false, + navigationTimeout: DEFAULT_NAVIGATION_TIMEOUT, + actionTimeout: DEFAULT_ACTION_TIMEOUT, + browsers: ['chromium'], + workers: 1, + retries: 1, + environments: [], + activeEnvironment: undefined, + storageStatePath: undefined, + isActive: true, + createdAt: now, + updatedAt: now, + }; + + save.mutate(config, { + onSettled: () => { + setIsSettingUp(false); + setSetupStatus(null); + }, + }); + })(); + }; + + return { + targetUrl, + setTargetUrl, + width, + setWidth, + height, + setHeight, + mode, + setMode, + testDirectory, + setTestDirectory, + urlError, + setupStatus, + isSettingUp, + saving: save.isPending, + buttonLabel: buttonLabel(isSettingUp, save.isPending), + handleSubmit, + }; +} diff --git a/src/renderer/features/test-suite/components/SharedStepsPanel/SharedStepsPanel.tsx b/src/renderer/features/test-suite/components/SharedStepsPanel/SharedStepsPanel.tsx new file mode 100644 index 00000000..3e02df55 --- /dev/null +++ b/src/renderer/features/test-suite/components/SharedStepsPanel/SharedStepsPanel.tsx @@ -0,0 +1,100 @@ +import { Folder, Trash2 } from 'lucide-react'; + +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + EmptyState, + Flex, + Grid, + PageContent, + ScrollArea, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Text, +} from '@ui'; + +import { CreateSharedStepDialog } from '../CreateSharedStepDialog'; + +import { useSharedStepsPanel } from './useSharedStepsPanel'; + +export function SharedStepsPanel() { + const vm = useSharedStepsPanel(); + + if (!vm.projectId) return null; + + return ( + <PageContent> + <Flex align="center" className="border-b border-border px-4 py-3" gap="sm" wrap="nowrap"> + <Select value={vm.domainFilter} onValueChange={vm.setDomainFilter}> + <SelectTrigger className="w-40"> + <SelectValue placeholder="Filter domain..." /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All domains</SelectItem> + {vm.domains.map((d) => ( + <SelectItem key={d} value={d}>{d}</SelectItem> + ))} + </SelectContent> + </Select> + + <CreateSharedStepDialog projectId={vm.projectId} /> + </Flex> + + <ScrollArea className="flex-1"> + {vm.filtered.length === 0 ? ( + <EmptyState + description="Create reusable step sequences to speed up test recording." + title="No shared steps" + /> + ) : ( + <Grid className="p-4" cols={2} gap="md"> + {vm.filtered.map((group) => ( + <Card key={group.id}> + <CardHeader className="pb-2"> + <CardTitle className="flex items-center justify-between text-sm"> + <Flex align="center" gap="sm"> + <Folder className="h-4 w-4 text-text-muted" /> + {group.name} + </Flex> + <Badge variant="secondary">{group.domain}</Badge> + </CardTitle> + </CardHeader> + <CardContent> + {group.description ? ( + <Text className="mb-2" size="sm" variant="muted">{group.description}</Text> + ) : null} + <Text className="mb-2" size="sm" variant="muted"> + {group.steps.length} steps — used {group.usageCount}x + </Text> + <Flex gap="sm"> + <Button + size="sm" + variant="outline" + onClick={() => { vm.insertSteps(group); }} + > + Insert into Recording + </Button> + <Button + size="icon" + variant="ghost" + onClick={() => { vm.handleDelete(group.id); }} + > + <Trash2 className="h-3 w-3 text-destructive" /> + </Button> + </Flex> + </CardContent> + </Card> + ))} + </Grid> + )} + </ScrollArea> + </PageContent> + ); +} diff --git a/src/renderer/features/test-suite/components/SharedStepsPanel/index.ts b/src/renderer/features/test-suite/components/SharedStepsPanel/index.ts new file mode 100644 index 00000000..5cb21c0b --- /dev/null +++ b/src/renderer/features/test-suite/components/SharedStepsPanel/index.ts @@ -0,0 +1 @@ +export { SharedStepsPanel } from './SharedStepsPanel'; diff --git a/src/renderer/features/test-suite/components/SharedStepsPanel/useSharedStepsPanel.ts b/src/renderer/features/test-suite/components/SharedStepsPanel/useSharedStepsPanel.ts new file mode 100644 index 00000000..b968a3b6 --- /dev/null +++ b/src/renderer/features/test-suite/components/SharedStepsPanel/useSharedStepsPanel.ts @@ -0,0 +1,47 @@ +import { useState } from 'react'; + +import { useLooseParams } from '@renderer/shared/hooks'; + +import { + useDeleteSharedSteps, + useSharedStepDomains, + useSharedSteps, +} from '../../api/useSharedSteps'; +import { useTestSuiteStore } from '../../test-suite-store'; + +export function useSharedStepsPanel() { + const { projectId } = useLooseParams(); + const { data: groups = [] } = useSharedSteps(projectId); + const { data: domains = [] } = useSharedStepDomains(projectId); + const deleteSharedSteps = useDeleteSharedSteps(); + const [domainFilter, setDomainFilter] = useState<string>('all'); + const addStep = useTestSuiteStore((s) => s.addStep); + + const filtered = domainFilter === 'all' + ? groups + : groups.filter((g) => g.domain === domainFilter); + + const insertSteps = (group: typeof groups[0]) => { + for (let i = 0; i < group.steps.length; i++) { + addStep({ + stepIndex: Date.now() + i, + step: group.steps[i], + timestamp: new Date().toISOString(), + }); + } + }; + + const handleDelete = (id: string) => { + deleteSharedSteps.mutate(id); + }; + + return { + projectId, + domains, + domainFilter, + setDomainFilter, + filtered, + insertSteps, + handleDelete, + }; +} diff --git a/src/renderer/features/test-suite/components/ShortcutHelpDialog.tsx b/src/renderer/features/test-suite/components/ShortcutHelpDialog.tsx new file mode 100644 index 00000000..3bc21690 --- /dev/null +++ b/src/renderer/features/test-suite/components/ShortcutHelpDialog.tsx @@ -0,0 +1,48 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Flex, + Stack, + Text, +} from '@ui'; + +import { useTestSuiteStore } from '../test-suite-store'; + +const SHORTCUTS = [ + { keys: 'Alt + 1', action: 'Recording tab' }, + { keys: 'Alt + 2', action: 'Library tab' }, + { keys: 'Alt + 3', action: 'Results tab' }, + { keys: 'Alt + 4', action: 'Screenshots tab' }, + { keys: 'Alt + 5', action: 'Analytics tab' }, + { keys: 'Alt + 6', action: 'CI Export tab' }, + { keys: 'Alt + S', action: 'Focus search' }, + { keys: '?', action: 'Show this help' }, + { keys: 'Escape', action: 'Close dialog' }, +]; + +export function ShortcutHelpDialog() { + const open = useTestSuiteStore((s) => s.shortcutHelpOpen); + const setOpen = useTestSuiteStore((s) => s.setShortcutHelpOpen); + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent className="max-w-sm"> + <DialogHeader> + <DialogTitle>Keyboard Shortcuts</DialogTitle> + </DialogHeader> + <Stack gap="sm"> + {SHORTCUTS.map((s) => ( + <Flex key={s.keys} align="center" justify="between"> + <Text size="sm" variant="muted">{s.action}</Text> + <kbd className="rounded border border-border bg-bg-surface px-2 py-0.5 font-mono text-xs"> + {s.keys} + </kbd> + </Flex> + ))} + </Stack> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/test-suite/components/StepList/StepList.tsx b/src/renderer/features/test-suite/components/StepList/StepList.tsx new file mode 100644 index 00000000..598f2f64 --- /dev/null +++ b/src/renderer/features/test-suite/components/StepList/StepList.tsx @@ -0,0 +1,119 @@ +/** + * StepList + * + * Renders recorder step events live as they stream in from the main + * process. Subscribes to `TEST_SUITE_EVENTS.RECORDER.STEP` and appends + * each payload to the test-suite store. Steps can be drag-reordered, + * removed, and (for `fill` steps) inline-edited. + */ + +import { + DndContext, + closestCenter, +} from '@dnd-kit/core'; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { GripVertical, Pencil, Trash2 } from 'lucide-react'; + +import { Button, Flex, Input, Stack, Text } from '@ui'; + +import { renderDetail, useSortableStepItem, useStepList } from './useStepList'; + +import type { RecordedStep } from '../../test-suite-store'; + +export function StepList() { + const vm = useStepList(); + + return ( + <Stack className="overflow-y-auto p-3 text-sm" gap="sm"> + <DndContext + collisionDetection={closestCenter} + sensors={vm.sensors} + onDragEnd={vm.handleDragEnd} + > + <SortableContext + items={vm.steps.map((s) => s.stepIndex)} + strategy={verticalListSortingStrategy} + > + {vm.steps.map((evt) => ( + <SortableStepItem key={evt.stepIndex} step={evt} /> + ))} + </SortableContext> + </DndContext> + </Stack> + ); +} + +function SortableStepItem({ step }: { step: RecordedStep }) { + const vm = useSortableStepItem(step); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: step.stepIndex }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( + <div + ref={setNodeRef} + className="flex items-start gap-1 rounded border border-border bg-bg-surface px-2 py-1" + style={style} + > + <div + className="mt-0.5 cursor-grab text-text-dim" + {...attributes} + {...listeners} + > + <GripVertical className="h-3 w-3" /> + </div> + <Text className="w-6 text-text-dim" size="sm">{step.stepIndex + 1}</Text> + <Text className="font-mono uppercase text-accent" size="sm">{step.step.type}</Text> + <Text className="flex-1 truncate" size="sm" variant="muted"> + {vm.editing ? ( + <Flex align="center" gap="sm"> + <Input + className="h-5 text-xs" + value={vm.editValue} + onKeyDown={vm.handleKeyDown} + onChange={(e) => { + vm.setEditValue(e.target.value); + }} + /> + <Button size="icon-xs" variant="ghost" onClick={vm.saveEdit}> + <Pencil className="h-3 w-3" /> + </Button> + </Flex> + ) : ( + renderDetail(step.step) + )} + </Text> + <Flex align="center" className="shrink-0" gap="none"> + {step.step.type === 'fill' && !vm.editing && ( + <Button size="icon-xs" variant="ghost" onClick={vm.startEdit}> + <Pencil className="h-3 w-3" /> + </Button> + )} + <Button + size="icon-xs" + variant="ghost" + onClick={vm.handleRemove} + > + <Trash2 className="h-3 w-3 text-destructive" /> + </Button> + </Flex> + </div> + ); +} diff --git a/src/renderer/features/test-suite/components/StepList/index.ts b/src/renderer/features/test-suite/components/StepList/index.ts new file mode 100644 index 00000000..8509fdc6 --- /dev/null +++ b/src/renderer/features/test-suite/components/StepList/index.ts @@ -0,0 +1 @@ +export { StepList } from './StepList'; diff --git a/src/renderer/features/test-suite/components/StepList/useStepList.ts b/src/renderer/features/test-suite/components/StepList/useStepList.ts new file mode 100644 index 00000000..4ffcc3e9 --- /dev/null +++ b/src/renderer/features/test-suite/components/StepList/useStepList.ts @@ -0,0 +1,128 @@ +import { useState } from 'react'; + +import { + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; + +import type { TestSuiteStep } from '@shared/types/test-suite'; + +import { useTestSuiteStore } from '../../test-suite-store'; + +import type { RecordedStep } from '../../test-suite-store'; +import type { DragEndEvent } from '@dnd-kit/core'; + +export function useStepList() { + const steps = useTestSuiteStore((s) => s.recordedSteps); + const reorderSteps = useTestSuiteStore((s) => s.reorderSteps); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = steps.findIndex((s) => s.stepIndex === active.id); + const newIndex = steps.findIndex((s) => s.stepIndex === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + reorderSteps(oldIndex, newIndex); + } + }; + + return { + steps, + sensors, + handleDragEnd, + }; +} + +export function useSortableStepItem(step: RecordedStep) { + const removeStep = useTestSuiteStore((s) => s.removeStep); + const updateStep = useTestSuiteStore((s) => s.updateStep); + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(''); + + const startEdit = () => { + if (step.step.type === 'fill') { + setEditValue(step.step.value); + setEditing(true); + } + }; + + const saveEdit = () => { + if (step.step.type === 'fill') { + updateStep(step.stepIndex, { ...step.step, value: editValue }); + } + setEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === 'Enter') { + saveEdit(); + return; + } + if (e.key === 'Escape') { + setEditing(false); + } + }; + + const handleRemove = () => { + removeStep(step.stepIndex); + }; + + return { + editing, + editValue, + setEditValue, + startEdit, + saveEdit, + handleKeyDown, + handleRemove, + }; +} + +// ─── Detail renderers ────────────────────────────────────────── + +type DetailRenderer = (step: TestSuiteStep) => string; + +const DETAIL_RENDERERS: Record<TestSuiteStep['type'], DetailRenderer> = { + navigate: (step) => (step.type === 'navigate' ? step.url : ''), + click: (step) => { + if (step.type !== 'click') return ''; + if (step.context?.tagName) { + const label = step.context.label ?? step.context.text ?? ''; + return label ? `${step.context.tagName} "${label.slice(0, 30)}"` : step.context.tagName; + } + // Fallback: extract last segment of selector + const parts = step.selector.split(' > '); + return parts.at(-1) ?? step.selector; + }, + fill: (step) => { + if (step.type !== 'fill') return ''; + const label = step.context?.label ?? step.context?.placeholder ?? ''; + const target = label ? `"${label.slice(0, 20)}"` : step.selector.split(' > ').pop() ?? step.selector; + return `${target} → "${step.value.slice(0, 30)}"`; + }, + select: (step) => { + if (step.type !== 'select') return ''; + const label = step.context?.label ?? ''; + const target = label ? `"${label.slice(0, 20)}"` : step.selector.split(' > ').pop() ?? step.selector; + return `${target} → ${step.value}`; + }, + press: (step) => (step.type === 'press' ? step.key : ''), + wait: (step) => (step.type === 'wait' ? `${String(step.ms)}ms` : ''), + assert: (step) => + step.type === 'assert' ? `${step.selector} = ${step.expected}` : '', +}; + +export function renderDetail(step: TestSuiteStep): string { + return DETAIL_RENDERERS[step.type](step); +} diff --git a/src/renderer/features/test-suite/components/StepTimeline/StepTimeline.tsx b/src/renderer/features/test-suite/components/StepTimeline/StepTimeline.tsx new file mode 100644 index 00000000..7a30dc88 --- /dev/null +++ b/src/renderer/features/test-suite/components/StepTimeline/StepTimeline.tsx @@ -0,0 +1,105 @@ +import { CheckCircle2, Clock, Loader2, XCircle } from 'lucide-react'; + +import { Badge, Flex, Icon, Stack, Text, Tooltip, TooltipContent, TooltipTrigger } from '@ui'; + +import type { RunStep } from '../../hooks/useRunSteps'; + +interface StepTimelineProps { + steps: RunStep[]; + runStatus: string; +} + +const STEP_TYPE_LABEL: Record<string, string> = { + navigate: 'NAV', + click: 'CLICK', + fill: 'FILL', + select: 'SELECT', + press: 'KEY', + wait: 'WAIT', + assert: 'ASSERT', +}; + +function formatDetail(label: string): string { + // stepToLabel produces "Click selector", "Navigate → url", etc. + // Strip the type prefix to get just the detail portion. + const prefixes = ['Navigate → ', 'Click ', 'Fill ', 'Select ', 'Press ', 'Wait ', 'Assert ']; + for (const p of prefixes) { + if (label.startsWith(p)) return label.slice(p.length); + } + return label; +} + +const stepIcons = { + passed: CheckCircle2, + failed: XCircle, + pending: Clock, + running: Loader2, +}; + +export function StepTimeline({ steps, runStatus }: StepTimelineProps) { + if (steps.length === 0) { + return ( + <Flex align="center" className="h-full p-3" justify="center"> + <Text size="sm" variant="muted"> + {runStatus === 'running' ? 'Waiting for steps...' : 'Select a test to see steps'} + </Text> + </Flex> + ); + } + + const maxDuration = Math.max(...steps.map((s) => s.durationMs ?? 0), 1); + + return ( + <Stack className="gap-1 overflow-y-auto p-3" gap="none"> + {steps.map((step, i) => { + const isLast = i === steps.length - 1; + const isComplete = step.durationMs !== null; + const barWidth = isComplete ? ((step.durationMs ?? 0) / maxDuration) * 100 : 0; + const isSlow = (step.durationMs ?? 0) > 5000; + const typeLabel = STEP_TYPE_LABEL[step.stepType] ?? step.stepType.toUpperCase(); + const detail = formatDetail(step.stepLabel); + const variant = step.status ?? (isLast && runStatus === 'running' ? 'running' : 'pending'); + + return ( + <Tooltip key={step.stepIndex}> + <TooltipTrigger asChild> + <div className="flex flex-col gap-0.5 rounded border border-border bg-bg-surface px-2 py-1.5"> + <Flex align="center" gap="sm" wrap="nowrap"> + <Text className="w-5 shrink-0 text-right text-text-dim" size="sm"> + {step.stepIndex + 1} + </Text> + <Badge className="shrink-0 font-mono text-[10px]" variant="outline"> + {typeLabel} + </Badge> + <Text className="flex-1 truncate" size="sm" variant="muted"> + {detail} + </Text> + <Icon component={stepIcons[variant]} variant={variant} /> + </Flex> + {isComplete ? ( + <Flex align="center" className="ml-7" gap="sm" wrap="nowrap"> + <div className="h-1.5 flex-1 rounded-full bg-bg-muted"> + <div + className={`h-full rounded-full transition-all ${isSlow ? 'bg-yellow-500' : 'bg-green-500'}`} + style={{ width: `${Math.max(barWidth, 4)}%` }} + /> + </div> + <Text className={`shrink-0 tabular-nums ${isSlow ? 'text-yellow-500' : ''}`} size="sm" variant="muted"> + {step.durationMs}ms + </Text> + </Flex> + ) : null} + </div> + </TooltipTrigger> + <TooltipContent> + <Text size="sm"> + Step {step.stepIndex + 1}: {step.stepLabel} + {step.durationMs === null ? ' — pending' : ` — ${step.durationMs}ms`} + </Text> + </TooltipContent> + </Tooltip> + ); + })} + </Stack> + ); +} diff --git a/src/renderer/features/test-suite/components/StepTimeline/index.ts b/src/renderer/features/test-suite/components/StepTimeline/index.ts new file mode 100644 index 00000000..a1e035e9 --- /dev/null +++ b/src/renderer/features/test-suite/components/StepTimeline/index.ts @@ -0,0 +1 @@ +export { StepTimeline } from './StepTimeline'; diff --git a/src/renderer/features/test-suite/components/TestSuitePage/TestSuitePage.tsx b/src/renderer/features/test-suite/components/TestSuitePage/TestSuitePage.tsx new file mode 100644 index 00000000..89ff59c6 --- /dev/null +++ b/src/renderer/features/test-suite/components/TestSuitePage/TestSuitePage.tsx @@ -0,0 +1,85 @@ +import { BarChart3, Blocks, FileCode, List, PlayCircle, Camera, Upload } from 'lucide-react'; + +import { PageContent, PageHeader, PageLayout, Text } from '@ui'; + +import { AnalyticsPanel } from '../AnalyticsPanel'; +import { ExportPanel } from '../ExportPanel'; +import { LibraryPanel } from '../LibraryPanel'; +import { RecordingPanel } from '../RecordingPanel'; +import { ResultsPanel } from '../ResultsPanel'; +import { ScreenshotsPanel } from '../ScreenshotsPanel'; +import { SetupCard } from '../SetupCard'; +import { SharedStepsPanel } from '../SharedStepsPanel'; +import { ShortcutHelpDialog } from '../ShortcutHelpDialog'; + +import { useTestSuitePage } from './useTestSuitePage'; + +const TABS = [ + { id: 'recording' as const, label: 'Recording', icon: PlayCircle }, + { id: 'library' as const, label: 'Library', icon: List }, + { id: 'results' as const, label: 'Results', icon: FileCode }, + { id: 'screenshots' as const, label: 'Screenshots', icon: Camera }, + { id: 'analytics' as const, label: 'Analytics', icon: BarChart3 }, + { id: 'shared-steps' as const, label: 'Shared Steps', icon: Blocks }, + { id: 'export' as const, label: 'CI Export', icon: Upload }, +]; + +export function TestSuitePage() { + const vm = useTestSuitePage(); + + if (!vm.projectId) { + return ( + <PageLayout> + <PageHeader> + <PageHeader.Row> + <PageHeader.Title>Test Suite</PageHeader.Title> + </PageHeader.Row> + </PageHeader> + <PageContent> + <Text className="p-6" variant="muted">No project selected.</Text> + </PageContent> + </PageLayout> + ); + } + + if (vm.isLoading) return <PageLayout><Text className="p-6">Loading…</Text></PageLayout>; + if (!vm.config) { + return ( + <PageLayout> + <PageHeader> + <PageHeader.Row> + <PageHeader.Title description="Set up testing for this project">Test Suite</PageHeader.Title> + </PageHeader.Row> + </PageHeader> + <SetupCard projectId={vm.projectId} /> + </PageLayout> + ); + } + + return ( + <PageLayout> + <PageHeader.Tabs value={vm.activeTab} onValueChange={(v) => vm.setActiveTab(v as typeof vm.activeTab)}> + <PageHeader> + <PageHeader.Row> + <PageHeader.Title description="Record, run, and export Playwright tests">Test Suite</PageHeader.Title> + </PageHeader.Row> + <PageHeader.TabList> + {TABS.map((t) => ( + <PageHeader.Tab key={t.id} value={t.id}> + <t.icon className="h-4 w-4" />{t.label} + </PageHeader.Tab> + ))} + </PageHeader.TabList> + </PageHeader> + <PageHeader.TabContent value="recording"><RecordingPanel /></PageHeader.TabContent> + <PageHeader.TabContent value="library"><LibraryPanel /></PageHeader.TabContent> + <PageHeader.TabContent value="results"><ResultsPanel /></PageHeader.TabContent> + <PageHeader.TabContent value="screenshots"><ScreenshotsPanel /></PageHeader.TabContent> + <PageHeader.TabContent value="analytics"><AnalyticsPanel /></PageHeader.TabContent> + <PageHeader.TabContent value="shared-steps"><SharedStepsPanel /></PageHeader.TabContent> + <PageHeader.TabContent value="export"><ExportPanel /></PageHeader.TabContent> + </PageHeader.Tabs> + <ShortcutHelpDialog /> + </PageLayout> + ); +} diff --git a/src/renderer/features/test-suite/components/TestSuitePage/index.ts b/src/renderer/features/test-suite/components/TestSuitePage/index.ts new file mode 100644 index 00000000..1693f5aa --- /dev/null +++ b/src/renderer/features/test-suite/components/TestSuitePage/index.ts @@ -0,0 +1 @@ +export { TestSuitePage } from './TestSuitePage'; diff --git a/src/renderer/features/test-suite/components/TestSuitePage/useTestSuitePage.ts b/src/renderer/features/test-suite/components/TestSuitePage/useTestSuitePage.ts new file mode 100644 index 00000000..45f8eb0a --- /dev/null +++ b/src/renderer/features/test-suite/components/TestSuitePage/useTestSuitePage.ts @@ -0,0 +1,23 @@ +import { useLooseParams } from '@renderer/shared/hooks'; + +import { useTestSuiteConfig } from '../../api/useTestSuiteConfig'; +import { useTestSuiteEvents } from '../../hooks/useTestSuiteEvents'; +import { useTestSuiteShortcuts } from '../../hooks/useTestSuiteShortcuts'; +import { useTestSuiteStore } from '../../test-suite-store'; + +export function useTestSuitePage() { + const { projectId } = useLooseParams(); + const { data: config, isLoading } = useTestSuiteConfig(projectId); + const { activeTab, setActiveTab } = useTestSuiteStore(); + + useTestSuiteShortcuts(); + useTestSuiteEvents(projectId ?? ''); + + return { + projectId, + config, + isLoading, + activeTab, + setActiveTab, + }; +} diff --git a/src/renderer/features/test-suite/components/TrendChart/TrendChart.tsx b/src/renderer/features/test-suite/components/TrendChart/TrendChart.tsx new file mode 100644 index 00000000..7af64ce4 --- /dev/null +++ b/src/renderer/features/test-suite/components/TrendChart/TrendChart.tsx @@ -0,0 +1,111 @@ +import type { TrendPointSchema } from '@shared/ipc/test-suite'; + +import { Flex, Text } from '@ui'; + +import type { z } from 'zod'; + +type TrendPointType = z.infer<typeof TrendPointSchema>; + +interface TrendChartProps { + data: TrendPointType[]; + width?: number; + height?: number; +} + +export function TrendChart({ data, width = 500, height = 200 }: TrendChartProps) { + if (data.length === 0) { + return ( + <Flex align="center" className="h-48" justify="center"> + <Text size="sm" variant="muted">No trend data yet</Text> + </Flex> + ); + } + + const padding = { top: 20, right: 20, bottom: 30, left: 40 }; + const chartW = width - padding.left - padding.right; + const chartH = height - padding.top - padding.bottom; + + const maxTotal = Math.max(...data.map((d) => d.total), 1); + const xStep = data.length > 1 ? chartW / (data.length - 1) : chartW; + + const toY = (val: number) => chartH - (val / maxTotal) * chartH; + const toX = (i: number) => (data.length > 1 ? i * xStep : chartW / 2); + + const passedPath = data + .map((d, i) => `${i === 0 ? 'M' : 'L'}${toX(i)},${toY(d.passed)}`) + .join(' '); + + const failedPath = data + .map((d, i) => `${i === 0 ? 'M' : 'L'}${toX(i)},${toY(d.failed)}`) + .join(' '); + + const yTicks = [0, Math.round(maxTotal / 2), maxTotal]; + + return ( + <svg className="w-full" viewBox={`0 0 ${width} ${height}`}> + <g transform={`translate(${padding.left},${padding.top})`}> + {/* Grid lines */} + {yTicks.map((tick) => ( + <g key={tick}> + <line + className="stroke-border" + strokeDasharray="4 4" + x1={0} + x2={chartW} + y1={toY(tick)} + y2={toY(tick)} + /> + <text + className="fill-text-muted text-[10px]" + dominantBaseline="middle" + textAnchor="end" + x={-8} + y={toY(tick)} + > + {tick} + </text> + </g> + ))} + + {/* Passed line */} + <path className="stroke-green-500" d={passedPath} fill="none" strokeWidth={2} /> + + {/* Failed line */} + <path className="stroke-destructive" d={failedPath} fill="none" strokeWidth={2} /> + + {/* X-axis labels */} + {data.map((d, i) => { + if (data.length > 14 && i % Math.ceil(data.length / 7) !== 0) return null; + return ( + <text + key={d.date} + className="fill-text-muted text-[10px]" + dominantBaseline="hanging" + textAnchor="middle" + x={toX(i)} + y={chartH + 8} + > + {d.date.slice(5)} + </text> + ); + })} + + {/* Dots */} + {data.map((d, i) => ( + <g key={d.date}> + <circle className="fill-green-500" cx={toX(i)} cy={toY(d.passed)} r={3} /> + <circle className="fill-destructive" cx={toX(i)} cy={toY(d.failed)} r={3} /> + </g> + ))} + </g> + + {/* Legend */} + <g transform={`translate(${padding.left},${height - 8})`}> + <circle className="fill-green-500" cx={0} cy={0} r={4} /> + <text className="fill-text-muted text-[10px]" dominantBaseline="middle" x={8} y={0}>Passed</text> + <circle className="fill-destructive" cx={70} cy={0} r={4} /> + <text className="fill-text-muted text-[10px]" dominantBaseline="middle" x={78} y={0}>Failed</text> + </g> + </svg> + ); +} diff --git a/src/renderer/features/test-suite/components/TrendChart/index.ts b/src/renderer/features/test-suite/components/TrendChart/index.ts new file mode 100644 index 00000000..01360c72 --- /dev/null +++ b/src/renderer/features/test-suite/components/TrendChart/index.ts @@ -0,0 +1 @@ +export { TrendChart } from './TrendChart'; diff --git a/src/renderer/features/test-suite/hooks/useBrowserViewBounds.ts b/src/renderer/features/test-suite/hooks/useBrowserViewBounds.ts new file mode 100644 index 00000000..329ce3bd --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useBrowserViewBounds.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react'; + +import { TEST_SUITE } from '@shared/ipc/test-suite/channels'; + +import { ipc } from '@renderer/shared/lib/ipc'; + +/** + * Manages the full BrowserView lifecycle for the test-suite recorder. + * + * - **active=true + url**: CREATEs the BrowserView at the element's bounds, + * then keeps bounds in sync via ResizeObserver + window resize. + * - **active=false**: DESTROYs the BrowserView. + * + * This hook owns CREATE / SET-BOUNDS / DESTROY so the parent component + * only needs to toggle `active` and provide the target `url`. + */ +export function useBrowserViewBounds(active: boolean, url: string) { + const ref = useRef<HTMLDivElement>(null); + const created = useRef(false); + + useEffect(() => { + if (!active || !url || !ref.current) return; + const el = ref.current; + + const getBounds = () => { + const r = el.getBoundingClientRect(); + return { + x: Math.round(r.left), + y: Math.round(r.top), + width: Math.round(r.width), + height: Math.round(r.height), + }; + }; + + // First call: CREATE the view at the correct position + const bounds = getBounds(); + if (bounds.width > 0 && bounds.height > 0) { + void ipc(TEST_SUITE['BROWSER-VIEW'].CREATE, { url, bounds }); + created.current = true; + } + + // Subsequent calls: reposition only + const post = () => { + const b = getBounds(); + if (b.width <= 0 || b.height <= 0) return; + void ipc(TEST_SUITE['BROWSER-VIEW']['SET-BOUNDS'], b); + }; + + const ro = new ResizeObserver(post); + ro.observe(el); + window.addEventListener('resize', post); + + return () => { + ro.disconnect(); + window.removeEventListener('resize', post); + if (created.current) { + void ipc(TEST_SUITE['BROWSER-VIEW'].DESTROY, {}); + created.current = false; + } + }; + }, [active, url]); + + return ref; +} diff --git a/src/renderer/features/test-suite/hooks/useDisplayLines.ts b/src/renderer/features/test-suite/hooks/useDisplayLines.ts new file mode 100644 index 00000000..bcb1076e --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useDisplayLines.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; + +import { useRun } from '../api/useRuns'; + +import { useRunOutput } from './useRunOutput'; + +export interface DisplayLine { + line: string; + timestamp: string; +} + +export function useDisplayLines(runId: string | null) { + const { lines: liveLines } = useRunOutput(runId); + const { data: runRecord } = useRun(runId); + + const displayLines = useMemo((): DisplayLine[] => { + if (liveLines.length > 0) return liveLines; + if (runRecord?.outputLines && runRecord.outputLines.length > 0) { + return runRecord.outputLines.map((line, i) => ({ + line, + timestamp: `stored-${i}`, + })); + } + return []; + }, [liveLines, runRecord?.outputLines]); + + return displayLines; +} diff --git a/src/renderer/features/test-suite/hooks/useDisplaySteps.ts b/src/renderer/features/test-suite/hooks/useDisplaySteps.ts new file mode 100644 index 00000000..c785c7a8 --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useDisplaySteps.ts @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; + +import type { TestSuiteStep } from '@shared/types/test-suite'; + +import { useRun } from '../api/useRuns'; +import { describeStep } from '../lib/describe-step'; + +import { useRunSteps } from './useRunSteps'; + +import type { RunStep } from './useRunSteps'; + +function getStepStatus(index: number, failStart: number, stepsFailed: number): 'passed' | 'failed' { + return index >= failStart && stepsFailed > 0 ? 'failed' : 'passed'; +} + +export function useDisplaySteps( + runId: string | null, + scriptSteps: TestSuiteStep[] | undefined, +): RunStep[] { + const { steps: liveRunSteps } = useRunSteps(runId); + const { data: runRecord } = useRun(runId); + + const runStatus = runRecord?.status; + const runDurationMs = runRecord?.durationMs ?? 0; + const stepsFailed = runRecord?.stepsFailed ?? 0; + const totalSteps = scriptSteps?.length ?? 1; + + return useMemo((): RunStep[] => { + if (liveRunSteps.length > 0) return liveRunSteps; + if (scriptSteps) { + const isComplete = runStatus === 'passed' || runStatus === 'failed'; + const failStart = totalSteps - stepsFailed; + return scriptSteps.map((step, i) => ({ + stepIndex: i, + stepType: step.type, + stepLabel: describeStep(step), + timestamp: runRecord?.startedAt ?? '', + durationMs: isComplete ? Math.round(runDurationMs / totalSteps) : null, + status: isComplete ? getStepStatus(i, failStart, stepsFailed) : undefined, + })); + } + return []; + }, [liveRunSteps, runDurationMs, runRecord?.startedAt, runStatus, scriptSteps, stepsFailed, totalSteps]); +} diff --git a/src/renderer/features/test-suite/hooks/useFormWithReset.ts b/src/renderer/features/test-suite/hooks/useFormWithReset.ts new file mode 100644 index 00000000..53c841c7 --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useFormWithReset.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +export function useFormWithReset<T>(defaults: T, open: boolean, deps: unknown[] = []) { + const [values, setValues] = useState(defaults); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (open) { + setValues(defaults); + setError(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, ...deps]); + + const update = <K extends keyof T>(key: K, val: T[K]) => + setValues((v) => ({ ...v, [key]: val })); + + return { values, setValues, error, setError, update }; +} diff --git a/src/renderer/features/test-suite/hooks/useLibraryFilters.ts b/src/renderer/features/test-suite/hooks/useLibraryFilters.ts new file mode 100644 index 00000000..d9f6a77a --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useLibraryFilters.ts @@ -0,0 +1,149 @@ +import { useMemo, useState } from 'react'; + +import type { QaScriptSchema } from '@shared/ipc/test-suite'; + +import type { StatusFilter } from '../test-suite-store'; +import type { z } from 'zod'; + +type QaScript = z.infer<typeof QaScriptSchema>; + +interface AllRun { + scriptId: string; + startedAt: string; + status: string; +} + +interface UseLibraryFiltersInput { + allRuns: AllRun[]; + flakySet: Set<string>; + scripts: QaScript[]; + statusFilter: StatusFilter; +} + +export function useLibraryFilters({ + allRuns, + flakySet, + scripts, + statusFilter, +}: UseLibraryFiltersInput) { + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState<Set<string>>(new Set()); + const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set()); + + const allTags = useMemo(() => { + const tagSet = new Set<string>(); + for (const s of scripts) { + for (const t of s.tags) tagSet.add(t); + } + return Array.from(tagSet).sort(); + }, [scripts]); + + const lastStatusByScript = useMemo(() => { + const latestStartByScript = new Map<string, string>(); + const statusMap = new Map<string, string>(); + for (const run of allRuns) { + const prevStart = latestStartByScript.get(run.scriptId); + if (!prevStart || run.startedAt > prevStart) { + latestStartByScript.set(run.scriptId, run.startedAt); + statusMap.set(run.scriptId, run.status); + } + } + return statusMap; + }, [allRuns]); + + const getLastStatus = (scriptId: string): string | undefined => + lastStatusByScript.get(scriptId); + + const matchesSearch = (name: string) => + name.toLowerCase().includes(search.toLowerCase()); + + const statusCounts = useMemo( + () => ({ + all: scripts.filter((s) => matchesSearch(s.name)).length, + passed: scripts.filter( + (s) => matchesSearch(s.name) && getLastStatus(s.id) === 'passed', + ).length, + failed: scripts.filter( + (s) => matchesSearch(s.name) && getLastStatus(s.id) === 'failed', + ).length, + flaky: scripts.filter( + (s) => matchesSearch(s.name) && flakySet.has(s.id), + ).length, + 'no-runs': scripts.filter( + (s) => matchesSearch(s.name) && !getLastStatus(s.id), + ).length, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [scripts, search, flakySet, lastStatusByScript], + ); + + const filtered = useMemo( + () => + scripts.filter((s) => { + if (!matchesSearch(s.name)) return false; + if (statusFilter !== 'all') { + if (statusFilter === 'flaky' && !flakySet.has(s.id)) return false; + else if (statusFilter === 'no-runs' && getLastStatus(s.id)) + return false; + else if ( + statusFilter !== 'flaky' && + statusFilter !== 'no-runs' && + getLastStatus(s.id) !== statusFilter + ) + return false; + } + if (selectedTags.size > 0) { + const scriptTags = new Set(s.tags); + for (const t of selectedTags) { + if (!scriptTags.has(t)) return false; + } + } + return true; + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [scripts, search, statusFilter, flakySet, lastStatusByScript, selectedTags], + ); + + const toggleSelect = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleAll = () => { + if (selected.size === filtered.length) setSelected(new Set()); + else setSelected(new Set(filtered.map((s) => s.id))); + }; + + const toggleTag = (tag: string) => { + setSelectedTags((prev) => { + const next = new Set(prev); + if (next.has(tag)) next.delete(tag); + else next.add(tag); + return next; + }); + }; + + const clearTags = () => setSelectedTags(new Set()); + + const formatDate = (dateStr: string) => + new Date(dateStr).toLocaleDateString(); + + return { + allTags, + clearTags, + filtered, + formatDate, + search, + selected, + selectedTags, + setSearch, + statusCounts, + toggleAll, + toggleSelect, + toggleTag, + } as const; +} diff --git a/src/renderer/features/test-suite/hooks/useMutationWithDialogClose.ts b/src/renderer/features/test-suite/hooks/useMutationWithDialogClose.ts new file mode 100644 index 00000000..5b92f775 --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useMutationWithDialogClose.ts @@ -0,0 +1,23 @@ +import { useCallback } from 'react'; + +import type { UseMutationResult } from '@tanstack/react-query'; + +export function useMutationWithDialogClose<TData, TPayload>( + mutation: UseMutationResult<TData, Error, TPayload>, + onClose: () => void, + resetState?: () => void, +) { + const handleMutate = useCallback( + (payload: TPayload) => { + mutation.mutate(payload, { + onSuccess: () => { + resetState?.(); + onClose(); + }, + }); + }, + [mutation, onClose, resetState], + ); + + return { handleMutate, isPending: mutation.isPending }; +} diff --git a/src/renderer/features/test-suite/hooks/useRunCompletionToast.ts b/src/renderer/features/test-suite/hooks/useRunCompletionToast.ts new file mode 100644 index 00000000..967fe518 --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useRunCompletionToast.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react'; + +import { useToastStore } from '@renderer/shared/stores'; + +import { formatDuration } from '../lib/format'; + +export function useRunCompletionToast( + status: string | undefined, + scriptName: string | undefined, + stepsFailed: number | undefined, + durationMs: number | undefined, +) { + const addToast = useToastStore((s) => s.addToast); + const prevStatusRef = useRef<string | undefined>(undefined); + + useEffect(() => { + if (!status || !scriptName) return; + const prevStatus = prevStatusRef.current; + prevStatusRef.current = status; + + if (prevStatus === 'running' && status === 'passed') { + addToast( + `${scriptName} — All tests passed (${formatDuration(durationMs ?? 0)})`, + 'success', + ); + } else if (prevStatus === 'running' && status === 'failed') { + addToast( + `${scriptName} — ${stepsFailed ?? 0} step(s) failed`, + 'error', + ); + } + }, [status, scriptName, stepsFailed, durationMs, addToast]); +} diff --git a/src/renderer/features/test-suite/hooks/useRunOutput.ts b/src/renderer/features/test-suite/hooks/useRunOutput.ts new file mode 100644 index 00000000..e1565851 --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useRunOutput.ts @@ -0,0 +1,34 @@ +import { useState } from 'react'; + +import { TEST_SUITE_EVENTS } from '@shared/ipc/test-suite/channels'; + +import { useIpcEvent } from '@renderer/shared/hooks'; + +import type { StreamOutputLine } from '../lib/types'; + +interface OutputState { + forRunId: string | null; + lines: StreamOutputLine[]; +} + +export function useRunOutput(runId: string | null) { + const [state, setState] = useState<OutputState>({ forRunId: runId, lines: [] }); + + // Derive lines synchronously — returns [] when runId changes, no effect needed + const lines = state.forRunId === runId ? state.lines : []; + + useIpcEvent(TEST_SUITE_EVENTS.OUTPUT.LINE, (payload) => { + if (payload.runId === runId) { + setState((prev) => ({ + forRunId: runId, + lines: [ + ...(prev.forRunId === runId ? prev.lines : []), + { line: payload.line, timestamp: payload.timestamp }, + ], + })); + } + }); + + const clear = () => setState({ forRunId: runId, lines: [] }); + return { lines, clear }; +} diff --git a/src/renderer/features/test-suite/hooks/useRunSteps.ts b/src/renderer/features/test-suite/hooks/useRunSteps.ts new file mode 100644 index 00000000..0aa42a07 --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useRunSteps.ts @@ -0,0 +1,55 @@ +import { useState } from 'react'; + +import { TEST_SUITE_EVENTS } from '@shared/ipc/test-suite/channels'; + +import { useIpcEvent } from '@renderer/shared/hooks'; + +export interface RunStep { + stepIndex: number; + stepType: string; + stepLabel: string; + timestamp: string; + durationMs: number | null; + status?: 'passed' | 'failed'; +} + +interface StepsState { + forRunId: string | null; + steps: RunStep[]; +} + +export function useRunSteps(runId: string | null) { + const [state, setState] = useState<StepsState>({ forRunId: runId, steps: [] }); + + const steps = state.forRunId === runId ? state.steps : []; + + useIpcEvent(TEST_SUITE_EVENTS.RUN.STEP, (payload) => { + if (payload.runId !== runId) return; + + setState((prev) => { + const existing = prev.forRunId === runId ? [...prev.steps] : []; + + // Mark previous step as passed (it completed without error) + const last = existing.at(-1); + if (last) { + const prevTime = new Date(last.timestamp).getTime(); + const currTime = new Date(payload.timestamp).getTime(); + existing[existing.length - 1] = { ...last, durationMs: currTime - prevTime, status: 'passed' }; + } + + existing.push({ + stepIndex: payload.stepIndex, + stepType: payload.stepType, + stepLabel: payload.stepLabel, + timestamp: payload.timestamp, + durationMs: null, + status: undefined, + }); + + return { forRunId: runId, steps: existing }; + }); + }); + + const clear = () => setState({ forRunId: runId, steps: [] }); + return { steps, clear }; +} diff --git a/src/renderer/features/test-suite/hooks/useTestSuiteEvents.ts b/src/renderer/features/test-suite/hooks/useTestSuiteEvents.ts new file mode 100644 index 00000000..0c9f357a --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useTestSuiteEvents.ts @@ -0,0 +1,19 @@ +import { useQueryClient } from '@tanstack/react-query'; + +import { TEST_SUITE_EVENTS } from '@shared/ipc/test-suite/channels'; + +import { useIpcEvent } from '@renderer/shared/hooks'; + +import { testSuiteKeys } from '../api/testSuiteKeys'; + +export function useTestSuiteEvents(projectId: string) { + const queryClient = useQueryClient(); + + useIpcEvent(TEST_SUITE_EVENTS.CONFIG.CHANGED, () => { + void queryClient.invalidateQueries({ queryKey: testSuiteKeys.config(projectId) }); + }); + + useIpcEvent(TEST_SUITE_EVENTS.RUN.COMPLETED, () => { + void queryClient.invalidateQueries({ queryKey: testSuiteKeys.all }); + }); +} diff --git a/src/renderer/features/test-suite/hooks/useTestSuiteShortcuts.ts b/src/renderer/features/test-suite/hooks/useTestSuiteShortcuts.ts new file mode 100644 index 00000000..c039a730 --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useTestSuiteShortcuts.ts @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; + +import { useTestSuiteStore } from '../test-suite-store'; + +export function useTestSuiteShortcuts() { + const setActiveTab = useTestSuiteStore((s) => s.setActiveTab); + const setShortcutHelpOpen = useTestSuiteStore((s) => s.setShortcutHelpOpen); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + + if (e.key === '?') { + e.preventDefault(); + setShortcutHelpOpen(true); + return; + } + + if (e.key === 'Escape') { + setShortcutHelpOpen(false); + return; + } + + if (e.altKey) { + switch (e.key) { + case '1': + e.preventDefault(); + setActiveTab('recording'); + break; + case '2': + e.preventDefault(); + setActiveTab('library'); + break; + case '3': + e.preventDefault(); + setActiveTab('results'); + break; + case '4': + e.preventDefault(); + setActiveTab('screenshots'); + break; + case '5': + e.preventDefault(); + setActiveTab('analytics'); + break; + case '6': + e.preventDefault(); + setActiveTab('export'); + break; + case 's': + e.preventDefault(); + setActiveTab('library'); + setTimeout(() => { + const searchInput = document.querySelector<HTMLInputElement>( + '[data-testid="test-suite-search"]', + ); + searchInput?.focus(); + }, 50); + break; + } + } + } + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [setActiveTab, setShortcutHelpOpen]); +} diff --git a/src/renderer/features/test-suite/hooks/useUrlValidation.ts b/src/renderer/features/test-suite/hooks/useUrlValidation.ts new file mode 100644 index 00000000..c5f59db8 --- /dev/null +++ b/src/renderer/features/test-suite/hooks/useUrlValidation.ts @@ -0,0 +1,18 @@ +import { useCallback, useState } from 'react'; + +export function useUrlValidation() { + const [urlError, setUrlError] = useState<string | null>(null); + + const validate = useCallback((url: string): boolean => { + try { + new URL(url); + setUrlError(null); + return true; + } catch { + setUrlError('Enter a valid URL (e.g. http://localhost:3000)'); + return false; + } + }, []); + + return { urlError, setUrlError, validate }; +} diff --git a/src/renderer/features/test-suite/index.ts b/src/renderer/features/test-suite/index.ts new file mode 100644 index 00000000..5dda0fd1 --- /dev/null +++ b/src/renderer/features/test-suite/index.ts @@ -0,0 +1,11 @@ +export { TestSuitePage } from './components/TestSuitePage'; +export { useTestSuiteStore } from './test-suite-store'; +export { + useTestSuiteScripts, + useRuns, + useTestSuiteScreenshots, + useTestSuiteConfig, +} from './api'; +export { testSuiteKeys } from './api/testSuiteKeys'; +export { useTestSuiteEvents } from './hooks/useTestSuiteEvents'; +export { TestSuitePage as default } from './components/TestSuitePage'; diff --git a/src/renderer/features/test-suite/lib/assertion-suggestions.ts b/src/renderer/features/test-suite/lib/assertion-suggestions.ts new file mode 100644 index 00000000..ac0a22a5 --- /dev/null +++ b/src/renderer/features/test-suite/lib/assertion-suggestions.ts @@ -0,0 +1,63 @@ +import type { AssertMethod } from '@shared/types/test-suite'; + +import type { RecordedStep } from '../test-suite-store'; + +export interface AssertionSuggestion { + selector: string; + expected: string; + assertMethod: AssertMethod; + attribute?: string; + description: string; + accepted: boolean; +} + +export function generateAssertionSuggestions(steps: RecordedStep[]): AssertionSuggestion[] { + const suggestions: AssertionSuggestion[] = []; + + for (const { step } of steps) { + if (step.type === 'navigate') { + suggestions.push({ + selector: '', + expected: step.url, + assertMethod: 'toHaveURL', + description: `Verify page navigated to ${step.url}`, + accepted: false, + }); + } + + if (step.type === 'fill' && 'selector' in step) { + suggestions.push({ + selector: step.selector, + expected: step.value, + assertMethod: 'toHaveText', + description: `Verify "${step.selector}" has value "${step.value}"`, + accepted: false, + }); + } + + if (step.type === 'click' && 'context' in step && step.context?.text) { + suggestions.push({ + selector: step.selector, + expected: step.context.text, + assertMethod: 'toBeVisible', + description: `Verify "${step.context.text}" is visible after click`, + accepted: false, + }); + } + } + + // After form submission (click following fills), suggest checking for new content + const hasClicks = steps.some((s) => s.step.type === 'click'); + const hasFills = steps.some((s) => s.step.type === 'fill'); + if (hasClicks && hasFills) { + suggestions.push({ + selector: 'table tbody tr', + expected: '1', + assertMethod: 'toHaveCount', + description: 'Verify table row count after form submission', + accepted: false, + }); + } + + return suggestions; +} diff --git a/src/renderer/features/test-suite/lib/constants.ts b/src/renderer/features/test-suite/lib/constants.ts new file mode 100644 index 00000000..c5a25ac5 --- /dev/null +++ b/src/renderer/features/test-suite/lib/constants.ts @@ -0,0 +1,65 @@ +import type { TestSuiteConfig } from '@shared/ipc/test-suite'; + +type ScreenshotMode = TestSuiteConfig['screenshotMode']; + +export const SCREENSHOT_MODES: ReadonlyArray<{ value: ScreenshotMode; label: string }> = [ + { value: 'smart', label: 'Smart (recommended)' }, + { value: 'per-click', label: 'Per click' }, + { value: 'per-nav', label: 'Per navigation' }, + { value: 'per-form', label: 'Per form' }, + { value: 'per-assertion', label: 'Per assertion' }, + { value: 'manual', label: 'Manual' }, +] as const; + +export const STATUS_FILTERS = ['all', 'passed', 'failed', 'flaky', 'no-runs'] as const; +export type StatusFilter = (typeof STATUS_FILTERS)[number]; + +export const TAB = { + RECORDING: 'recording', + LIBRARY: 'library', + RESULTS: 'results', + SCREENSHOTS: 'screenshots', + EXPORT: 'export', + ANALYTICS: 'analytics', + SHARED_STEPS: 'shared-steps', +} as const; + +// ── Default timeouts (ms) ── +export const DEFAULT_NAVIGATION_TIMEOUT = 30_000; +export const DEFAULT_ACTION_TIMEOUT = 10_000; + +// ── Default viewport ── +export const DEFAULT_VIEWPORT_WIDTH = 1280; +export const DEFAULT_VIEWPORT_HEIGHT = 720; +export const MIN_VIEWPORT_WIDTH = 320; +export const MIN_VIEWPORT_HEIGHT = 240; + +// ── Playwright config ── +export const MAX_WORKERS = 16; +export const MAX_RETRIES = 5; + +// ── Analytics thresholds ── +export const GRADE_THRESHOLDS = [ + { min: 90, grade: 'A', color: 'text-green-500' }, + { min: 80, grade: 'B', color: 'text-blue-500' }, + { min: 70, grade: 'C', color: 'text-yellow-500' }, + { min: 60, grade: 'D', color: 'text-orange-500' }, + { min: 0, grade: 'F', color: 'text-destructive' }, +] as const; + +export const HEALTH_WEIGHTS = { + passRate: 40, + stability: 30, + speed: 30, +} as const; + +export const SPEED_THRESHOLDS = { + fast: 5, + medium: 30, + curve: 25, +} as const; + +// ── UI constants ── +export const COPY_FEEDBACK_MS = 2_000; +export const ERROR_LINE_TRUNCATION = 20; +export const SPARKLINE_RUN_LIMIT = 10; diff --git a/src/renderer/features/test-suite/lib/describe-step.ts b/src/renderer/features/test-suite/lib/describe-step.ts new file mode 100644 index 00000000..6cadb12c --- /dev/null +++ b/src/renderer/features/test-suite/lib/describe-step.ts @@ -0,0 +1,60 @@ +import type { TestSuiteStep } from '@shared/types/test-suite'; + +export function cleanSelector(selector: string): string { + // data-testid + const testidMatch = /\[data-testid="([^"]+)"\]/.exec(selector); + if (testidMatch) return testidMatch[1]; + // id + if (selector.startsWith('#')) return selector.slice(1).replaceAll(/\\./g, '.'); + // last segment of CSS path + const parts = selector.split(' > '); + let last = parts.at(-1) ?? selector; + last = last.replace(/:nth-of-type\(\d+\)/, ''); + return last.trim(); +} + +export function describeStep(step: TestSuiteStep): string { + switch (step.type) { + case 'navigate': + return `Navigate to ${step.url}`; + case 'click': { + const name = step.context?.label ?? step.context?.text ?? cleanSelector(step.selector); + const tag = step.context?.tagName ?? 'element'; + return `Click "${name}" ${tag}`; + } + case 'fill': { + const name = step.context?.label ?? step.context?.placeholder ?? cleanSelector(step.selector); + return `Fill "${name}" with "${step.value.slice(0, 40)}"`; + } + case 'select': { + const name = step.context?.label ?? cleanSelector(step.selector); + return `Select "${step.value}" in "${name}"`; + } + case 'press': + return `Press ${step.key}`; + case 'wait': + return `Wait ${step.ms}ms`; + case 'assert': { + const sel = cleanSelector(step.selector); + switch (step.assertMethod ?? 'toHaveText') { + case 'toBeVisible': + return `Assert ${sel} is visible`; + case 'toBeHidden': + return `Assert ${sel} is hidden`; + case 'toContainText': + return `Assert ${sel} contains "${step.expected}"`; + case 'toHaveCount': + return `Assert ${sel} count = ${step.expected}`; + case 'toHaveAttribute': + return `Assert ${sel} [${step.attribute ?? ''}] = "${step.expected}"`; + case 'toHaveURL': + return `Assert URL = "${step.expected}"`; + case 'toHaveTitle': + return `Assert title = "${step.expected}"`; + case 'toHaveText': + default: + return `Assert ${sel} = "${step.expected}"`; + } + } + } +} diff --git a/src/renderer/features/test-suite/lib/format.ts b/src/renderer/features/test-suite/lib/format.ts new file mode 100644 index 00000000..37e82534 --- /dev/null +++ b/src/renderer/features/test-suite/lib/format.ts @@ -0,0 +1,95 @@ +/** + * Shared formatting utilities for the test-suite feature. + */ + +import type { RunRecord, StreamOutputLine } from './types'; + +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const secs = (ms / 1000).toFixed(1); + return `${secs}s`; +} + +export function getOutputLineClass(line: string): string { + if (line.includes('\u2713') || line.includes('passed')) return 'text-green-500'; + if (line.includes('\u2717') || line.includes('Error') || line.includes('error')) + return 'text-destructive'; + return 'text-muted-foreground'; +} + +export function buildSummaryMarkdown( + runRecord: RunRecord, + scriptName: string | undefined, + displayLines: StreamOutputLine[], +): string { + const totalSteps = (runRecord.stepsPassed ?? 0) + (runRecord.stepsFailed ?? 0); + const durationSec = ((runRecord.durationMs ?? 0) / 1000).toFixed(1); + const startDate = runRecord.startedAt ? new Date(runRecord.startedAt).toLocaleString() : 'N/A'; + + const lines: string[] = [ + `## ${scriptName ?? 'Test Run'} — ${runRecord.status.toUpperCase()}`, + '', + `| Metric | Value |`, + `|--------|-------|`, + `| Status | **${runRecord.status}** |`, + `| Duration | ${durationSec}s |`, + `| Steps Passed | ${runRecord.stepsPassed ?? 0} / ${totalSteps} |`, + `| Steps Failed | ${runRecord.stepsFailed ?? 0} / ${totalSteps} |`, + `| Started | ${startDate} |`, + ]; + + if (runRecord.triggeredBy) { + lines.push(`| Triggered By | ${runRecord.triggeredBy} |`); + } + + if (runRecord.error) { + lines.push('', '### Error', '', '```', runRecord.error, '```'); + } + + const errorLines = displayLines + .map((l) => l.line) + .filter((l) => l.includes('Error') || l.includes('\u2717') || l.includes('FAIL')); + + if (errorLines.length > 0 && !runRecord.error) { + lines.push('', '### Failures', '', '```'); + for (const line of errorLines.slice(0, 20)) { + lines.push(line); + } + if (errorLines.length > 20) { + lines.push(`... and ${errorLines.length - 20} more`); + } + lines.push('```'); + } + + return lines.join('\n'); +} + +export function buildJsonOutput(runRecord: RunRecord, displayLines: StreamOutputLine[]): string { + return JSON.stringify( + { + status: runRecord.status, + stepsPassed: runRecord.stepsPassed, + stepsFailed: runRecord.stepsFailed, + durationMs: runRecord.durationMs, + startedAt: runRecord.startedAt, + completedAt: runRecord.completedAt, + triggeredBy: runRecord.triggeredBy, + error: runRecord.error ?? null, + outputLines: displayLines.map((l) => l.line), + }, + null, + 2, + ); +} + +export function getCopyText( + view: string, + runRecord: RunRecord | null | undefined, + displayLines: StreamOutputLine[], + scriptName?: string, +): string { + if (!runRecord) return ''; + if (view === 'json') return buildJsonOutput(runRecord, displayLines); + if (view === 'summary') return buildSummaryMarkdown(runRecord, scriptName, displayLines); + return displayLines.map((l) => l.line).join('\n'); +} diff --git a/src/renderer/features/test-suite/lib/generate-spec-preview.ts b/src/renderer/features/test-suite/lib/generate-spec-preview.ts new file mode 100644 index 00000000..0cda716a --- /dev/null +++ b/src/renderer/features/test-suite/lib/generate-spec-preview.ts @@ -0,0 +1,75 @@ +import type { TestSuiteStep } from '@shared/types/test-suite'; + +function escape(s: string): string { + return s.replaceAll('\\', '\\\\').replaceAll("'", "\\'"); +} + +export function generateSpecPreview(params: { + name: string; + steps: TestSuiteStep[]; + baseUrl?: string; +}): string { + const lines: string[] = [ + "import { test, expect } from '@playwright/test';", + '', + `test('${escape(params.name)}', async ({ page }) => {`, + ]; + + for (const s of params.steps) { + switch (s.type) { + case 'navigate': + lines.push(` await page.goto('${escape(s.url)}');`); + break; + case 'click': + lines.push(` await page.locator('${escape(s.selector)}').click();`); + break; + case 'fill': + lines.push(` await page.locator('${escape(s.selector)}').fill('${escape(s.value)}');`); + break; + case 'select': + lines.push(` await page.locator('${escape(s.selector)}').selectOption('${escape(s.value)}');`); + break; + case 'press': + lines.push(` await page.keyboard.press('${escape(s.key)}');`); + break; + case 'wait': + lines.push(` await page.waitForTimeout(${s.ms});`); + break; + case 'assert': { + const method = s.assertMethod ?? 'toHaveText'; + const loc = `page.locator('${escape(s.selector)}')`; + switch (method) { + case 'toBeVisible': + lines.push(` await expect(${loc}).toBeVisible();`); + break; + case 'toBeHidden': + lines.push(` await expect(${loc}).toBeHidden();`); + break; + case 'toContainText': + lines.push(` await expect(${loc}).toContainText('${escape(s.expected)}');`); + break; + case 'toHaveCount': + lines.push(` await expect(${loc}).toHaveCount(${Number.isNaN(parseInt(s.expected, 10)) ? 0 : parseInt(s.expected, 10)});`); + break; + case 'toHaveAttribute': + lines.push(` await expect(${loc}).toHaveAttribute('${escape(s.attribute ?? '')}', '${escape(s.expected)}');`); + break; + case 'toHaveURL': + lines.push(` await expect(page).toHaveURL('${escape(s.expected)}');`); + break; + case 'toHaveTitle': + lines.push(` await expect(page).toHaveTitle('${escape(s.expected)}');`); + break; + case 'toHaveText': + default: + lines.push(` await expect(${loc}).toHaveText('${escape(s.expected)}');`); + break; + } + break; + } + } + } + + lines.push('});', ''); + return lines.join('\n'); +} diff --git a/src/renderer/features/test-suite/lib/health-score.ts b/src/renderer/features/test-suite/lib/health-score.ts new file mode 100644 index 00000000..7f5f8394 --- /dev/null +++ b/src/renderer/features/test-suite/lib/health-score.ts @@ -0,0 +1,39 @@ +import { GRADE_THRESHOLDS, HEALTH_WEIGHTS, SPEED_THRESHOLDS } from './constants'; + +export interface HealthScore { + score: number; + grade: string; + color: string; + breakdown: { passRate: number; stability: number; speed: number }; +} + +export function computeHealthScore(summary: { + passRate: number; + avgDurationMs: number; + flakyCount: number; + totalScripts: number; +}): HealthScore { + const passRate = Math.min(HEALTH_WEIGHTS.passRate, Math.round(summary.passRate * 0.4)); + const flakyRatio = summary.totalScripts > 0 ? summary.flakyCount / summary.totalScripts : 0; + const stability = Math.max(0, Math.round(HEALTH_WEIGHTS.stability * (1 - flakyRatio))); + const avgSec = summary.avgDurationMs / 1000; + const speed = speedFromAvgSec(avgSec); + const score = passRate + stability + speed; + // GRADE_THRESHOLDS always has a { min: 0 } fallback entry so `find` never returns undefined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const threshold = GRADE_THRESHOLDS.find((t) => score >= t.min)!; + return { + score, + grade: threshold.grade, + color: threshold.color, + breakdown: { passRate, stability, speed }, + }; +} + +function speedFromAvgSec(avgSec: number): number { + if (avgSec <= SPEED_THRESHOLDS.fast) return HEALTH_WEIGHTS.speed; + if (avgSec >= SPEED_THRESHOLDS.medium) return 0; + return Math.round( + HEALTH_WEIGHTS.speed * (1 - (avgSec - SPEED_THRESHOLDS.fast) / SPEED_THRESHOLDS.curve), + ); +} diff --git a/src/renderer/features/test-suite/lib/run-description.ts b/src/renderer/features/test-suite/lib/run-description.ts new file mode 100644 index 00000000..6418ffdf --- /dev/null +++ b/src/renderer/features/test-suite/lib/run-description.ts @@ -0,0 +1,45 @@ +import { formatDuration } from './format'; + +import type { RunRecord } from './types'; + +export function buildDefaultDescription( + scriptName: string, + runRecord: RunRecord, +): string { + const errorLines = (runRecord.outputLines ?? []) + .filter((l) => l.includes('Error') || l.includes('\u2717') || l.includes('FAIL')) + .slice(0, 30); + + let errorSection = 'Test failed — see run output for details'; + if (runRecord.error) { + errorSection = runRecord.error; + } else if (errorLines.length > 0) { + errorSection = errorLines.join('\n'); + } + + return [ + `## Test Failure Report`, + '', + `**Script:** ${scriptName}`, + `**Status:** ${runRecord.status}`, + `**Steps Passed:** ${runRecord.stepsPassed ?? 0}`, + `**Steps Failed:** ${runRecord.stepsFailed ?? 0}`, + `**Duration:** ${formatDuration(runRecord.durationMs ?? 0)}`, + `**Run Date:** ${runRecord.startedAt ? new Date(runRecord.startedAt).toLocaleString() : 'N/A'}`, + '', + `### Error Output`, + '', + '```', + errorSection, + '```', + '', + `### Full Output`, + '', + '```', + ...(runRecord.outputLines ?? []).slice(0, 100), + ...((runRecord.outputLines ?? []).length > 100 + ? [`... (${(runRecord.outputLines ?? []).length - 100} more lines)`] + : []), + '```', + ].join('\n'); +} diff --git a/src/renderer/features/test-suite/lib/screenshot-utils.ts b/src/renderer/features/test-suite/lib/screenshot-utils.ts new file mode 100644 index 00000000..8926ac9f --- /dev/null +++ b/src/renderer/features/test-suite/lib/screenshot-utils.ts @@ -0,0 +1,8 @@ +/** Convert a local file path to a local-file:// URL for Electron's custom protocol */ +export function fileUrl(fp: string): string { + const normalized = fp.replaceAll('\\', '/'); + // Use custom local-file:// protocol registered in main process. + // Electron blocks file:// from http://localhost in dev mode, + // so we proxy through a custom scheme that protocol.handle serves. + return `local-file:///${normalized.replace(/^\/+/, '')}`; +} diff --git a/src/main/features/qa/recorder/selector-builder.ts b/src/renderer/features/test-suite/lib/selector-builder.ts similarity index 100% rename from src/main/features/qa/recorder/selector-builder.ts rename to src/renderer/features/test-suite/lib/selector-builder.ts diff --git a/src/renderer/features/test-suite/lib/starter-test.ts b/src/renderer/features/test-suite/lib/starter-test.ts new file mode 100644 index 00000000..976cdd35 --- /dev/null +++ b/src/renderer/features/test-suite/lib/starter-test.ts @@ -0,0 +1,61 @@ +import type { TestSuiteConfig, TestSuiteStepSchema } from '@shared/ipc/test-suite/schemas'; + +import { DEFAULT_VIEWPORT_HEIGHT, DEFAULT_VIEWPORT_WIDTH } from './constants'; + +import type { z } from 'zod'; + + +type TestSuiteStep = z.infer<typeof TestSuiteStepSchema>; + +export const DEFAULT_CONFIG_TARGET_URL = 'http://localhost:3000'; +export const DEFAULT_CONFIG_SCREENSHOT_MODE: TestSuiteConfig['screenshotMode'] = 'smart'; +export const DEFAULT_CONFIG_TEST_DIRECTORY = 'test-suite/'; + +export function buildDefaultConfig(): TestSuiteConfig { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + name: 'localhost-default', + targetUrl: DEFAULT_CONFIG_TARGET_URL, + viewportWidth: DEFAULT_VIEWPORT_WIDTH, + viewportHeight: DEFAULT_VIEWPORT_HEIGHT, + screenshotMode: DEFAULT_CONFIG_SCREENSHOT_MODE, + testDirectory: DEFAULT_CONFIG_TEST_DIRECTORY, + saveScreenshotsToTemp: false, + navigationTimeout: 30000, + actionTimeout: 10000, + browsers: ['chromium'], + workers: 1, + retries: 1, + environments: [], + activeEnvironment: undefined, + storageStatePath: undefined, + isActive: true, + createdAt: now, + updatedAt: now, + }; +} + +export interface StarterTestInput { + projectId: string; + targetUrl: string; +} + +export interface StarterTest { + projectId: string; + name: string; + description: string; + steps: TestSuiteStep[]; +} + +export function buildStarterTest({ projectId, targetUrl }: StarterTestInput): StarterTest { + return { + projectId, + name: 'Starter: Smoke Test', + description: 'Auto-generated baseline — navigates to the app and verifies it loads.', + steps: [ + { type: 'navigate', url: targetUrl }, + { type: 'wait', ms: 1000 }, + ], + }; +} diff --git a/src/renderer/features/test-suite/lib/types.ts b/src/renderer/features/test-suite/lib/types.ts new file mode 100644 index 00000000..d8f5db75 --- /dev/null +++ b/src/renderer/features/test-suite/lib/types.ts @@ -0,0 +1,32 @@ +/** + * Shared types for the test-suite feature renderer layer. + */ + +/** Output line with sequential ID for store-based rendering */ +export interface StoreOutputLine { + id: number; + text: string; +} + +/** Output line from IPC event stream */ +export interface StreamOutputLine { + line: string; + timestamp: string; +} + +/** Run record shape used by results/output components */ +export interface RunRecord { + id?: string; + scriptId?: string; + status: string; + triggeredBy?: string; + startedAt?: string; + completedAt?: string; + outputLines?: string[]; + screenshots?: string[]; + error?: string; + stepsPassed?: number; + stepsFailed?: number; + durationMs?: number; + reportPath?: string; +} diff --git a/src/renderer/features/test-suite/test-suite-store.ts b/src/renderer/features/test-suite/test-suite-store.ts new file mode 100644 index 00000000..75566c5a --- /dev/null +++ b/src/renderer/features/test-suite/test-suite-store.ts @@ -0,0 +1,155 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import type { TestSuiteStep } from '@shared/types/test-suite'; + +import type { StatusFilter } from './lib/constants'; +import type { StoreOutputLine } from './lib/types'; + +export type { StatusFilter } from './lib/constants'; + +export type TestSuiteTab = 'recording' | 'library' | 'results' | 'screenshots' | 'export' | 'analytics' | 'shared-steps'; + +export interface RecordedStep { + stepIndex: number; + step: TestSuiteStep; + timestamp: string; +} + +interface TestSuiteUiState { + activeTab: TestSuiteTab; + selectedScriptId: string | null; + selectedRunId: string | null; + recordingActive: boolean; + recordedSteps: RecordedStep[]; + shortcutHelpOpen: boolean; + libraryStatusFilter: StatusFilter; + + // Ephemeral run-output state (not persisted) + isRunning: boolean; + activeRunId: string | null; + outputLines: StoreOutputLine[]; + _lineCounter: number; + + setActiveTab: (tab: TestSuiteTab) => void; + setSelectedScriptId: (id: string | null) => void; + setSelectedRunId: (id: string | null) => void; + setRecordingActive: (active: boolean) => void; + addStep: (step: RecordedStep) => void; + /** Append a raw TestSuiteStep captured from the webview preload */ + appendRawStep: (step: TestSuiteStep) => void; + removeStep: (stepIndex: number) => void; + reorderSteps: (fromIndex: number, toIndex: number) => void; + updateStep: (stepIndex: number, step: TestSuiteStep) => void; + clearSteps: () => void; + setShortcutHelpOpen: (open: boolean) => void; + setLibraryStatusFilter: (filter: StatusFilter) => void; + + // Run output actions + setRunning: (running: boolean) => void; + setActiveRunId: (runId: string | null) => void; + appendOutputLine: (line: string) => void; + clearOutputLines: () => void; +} + +export const useTestSuiteStore = create<TestSuiteUiState>()( + persist( + (set) => ({ + activeTab: 'recording', + selectedScriptId: null, + selectedRunId: null, + recordingActive: false, + recordedSteps: [], + shortcutHelpOpen: false, + libraryStatusFilter: 'all' as StatusFilter, + + // Ephemeral run-output state + isRunning: false, + activeRunId: null, + outputLines: [], + _lineCounter: 0, + + setActiveTab: (activeTab) => { + set({ activeTab }); + }, + setSelectedScriptId: (selectedScriptId) => { + set({ selectedScriptId }); + }, + setSelectedRunId: (selectedRunId) => { + set({ selectedRunId }); + }, + setRecordingActive: (recordingActive) => { + set({ recordingActive }); + }, + addStep: (step) => { + set((s) => ({ recordedSteps: [...s.recordedSteps, step] })); + }, + appendRawStep: (step) => { + set((s) => { + const stepIndex = s.recordedSteps.length; + const recorded: RecordedStep = { stepIndex, step, timestamp: new Date().toISOString() }; + return { recordedSteps: [...s.recordedSteps, recorded] }; + }); + }, + removeStep: (stepIndex) => { + set((state) => ({ + recordedSteps: state.recordedSteps + .filter((s) => s.stepIndex !== stepIndex) + .map((s, i) => ({ ...s, stepIndex: i })), + })); + }, + reorderSteps: (fromIndex, toIndex) => { + set((state) => { + const steps = [...state.recordedSteps]; + if (fromIndex < 0 || fromIndex >= steps.length) { + return { recordedSteps: steps }; + } + const [moved] = steps.splice(fromIndex, 1) as [RecordedStep]; + steps.splice(toIndex, 0, moved); + return { recordedSteps: steps.map((s, i) => ({ ...s, stepIndex: i })) }; + }); + }, + updateStep: (stepIndex, step) => { + set((state) => ({ + recordedSteps: state.recordedSteps.map((s) => + s.stepIndex === stepIndex ? { ...s, step } : s, + ), + })); + }, + clearSteps: () => { + set({ recordedSteps: [] }); + }, + setShortcutHelpOpen: (shortcutHelpOpen) => { + set({ shortcutHelpOpen }); + }, + setLibraryStatusFilter: (libraryStatusFilter) => { + set({ libraryStatusFilter }); + }, + + // Run output actions + setRunning: (isRunning) => { + set({ isRunning }); + }, + setActiveRunId: (activeRunId) => { + set({ activeRunId }); + }, + appendOutputLine: (line) => { + set((s) => ({ + outputLines: [...s.outputLines, { id: s._lineCounter, text: line }], + _lineCounter: s._lineCounter + 1, + })); + }, + clearOutputLines: () => { + set({ outputLines: [], _lineCounter: 0 }); + }, + }), + { + name: 'adc-test-suite-ui', + partialize: (state) => ({ + activeTab: state.activeTab, + selectedScriptId: state.selectedScriptId, + selectedRunId: state.selectedRunId, + }), + }, + ), +); diff --git a/src/renderer/features/tools/components/ToolsPage.tsx b/src/renderer/features/tools/components/ToolsPage/ToolsPage.tsx similarity index 87% rename from src/renderer/features/tools/components/ToolsPage.tsx rename to src/renderer/features/tools/components/ToolsPage/ToolsPage.tsx index 6a013f46..bb659541 100644 --- a/src/renderer/features/tools/components/ToolsPage.tsx +++ b/src/renderer/features/tools/components/ToolsPage/ToolsPage.tsx @@ -7,8 +7,6 @@ import { Cog, Workflow } from 'lucide-react'; -import { useLayoutStore } from '@renderer/shared/stores'; - import { Badge, Button, @@ -24,12 +22,9 @@ import { Text, } from '@ui'; -import { useProjects } from '@features/projects'; - -import { useClaudeConfig } from '../api/useClaudeConfig'; -import { useToolsUI } from '../store'; +import { WorkflowEditor } from '../WorkflowEditor'; -import { WorkflowEditor } from './WorkflowEditor'; +import { useToolsPage } from './useToolsPage'; interface ConfigSectionProps { items: Array<{ name: string; description: string }>; @@ -82,11 +77,7 @@ function ConfigSection({ items, loading, title, type }: ConfigSectionProps) { } export function ToolsPage() { - const { activeTab, setActiveTab } = useToolsUI(); - const activeProjectId = useLayoutStore((s) => s.activeProjectId); - const { data: projects } = useProjects(); - const activeProject = projects?.find((p) => p.id === activeProjectId); - const { data, isLoading, refetch } = useClaudeConfig(activeProject?.path); + const { activeTab, setActiveTab, data, isLoading, refetch } = useToolsPage(); return ( <PageLayout> diff --git a/src/renderer/features/tools/components/ToolsPage/index.ts b/src/renderer/features/tools/components/ToolsPage/index.ts new file mode 100644 index 00000000..fd3a3f57 --- /dev/null +++ b/src/renderer/features/tools/components/ToolsPage/index.ts @@ -0,0 +1 @@ +export { ToolsPage } from './ToolsPage'; diff --git a/src/renderer/features/tools/components/ToolsPage/useToolsPage.ts b/src/renderer/features/tools/components/ToolsPage/useToolsPage.ts new file mode 100644 index 00000000..a7f43edd --- /dev/null +++ b/src/renderer/features/tools/components/ToolsPage/useToolsPage.ts @@ -0,0 +1,22 @@ +import { useLayoutStore } from '@renderer/shared/stores'; + +import { useProjects } from '@features/projects'; + +import { useClaudeConfig } from '../../api/useClaudeConfig'; +import { useToolsUI } from '../../store'; + +export function useToolsPage() { + const { activeTab, setActiveTab } = useToolsUI(); + const activeProjectId = useLayoutStore((s) => s.activeProjectId); + const { data: projects } = useProjects(); + const activeProject = projects?.find((p) => p.id === activeProjectId); + const { data, isLoading, refetch } = useClaudeConfig(activeProject?.path); + + return { + activeTab, + setActiveTab, + data, + isLoading, + refetch, + }; +} diff --git a/src/renderer/features/tools/components/WorkflowEditor.tsx b/src/renderer/features/tools/components/WorkflowEditor.tsx deleted file mode 100644 index cbdf4495..00000000 --- a/src/renderer/features/tools/components/WorkflowEditor.tsx +++ /dev/null @@ -1,281 +0,0 @@ -/** - * WorkflowEditor — Main workflow template editor - * - * Combines a sidebar template list with a detail panel for editing - * name, description, and phase configurations. Supports CRUD operations, - * duplication, and sending phase prompts to the assistant widget. - */ - -import { useEffect, useMemo, useState } from 'react'; - -import { Copy, Save, Trash2 } from 'lucide-react'; - -import type { WorkflowPhase } from '@shared/ipc/workflow-templates/schemas'; - -import { useAssistantWidgetStore } from '@renderer/shared/stores/assistant-widget-store'; -import { useLayoutStore } from '@renderer/shared/stores/layout-store'; - -import { Button, Input, Label, ScrollArea, Text, Textarea } from '@ui'; - -import { useSendCommand } from '@features/assistant/api/useAssistant'; -import { useProjects } from '@features/projects/api/useProjects'; - -import { - useCreateTemplate, - useDeleteTemplate, - useDuplicateTemplate, - usePluginArtifacts, - useUpdateTemplate, - useWorkflowTemplates, -} from '../api/useWorkflowTemplates'; -import { useToolsUI } from '../store'; - -import { PhaseSection } from './PhaseSection'; -import { WorkflowSidebar } from './WorkflowSidebar'; - -// ─── Defaults ─────────────────────────────────────────────── - -const DEFAULT_PHASES: WorkflowPhase[] = [ - { - name: 'brainstorming', - strategy: 'skip', - prompt: '', - summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] }, - }, - { - name: 'planning', - strategy: 'skip', - prompt: '', - summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] }, - }, - { - name: 'implementation', - strategy: 'skip', - prompt: '', - summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] }, - }, -]; - -// ─── Component ────────────────────────────────────────────── - -export function WorkflowEditor() { - const { selectedTemplateId, setSelectedTemplateId } = useToolsUI(); - - // Active project path for plugin artifacts - const activeProjectId = useLayoutStore((s) => s.activeProjectId); - const { data: projectsData } = useProjects(); - - const activeProjectPath = useMemo(() => { - if (!activeProjectId || !projectsData) return null; - const project = projectsData.find((p) => p.id === activeProjectId); - return project?.path ?? null; - }, [activeProjectId, projectsData]); - - // Queries - const { data: templates = [] } = useWorkflowTemplates(); - const { data: artifacts = [] } = usePluginArtifacts(activeProjectPath); - - // Mutations - const createTemplate = useCreateTemplate(); - const updateTemplate = useUpdateTemplate(); - const deleteTemplate = useDeleteTemplate(); - const duplicateTemplate = useDuplicateTemplate(); - - // Assistant - const sendCommand = useSendCommand(); - const openWidget = useAssistantWidgetStore((s) => s.open); - - // Local form state - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [phases, setPhases] = useState<WorkflowPhase[]>(DEFAULT_PHASES); - - // Sync form state from selected template - const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null; - - useEffect(() => { - if (selectedTemplate) { - setName(selectedTemplate.name); - setDescription(selectedTemplate.description); - const templatePhases = Array.isArray(selectedTemplate.phases) ? selectedTemplate.phases : []; - setPhases(templatePhases.length > 0 ? templatePhases : DEFAULT_PHASES); - } - }, [selectedTemplate]); - - // Auto-select first template on mount - useEffect(() => { - const first = templates.at(0); - if (selectedTemplateId === null && first !== undefined) { - setSelectedTemplateId(first.id); - } - }, [selectedTemplateId, templates, setSelectedTemplateId]); - - // ─── Handlers ────────────────────────────────────────────── - - function handleCreate() { - createTemplate.mutate( - { - name: 'New Workflow', - description: '', - mode: 'standard', - branching: { featurePrefix: 'feature', workPrefix: 'work', useWorktrees: true }, - team: { - maxConcurrentAgents: 3, - spawnQaPerTask: true, - enableGuardian: true, - roles: [], - }, - qa: { - runLint: true, - runTypecheck: true, - runBuild: true, - runTests: false, - maxRounds: 3, - }, - permissions: { - allowPush: false, - allowCreatePr: false, - allowDeleteBranch: false, - allowShellExec: false, - }, - guardian: { - blockingRules: [], - warningRules: [], - maxFileSizeLines: 500, - }, - phases: DEFAULT_PHASES, - }, - { - onSuccess: (result) => { - setSelectedTemplateId(result.template.id); - }, - }, - ); - } - - function handleSave() { - if (selectedTemplateId === null) return; - updateTemplate.mutate({ - id: selectedTemplateId, - updates: { name, description, phases }, - }); - } - - function handleDelete() { - if (selectedTemplateId === null) return; - deleteTemplate.mutate(selectedTemplateId, { - onSuccess: () => { - setSelectedTemplateId(null); - }, - }); - } - - function handleDuplicate() { - if (selectedTemplateId === null) return; - duplicateTemplate.mutate( - { id: selectedTemplateId }, - { - onSuccess: (result) => { - setSelectedTemplateId(result.template.id); - }, - }, - ); - } - - function handlePhaseUpdate(index: number, updates: Partial<WorkflowPhase>) { - setPhases((prev) => - prev.map((phase, i) => (i === index ? { ...phase, ...updates } : phase)), - ); - } - - function handleGenerate(prompt: string) { - openWidget(); - sendCommand.mutate({ input: prompt, context: { activeView: 'tools' } }); - } - - // ─── Render ─────────────────────────────────────────────── - - return ( - <div className="flex h-full"> - <WorkflowSidebar - selectedId={selectedTemplateId} - templates={templates} - onNew={handleCreate} - onSelect={setSelectedTemplateId} - /> - - <div className="flex-1"> - {selectedTemplate ? ( - <ScrollArea className="h-full"> - <div className="max-w-2xl space-y-4 p-6"> - <div className="space-y-1.5"> - <Label htmlFor="template-name">Name</Label> - <Input - id="template-name" - placeholder="Workflow name" - value={name} - onChange={(e) => setName(e.target.value)} - /> - </div> - - <div className="space-y-1.5"> - <Label htmlFor="template-description">Description</Label> - <Textarea - id="template-description" - placeholder="Short description..." - resize="none" - rows={2} - value={description} - onChange={(e) => setDescription(e.target.value)} - /> - </div> - - {phases.map((phase, index) => ( - <PhaseSection - key={phase.name} - artifacts={artifacts} - isGenerating={sendCommand.isPending} - phase={phase} - onGenerate={handleGenerate} - onUpdate={(updates) => handlePhaseUpdate(index, updates)} - /> - ))} - - <div className="flex items-center gap-2 pt-4"> - <Button - disabled={updateTemplate.isPending || name.trim().length === 0} - onClick={handleSave} - > - <Save className="mr-1.5 h-4 w-4" /> - Save - </Button> - <Button - disabled={duplicateTemplate.isPending} - variant="outline" - onClick={handleDuplicate} - > - <Copy className="mr-1.5 h-4 w-4" /> - Duplicate - </Button> - <Button - disabled={deleteTemplate.isPending || (selectedTemplate.isBuiltin)} - variant="destructive" - onClick={handleDelete} - > - <Trash2 className="mr-1.5 h-4 w-4" /> - Delete - </Button> - </div> - </div> - </ScrollArea> - ) : ( - <div className="flex h-full items-center justify-center"> - <Text className="text-muted-foreground"> - Select a workflow template or create a new one. - </Text> - </div> - )} - </div> - </div> - ); -} diff --git a/src/renderer/features/tools/components/WorkflowEditor/WorkflowEditor.tsx b/src/renderer/features/tools/components/WorkflowEditor/WorkflowEditor.tsx new file mode 100644 index 00000000..bcbcd4a0 --- /dev/null +++ b/src/renderer/features/tools/components/WorkflowEditor/WorkflowEditor.tsx @@ -0,0 +1,125 @@ +/** + * WorkflowEditor — Main workflow template editor + * + * Combines a sidebar template list with a detail panel for editing + * name, description, and phase configurations. Supports CRUD operations, + * duplication, and sending phase prompts to the assistant widget. + */ + +import { Copy, Save, Trash2 } from 'lucide-react'; + +import { Button, Input, Label, ScrollArea, Text, Textarea } from '@ui'; + +import { PhaseSection } from '../PhaseSection'; +import { WorkflowSidebar } from '../WorkflowSidebar'; + +import { useWorkflowEditor } from './useWorkflowEditor'; + +export function WorkflowEditor() { + const { + selectedTemplateId, + setSelectedTemplateId, + templates, + artifacts, + updateTemplate, + deleteTemplate, + duplicateTemplate, + sendCommand, + name, + setName, + description, + setDescription, + phases, + selectedTemplate, + handleCreate, + handleSave, + handleDelete, + handleDuplicate, + handlePhaseUpdate, + handleGenerate, + } = useWorkflowEditor(); + + return ( + <div className="flex h-full"> + <WorkflowSidebar + selectedId={selectedTemplateId} + templates={templates} + onNew={handleCreate} + onSelect={setSelectedTemplateId} + /> + + <div className="flex-1"> + {selectedTemplate ? ( + <ScrollArea className="h-full"> + <div className="max-w-2xl space-y-4 p-6"> + <div className="space-y-1.5"> + <Label htmlFor="template-name">Name</Label> + <Input + id="template-name" + placeholder="Workflow name" + value={name} + onChange={(e) => setName(e.target.value)} + /> + </div> + + <div className="space-y-1.5"> + <Label htmlFor="template-description">Description</Label> + <Textarea + id="template-description" + placeholder="Short description..." + resize="none" + rows={2} + value={description} + onChange={(e) => setDescription(e.target.value)} + /> + </div> + + {phases.map((phase, index) => ( + <PhaseSection + key={phase.name} + artifacts={artifacts} + isGenerating={sendCommand.isPending} + phase={phase} + onGenerate={handleGenerate} + onUpdate={(updates) => handlePhaseUpdate(index, updates)} + /> + ))} + + <div className="flex items-center gap-2 pt-4"> + <Button + disabled={updateTemplate.isPending || name.trim().length === 0} + onClick={handleSave} + > + <Save className="mr-1.5 h-4 w-4" /> + Save + </Button> + <Button + disabled={duplicateTemplate.isPending} + variant="outline" + onClick={handleDuplicate} + > + <Copy className="mr-1.5 h-4 w-4" /> + Duplicate + </Button> + <Button + disabled={deleteTemplate.isPending || (selectedTemplate.isBuiltin)} + variant="destructive" + onClick={handleDelete} + > + <Trash2 className="mr-1.5 h-4 w-4" /> + Delete + </Button> + </div> + </div> + </ScrollArea> + ) : ( + <div className="flex h-full items-center justify-center"> + <Text className="text-muted-foreground"> + Select a workflow template or create a new one. + </Text> + </div> + )} + </div> + </div> + ); +} diff --git a/src/renderer/features/tools/components/WorkflowEditor/index.ts b/src/renderer/features/tools/components/WorkflowEditor/index.ts new file mode 100644 index 00000000..4e48e82a --- /dev/null +++ b/src/renderer/features/tools/components/WorkflowEditor/index.ts @@ -0,0 +1 @@ +export { WorkflowEditor } from './WorkflowEditor'; diff --git a/src/renderer/features/tools/components/WorkflowEditor/useWorkflowEditor.ts b/src/renderer/features/tools/components/WorkflowEditor/useWorkflowEditor.ts new file mode 100644 index 00000000..d4c67c55 --- /dev/null +++ b/src/renderer/features/tools/components/WorkflowEditor/useWorkflowEditor.ts @@ -0,0 +1,198 @@ +import { useEffect, useMemo, useState } from 'react'; + +import type { WorkflowPhase } from '@shared/ipc/workflow-templates/schemas'; + +import { useAssistantWidgetStore } from '@renderer/shared/stores/assistant-widget-store'; +import { useLayoutStore } from '@renderer/shared/stores/layout-store'; + +import { useSendCommand } from '@features/assistant/api/useAssistant'; +import { useProjects } from '@features/projects/api/useProjects'; + +import { + useCreateTemplate, + useDeleteTemplate, + useDuplicateTemplate, + usePluginArtifacts, + useUpdateTemplate, + useWorkflowTemplates, +} from '../../api/useWorkflowTemplates'; +import { useToolsUI } from '../../store'; + +const DEFAULT_PHASES: WorkflowPhase[] = [ + { + name: 'brainstorming', + strategy: 'skip', + prompt: '', + summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] }, + }, + { + name: 'planning', + strategy: 'skip', + prompt: '', + summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] }, + }, + { + name: 'implementation', + strategy: 'skip', + prompt: '', + summarySpec: { maxChars: 300, tableFields: ['Approach', 'Risk', 'Estimate'] }, + }, +]; + +export { DEFAULT_PHASES }; + +export function useWorkflowEditor() { + const { selectedTemplateId, setSelectedTemplateId } = useToolsUI(); + + const activeProjectId = useLayoutStore((s) => s.activeProjectId); + const { data: projectsData } = useProjects(); + + const activeProjectPath = useMemo(() => { + if (!activeProjectId || !projectsData) return null; + const project = projectsData.find((p) => p.id === activeProjectId); + return project?.path ?? null; + }, [activeProjectId, projectsData]); + + const { data: templates = [] } = useWorkflowTemplates(); + const { data: artifacts = [] } = usePluginArtifacts(activeProjectPath); + + const createTemplate = useCreateTemplate(); + const updateTemplate = useUpdateTemplate(); + const deleteTemplate = useDeleteTemplate(); + const duplicateTemplate = useDuplicateTemplate(); + + const sendCommand = useSendCommand(); + const openWidget = useAssistantWidgetStore((s) => s.open); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [phases, setPhases] = useState<WorkflowPhase[]>(DEFAULT_PHASES); + + const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null; + + useEffect(() => { + if (selectedTemplate) { + setName(selectedTemplate.name); + setDescription(selectedTemplate.description); + const templatePhases = Array.isArray(selectedTemplate.phases) ? selectedTemplate.phases : []; + setPhases(templatePhases.length > 0 ? templatePhases : DEFAULT_PHASES); + } + }, [selectedTemplate]); + + useEffect(() => { + const first = templates.at(0); + if (selectedTemplateId === null && first !== undefined) { + setSelectedTemplateId(first.id); + } + }, [selectedTemplateId, templates, setSelectedTemplateId]); + + function handleCreate() { + createTemplate.mutate( + { + name: 'New Workflow', + description: '', + mode: 'standard', + branching: { featurePrefix: 'feature', workPrefix: 'work', useWorktrees: true }, + team: { + maxConcurrentAgents: 3, + spawnQaPerTask: true, + enableGuardian: true, + roles: [], + }, + qa: { + runLint: true, + runTypecheck: true, + runBuild: true, + runTests: false, + maxRounds: 3, + }, + permissions: { + allowPush: false, + allowCreatePr: false, + allowDeleteBranch: false, + allowShellExec: false, + }, + guardian: { + blockingRules: [], + warningRules: [], + maxFileSizeLines: 500, + }, + phases: DEFAULT_PHASES, + }, + { + onSuccess: (result) => { + setSelectedTemplateId(result.template.id); + }, + }, + ); + } + + function handleSave() { + if (selectedTemplateId === null) return; + updateTemplate.mutate({ + id: selectedTemplateId, + updates: { name, description, phases }, + }); + } + + function handleDelete() { + if (selectedTemplateId === null) return; + deleteTemplate.mutate(selectedTemplateId, { + onSuccess: () => { + setSelectedTemplateId(null); + }, + }); + } + + function handleDuplicate() { + if (selectedTemplateId === null) return; + duplicateTemplate.mutate( + { id: selectedTemplateId }, + { + onSuccess: (result) => { + setSelectedTemplateId(result.template.id); + }, + }, + ); + } + + function handlePhaseUpdate(index: number, updates: Partial<WorkflowPhase>) { + setPhases((prev) => + prev.map((phase, i) => (i === index ? { ...phase, ...updates } : phase)), + ); + } + + function handleGenerate(prompt: string) { + openWidget(); + sendCommand.mutate({ input: prompt, context: { activeView: 'tools' } }); + } + + return { + selectedTemplateId, + setSelectedTemplateId, + templates, + artifacts, + createTemplate, + updateTemplate, + deleteTemplate, + duplicateTemplate, + sendCommand, + name, + setName, + description, + setDescription, + phases, + selectedTemplate, + handleCreate, + handleSave, + handleDelete, + handleDuplicate, + handlePhaseUpdate, + handleGenerate, + // Derived mutation states + isCreating: createTemplate.isPending, + isSaving: updateTemplate.isPending, + isDeleting: deleteTemplate.isPending, + isDuplicating: duplicateTemplate.isPending, + }; +} diff --git a/src/renderer/features/visualization/components/VisualizationPage.tsx b/src/renderer/features/visualization/components/VisualizationPage.tsx deleted file mode 100644 index 027e03e5..00000000 --- a/src/renderer/features/visualization/components/VisualizationPage.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useCallback, useMemo } from 'react'; - -import { useQueryClient } from '@tanstack/react-query'; -import { ReactFlowProvider } from '@xyflow/react'; - -import { useLooseParams } from '@renderer/shared/hooks'; - -import { PageHeader, PageLayout } from '@ui'; - -import { visualizationKeys } from '../api/queryKeys'; -import { useAgentTeams, useCodebaseGraph } from '../api/useVisualization'; -import { useVisualizationStore } from '../store'; - -import { VisualizationCanvas } from './canvas/VisualizationCanvas'; -import { NodeDetailPanel } from './panels/NodeDetailPanel'; -import { LayerToggleToolbar } from './toolbar/LayerToggleToolbar'; - -// ─── Inner component (must be inside ReactFlowProvider) ───────── - -function VisualizationPageContent({ projectId }: { projectId: string }) { - const { - showCodebaseLayer, - showAgentLayer, - selectedFeature, - layoutDirection, - searchFilter, - showEdgeLabels, - toggleCodebaseLayer, - toggleAgentLayer, - setSelectedFeature, - setLayoutDirection, - setSearchFilter, - toggleEdgeLabels, - } = useVisualizationStore(); - - const queryClient = useQueryClient(); - - const codebaseQuery = useCodebaseGraph(projectId); - const agentQuery = useAgentTeams(projectId); - const agentTeams = agentQuery.data; - - // ─── Derived feature list ────────────────────────────────────── - - const features = useMemo( - () => agentTeams?.features.map((f) => f.feature) ?? [], - [agentTeams], - ); - - // ─── Handlers ───────────────────────────────────────────────── - - const handleRefresh = useCallback(() => { - void queryClient.invalidateQueries({ - queryKey: visualizationKeys.codebaseGraph(projectId), - }); - void queryClient.invalidateQueries({ - queryKey: visualizationKeys.agentTeams(projectId), - }); - }, [queryClient, projectId]); - - // ─── Loading check (for disabling toolbar actions) ──────────── - - const isPending = codebaseQuery.isPending || agentQuery.isPending; - - return ( - <PageLayout> - <PageHeader> - <PageHeader.Row> - <PageHeader.Title description="Codebase structure and agent activity"> - Visual Map - </PageHeader.Title> - <PageHeader.Actions> - <LayerToggleToolbar - features={features} - isPending={isPending} - layoutDirection={layoutDirection} - searchFilter={searchFilter} - selectedFeature={selectedFeature} - showAgentLayer={showAgentLayer} - showCodebaseLayer={showCodebaseLayer} - showEdgeLabels={showEdgeLabels} - onRefresh={handleRefresh} - onSelectFeature={setSelectedFeature} - onSetLayoutDirection={setLayoutDirection} - onSetSearchFilter={setSearchFilter} - onToggleAgent={toggleAgentLayer} - onToggleCodebase={toggleCodebaseLayer} - onToggleEdgeLabels={toggleEdgeLabels} - /> - </PageHeader.Actions> - </PageHeader.Row> - </PageHeader> - <div className="relative flex-1"> - <VisualizationCanvas projectId={projectId} /> - <NodeDetailPanel projectId={projectId} /> - </div> - </PageLayout> - ); -} - -// ─── Page (provides ReactFlowProvider context) ────────────────── - -export function VisualizationPage() { - const params = useLooseParams(); - const projectId = params.projectId ?? ''; - - return ( - <ReactFlowProvider> - <VisualizationPageContent projectId={projectId} /> - </ReactFlowProvider> - ); -} diff --git a/src/renderer/features/visualization/components/VisualizationPage/VisualizationPage.tsx b/src/renderer/features/visualization/components/VisualizationPage/VisualizationPage.tsx new file mode 100644 index 00000000..2ccfa29e --- /dev/null +++ b/src/renderer/features/visualization/components/VisualizationPage/VisualizationPage.tsx @@ -0,0 +1,78 @@ +import { ReactFlowProvider } from '@xyflow/react'; + +import { PageHeader, PageLayout } from '@ui'; + +import { VisualizationCanvas } from '../canvas/VisualizationCanvas'; +import { NodeDetailPanel } from '../panels/NodeDetailPanel'; +import { LayerToggleToolbar } from '../toolbar/LayerToggleToolbar'; + +import { useVisualizationPage, useVisualizationPageParams } from './useVisualizationPage'; + +// ─── Inner component (must be inside ReactFlowProvider) ───────── + +function VisualizationPageContent({ projectId }: { projectId: string }) { + const { + showCodebaseLayer, + showAgentLayer, + selectedFeature, + layoutDirection, + searchFilter, + showEdgeLabels, + toggleCodebaseLayer, + toggleAgentLayer, + setSelectedFeature, + setLayoutDirection, + setSearchFilter, + toggleEdgeLabels, + features, + handleRefresh, + isPending, + } = useVisualizationPage(projectId); + + return ( + <PageLayout> + <PageHeader> + <PageHeader.Row> + <PageHeader.Title description="Codebase structure and agent activity"> + Visual Map + </PageHeader.Title> + <PageHeader.Actions> + <LayerToggleToolbar + features={features} + isPending={isPending} + layoutDirection={layoutDirection} + searchFilter={searchFilter} + selectedFeature={selectedFeature} + showAgentLayer={showAgentLayer} + showCodebaseLayer={showCodebaseLayer} + showEdgeLabels={showEdgeLabels} + onRefresh={handleRefresh} + onSelectFeature={setSelectedFeature} + onSetLayoutDirection={setLayoutDirection} + onSetSearchFilter={setSearchFilter} + onToggleAgent={toggleAgentLayer} + onToggleCodebase={toggleCodebaseLayer} + onToggleEdgeLabels={toggleEdgeLabels} + /> + </PageHeader.Actions> + </PageHeader.Row> + </PageHeader> + <div className="relative flex-1"> + <VisualizationCanvas projectId={projectId} /> + <NodeDetailPanel projectId={projectId} /> + </div> + </PageLayout> + ); +} + +// ─── Page (provides ReactFlowProvider context) ────────────────── + +export function VisualizationPage() { + const { projectId } = useVisualizationPageParams(); + + return ( + <ReactFlowProvider> + <VisualizationPageContent projectId={projectId} /> + </ReactFlowProvider> + ); +} diff --git a/src/renderer/features/visualization/components/VisualizationPage/index.ts b/src/renderer/features/visualization/components/VisualizationPage/index.ts new file mode 100644 index 00000000..0180293a --- /dev/null +++ b/src/renderer/features/visualization/components/VisualizationPage/index.ts @@ -0,0 +1 @@ +export { VisualizationPage } from './VisualizationPage'; diff --git a/src/renderer/features/visualization/components/VisualizationPage/useVisualizationPage.ts b/src/renderer/features/visualization/components/VisualizationPage/useVisualizationPage.ts new file mode 100644 index 00000000..d6abebbb --- /dev/null +++ b/src/renderer/features/visualization/components/VisualizationPage/useVisualizationPage.ts @@ -0,0 +1,71 @@ +import { useCallback, useMemo } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; + +import { useLooseParams } from '@renderer/shared/hooks'; + +import { visualizationKeys } from '../../api/queryKeys'; +import { useAgentTeams, useCodebaseGraph } from '../../api/useVisualization'; +import { useVisualizationStore } from '../../store'; + +export function useVisualizationPage(projectId: string) { + const { + showCodebaseLayer, + showAgentLayer, + selectedFeature, + layoutDirection, + searchFilter, + showEdgeLabels, + toggleCodebaseLayer, + toggleAgentLayer, + setSelectedFeature, + setLayoutDirection, + setSearchFilter, + toggleEdgeLabels, + } = useVisualizationStore(); + + const queryClient = useQueryClient(); + + const codebaseQuery = useCodebaseGraph(projectId); + const agentQuery = useAgentTeams(projectId); + const agentTeams = agentQuery.data; + + const features = useMemo( + () => agentTeams?.features.map((f) => f.feature) ?? [], + [agentTeams], + ); + + const handleRefresh = useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: visualizationKeys.codebaseGraph(projectId), + }); + void queryClient.invalidateQueries({ + queryKey: visualizationKeys.agentTeams(projectId), + }); + }, [queryClient, projectId]); + + const isPending = codebaseQuery.isPending || agentQuery.isPending; + + return { + showCodebaseLayer, + showAgentLayer, + selectedFeature, + layoutDirection, + searchFilter, + showEdgeLabels, + toggleCodebaseLayer, + toggleAgentLayer, + setSelectedFeature, + setLayoutDirection, + setSearchFilter, + toggleEdgeLabels, + features, + handleRefresh, + isPending, + }; +} + +export function useVisualizationPageParams() { + const params = useLooseParams(); + return { projectId: params.projectId ?? '' }; +} diff --git a/src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx b/src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx index 99a93b04..f29febf4 100644 --- a/src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx +++ b/src/renderer/features/visualization/components/panels/NodeDetailPanel.tsx @@ -8,7 +8,7 @@ import { X } from 'lucide-react'; import { cn } from '@renderer/shared/lib/utils'; -import { Button, Card, ScrollArea } from '@ui'; +import { Button, Card, Heading, ScrollArea } from '@ui'; import { useAgentTeams, useCodebaseGraph } from '../../api/useVisualization'; import { useVisualizationStore } from '../../store'; @@ -70,7 +70,7 @@ export function NodeDetailPanel({ projectId }: NodeDetailPanelProps) { > <Card className="flex h-full flex-col rounded-none border-0"> <header className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3"> - <h3 className="truncate text-sm font-semibold">{getPanelTitle(node)}</h3> + <Heading as="h3" className="truncate text-sm">{getPanelTitle(node)}</Heading> <Button aria-label="Close detail panel" size="icon" diff --git a/src/renderer/features/workflow-pipeline/api/useUpdateTask.ts b/src/renderer/features/workflow-pipeline/api/useUpdateTask.ts index 74dfd1d8..a93fd682 100644 --- a/src/renderer/features/workflow-pipeline/api/useUpdateTask.ts +++ b/src/renderer/features/workflow-pipeline/api/useUpdateTask.ts @@ -22,7 +22,7 @@ export function useUpdateTaskDescription() { mutationFn: ({ slug, description }: { slug: string; description: string }) => ipc(PROGRESS.UPDATE.TASK, { slug, updates: { description } }), onSuccess: (_data, variables) => { - void queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + void queryClient.invalidateQueries({ queryKey: progressKeys.lists() }); void queryClient.invalidateQueries({ queryKey: progressKeys.detail(variables.slug) }); }, onError: onError('update task description'), @@ -37,7 +37,7 @@ export function useUpdateTaskPlan() { mutationFn: ({ slug, planContent }: { slug: string; planContent: string }) => ipc(PROGRESS.UPDATE.TASK, { slug, updates: { description: planContent } }), onSuccess: (_data, variables) => { - void queryClient.invalidateQueries({ queryKey: progressKeys.list() }); + void queryClient.invalidateQueries({ queryKey: progressKeys.lists() }); void queryClient.invalidateQueries({ queryKey: progressKeys.detail(variables.slug) }); }, onError: onError('update task plan'), diff --git a/src/renderer/features/workflow-pipeline/components/TaskSelector.tsx b/src/renderer/features/workflow-pipeline/components/TaskSelector/TaskSelector.tsx similarity index 100% rename from src/renderer/features/workflow-pipeline/components/TaskSelector.tsx rename to src/renderer/features/workflow-pipeline/components/TaskSelector/TaskSelector.tsx diff --git a/src/renderer/features/workflow-pipeline/components/TaskSelector/index.ts b/src/renderer/features/workflow-pipeline/components/TaskSelector/index.ts new file mode 100644 index 00000000..20a568e3 --- /dev/null +++ b/src/renderer/features/workflow-pipeline/components/TaskSelector/index.ts @@ -0,0 +1 @@ +export { TaskSelector } from './TaskSelector'; diff --git a/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage.tsx b/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage.tsx deleted file mode 100644 index 3f7e067b..00000000 --- a/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage.tsx +++ /dev/null @@ -1,179 +0,0 @@ -/** - * WorkflowPipelinePage — Main page assembling TaskSelector, PipelineDiagram, and step panels. - * Wires store state, mutations, and event hooks together. - */ - -import { useCallback, useEffect, useState } from 'react'; - -import type { ProgressStatus, ProgressTask } from '@shared/types/progress'; - -import { useLooseParams } from '@renderer/shared/hooks'; - -import { PageContent, PageHeader, PageLayout } from '@ui'; - -import { useProgressTask } from '@features/tasks/api/useProgress'; - -import { useUpdateTaskDescription, useUpdateTaskPlan } from '../api/useUpdateTask'; -import { useWorkflowPipelineEvents } from '../hooks/useWorkflowPipelineEvents'; -import { useWorkflowPipelineStore } from '../store'; - -import { PipelineDiagram } from './PipelineDiagram'; -import { BacklogPanel } from './step-panels/BacklogPanel'; -import { DonePanel } from './step-panels/DonePanel'; -import { ErrorPanel } from './step-panels/ErrorPanel'; -import { PlanningPanel } from './step-panels/PlanningPanel'; -import { PlanReadyPanel } from './step-panels/PlanReadyPanel'; -import { QueuedPanel } from './step-panels/QueuedPanel'; -import { ReviewPanel } from './step-panels/ReviewPanel'; -import { RunningPanel } from './step-panels/RunningPanel'; -import { TaskSelector } from './TaskSelector'; - -/** Maps a ProgressStatus to the default pipeline step key for the diagram */ -function statusToStepKey(status: ProgressStatus): string { - if (status === 'researching' || status === 'research_done') return 'planning'; - if (status === 'executing') return 'running'; - return status; -} - -function renderStepPanel( - step: string, - task: ProgressTask, - onSaveDescription: (text: string) => void, - onSavePlan: (text: string) => void, - savingDescription: boolean, - savingPlan: boolean, -): React.ReactNode { - switch (step) { - case 'backlog': { - return ( - <BacklogPanel - saving={savingDescription} - task={task} - onSaveDescription={onSaveDescription} - /> - ); - } - case 'planning': { - return <PlanningPanel task={task} />; - } - case 'plan_ready': { - return <PlanReadyPanel saving={savingPlan} task={task} onSavePlan={onSavePlan} />; - } - case 'queued': { - return <QueuedPanel task={task} />; - } - case 'running': { - return <RunningPanel task={task} />; - } - case 'review': { - return <ReviewPanel task={task} />; - } - case 'done': { - return <DonePanel task={task} />; - } - case 'error': { - return <ErrorPanel task={task} />; - } - default: { - return ( - <p className="text-muted-foreground text-sm"> - No panel available for step "{step}". - </p> - ); - } - } -} - -export function WorkflowPipelinePage() { - const params = useLooseParams(); - const projectId = params.projectId ?? ''; - - const [selectedSlug, setSelectedSlug] = useState<string | null>(null); - const { data: task } = useProgressTask(selectedSlug); - - const { selectedStep, setSelectedStep } = useWorkflowPipelineStore(); - - const updateDescription = useUpdateTaskDescription(); - const updatePlan = useUpdateTaskPlan(); - - // Initialize the events hook for real-time cache updates - useWorkflowPipelineEvents(); - - // When a task is loaded or changes, default selectedStep to its current status - useEffect(() => { - if (task) { - setSelectedStep(statusToStepKey(task.status)); - } - }, [task, setSelectedStep]); - - const handleStepClick = useCallback( - (step: string) => { - setSelectedStep(step); - }, - [setSelectedStep], - ); - - const handleSaveDescription = useCallback( - (text: string) => { - if (selectedSlug) { - updateDescription.mutate({ slug: selectedSlug, description: text }); - } - }, - [selectedSlug, updateDescription], - ); - - const handleSavePlan = useCallback( - (text: string) => { - if (selectedSlug) { - updatePlan.mutate({ slug: selectedSlug, planContent: text }); - } - }, - [selectedSlug, updatePlan], - ); - - return ( - <PageLayout> - <PageHeader> - <PageHeader.Row> - <PageHeader.Title>Workflow Pipeline</PageHeader.Title> - <PageHeader.Actions> - <TaskSelector - projectId={projectId} - selectedSlug={selectedSlug} - onSelectTask={setSelectedSlug} - /> - </PageHeader.Actions> - </PageHeader.Row> - </PageHeader> - - <PageContent> - {task ? ( - <div className="space-y-6"> - <PipelineDiagram - selectedStep={selectedStep} - taskStatus={task.status} - onStepClick={handleStepClick} - /> - - <div className="bg-card border-border flex-1 overflow-auto rounded-lg border p-6"> - {selectedStep - ? renderStepPanel( - selectedStep, - task, - handleSaveDescription, - handleSavePlan, - updateDescription.isPending, - updatePlan.isPending, - ) - : null} - </div> - </div> - ) : ( - <div className="text-muted-foreground flex flex-1 items-center justify-center"> - Select a task to view its workflow pipeline - </div> - )} - </PageContent> - </PageLayout> - ); -} diff --git a/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage/WorkflowPipelinePage.tsx b/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage/WorkflowPipelinePage.tsx new file mode 100644 index 00000000..9019e497 --- /dev/null +++ b/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage/WorkflowPipelinePage.tsx @@ -0,0 +1,131 @@ +/** + * WorkflowPipelinePage — Main page assembling TaskSelector, PipelineDiagram, and step panels. + * Wires store state, mutations, and event hooks together. + */ + +import type { ProgressTask } from '@shared/types/progress'; + +import { PageContent, PageHeader, PageLayout } from '@ui'; + +import { PipelineDiagram } from '../PipelineDiagram'; +import { BacklogPanel } from '../step-panels/BacklogPanel'; +import { DonePanel } from '../step-panels/DonePanel'; +import { ErrorPanel } from '../step-panels/ErrorPanel'; +import { PlanningPanel } from '../step-panels/PlanningPanel'; +import { PlanReadyPanel } from '../step-panels/PlanReadyPanel'; +import { QueuedPanel } from '../step-panels/QueuedPanel'; +import { ReviewPanel } from '../step-panels/ReviewPanel'; +import { RunningPanel } from '../step-panels/RunningPanel'; +import { TaskSelector } from '../TaskSelector'; + +import { useWorkflowPipelinePage } from './useWorkflowPipelinePage'; + +function renderStepPanel( + step: string, + task: ProgressTask, + onSaveDescription: (text: string) => void, + onSavePlan: (text: string) => void, + savingDescription: boolean, + savingPlan: boolean, +): React.ReactNode { + switch (step) { + case 'backlog': { + return ( + <BacklogPanel + saving={savingDescription} + task={task} + onSaveDescription={onSaveDescription} + /> + ); + } + case 'planning': { + return <PlanningPanel task={task} />; + } + case 'plan_ready': { + return <PlanReadyPanel saving={savingPlan} task={task} onSavePlan={onSavePlan} />; + } + case 'queued': { + return <QueuedPanel task={task} />; + } + case 'running': { + return <RunningPanel task={task} />; + } + case 'review': { + return <ReviewPanel task={task} />; + } + case 'done': { + return <DonePanel task={task} />; + } + case 'error': { + return <ErrorPanel task={task} />; + } + default: { + return ( + <p className="text-muted-foreground text-sm"> + No panel available for step "{step}". + </p> + ); + } + } +} + +export function WorkflowPipelinePage() { + const { + projectId, + selectedSlug, + setSelectedSlug, + task, + selectedStep, + updateDescription, + updatePlan, + handleStepClick, + handleSaveDescription, + handleSavePlan, + } = useWorkflowPipelinePage(); + + return ( + <PageLayout> + <PageHeader> + <PageHeader.Row> + <PageHeader.Title>Workflow Pipeline</PageHeader.Title> + <PageHeader.Actions> + <TaskSelector + projectId={projectId} + selectedSlug={selectedSlug} + onSelectTask={setSelectedSlug} + /> + </PageHeader.Actions> + </PageHeader.Row> + </PageHeader> + + <PageContent> + {task ? ( + <div className="space-y-6"> + <PipelineDiagram + selectedStep={selectedStep} + taskStatus={task.status} + onStepClick={handleStepClick} + /> + + <div className="bg-card border-border flex-1 overflow-auto rounded-lg border p-6"> + {selectedStep + ? renderStepPanel( + selectedStep, + task, + handleSaveDescription, + handleSavePlan, + updateDescription.isPending, + updatePlan.isPending, + ) + : null} + </div> + </div> + ) : ( + <div className="text-muted-foreground flex flex-1 items-center justify-center"> + Select a task to view its workflow pipeline + </div> + )} + </PageContent> + </PageLayout> + ); +} diff --git a/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage/index.ts b/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage/index.ts new file mode 100644 index 00000000..41ab28b3 --- /dev/null +++ b/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage/index.ts @@ -0,0 +1 @@ +export { WorkflowPipelinePage } from './WorkflowPipelinePage'; diff --git a/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage/useWorkflowPipelinePage.ts b/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage/useWorkflowPipelinePage.ts new file mode 100644 index 00000000..500da960 --- /dev/null +++ b/src/renderer/features/workflow-pipeline/components/WorkflowPipelinePage/useWorkflowPipelinePage.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { ProgressStatus, ProgressTask } from '@shared/types/progress'; + +import { useLooseParams } from '@renderer/shared/hooks'; + +import { useProgressTask } from '@features/tasks/api/useProgress'; + +import { useUpdateTaskDescription, useUpdateTaskPlan } from '../../api/useUpdateTask'; +import { useWorkflowPipelineEvents } from '../../hooks/useWorkflowPipelineEvents'; +import { useWorkflowPipelineStore } from '../../store'; + +function statusToStepKey(status: ProgressStatus): string { + if (status === 'researching' || status === 'research_done') return 'planning'; + if (status === 'executing') return 'running'; + return status; +} + +export function useWorkflowPipelinePage() { + const params = useLooseParams(); + const projectId = params.projectId ?? ''; + + const [selectedSlug, setSelectedSlug] = useState<string | null>(null); + const { data: task } = useProgressTask(selectedSlug); + + const { selectedStep, setSelectedStep } = useWorkflowPipelineStore(); + + const updateDescription = useUpdateTaskDescription(); + const updatePlan = useUpdateTaskPlan(); + + useWorkflowPipelineEvents(); + + useEffect(() => { + if (task) { + setSelectedStep(statusToStepKey(task.status)); + } + }, [task, setSelectedStep]); + + const handleStepClick = useCallback( + (step: string) => { + setSelectedStep(step); + }, + [setSelectedStep], + ); + + const handleSaveDescription = useCallback( + (text: string) => { + if (selectedSlug) { + updateDescription.mutate({ slug: selectedSlug, description: text }); + } + }, + [selectedSlug, updateDescription], + ); + + const handleSavePlan = useCallback( + (text: string) => { + if (selectedSlug) { + updatePlan.mutate({ slug: selectedSlug, planContent: text }); + } + }, + [selectedSlug, updatePlan], + ); + + return { + projectId, + selectedSlug, + setSelectedSlug, + task: task as ProgressTask | undefined, + selectedStep, + updateDescription, + updatePlan, + handleStepClick, + handleSaveDescription, + handleSavePlan, + // Derived mutation states + isSavingDescription: updateDescription.isPending, + isSavingPlan: updatePlan.isPending, + }; +} diff --git a/src/renderer/features/workflow/components/WorkflowPermissionModal.tsx b/src/renderer/features/workflow/components/WorkflowPermissionModal.tsx deleted file mode 100644 index d5b75acd..00000000 --- a/src/renderer/features/workflow/components/WorkflowPermissionModal.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/** - * WorkflowPermissionModal — High-priority notification for agent permission requests. - * - * Listens for event:workflow.permission events and opens a blocking modal. - * Multiple events are queued — each must be dismissed before the next appears. - * Hard to miss: non-dismissable via backdrop click or Escape key. - * - * Note: This is an informational notification. The permission event is emitted - * by the plugin when an agent requires user awareness; it does not block the - * agent waiting for a response. - */ - -import { useCallback, useState } from 'react'; - -import { Bell } from 'lucide-react'; - -import { WORKFLOW_EVENTS } from '@shared/ipc/workflow/channels'; - -import { useIpcEvent } from '@renderer/shared/hooks'; - -import { Button } from '@ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@ui/dialog'; - -interface PermissionRequest { - id: string; - ticket: string; - agent: string; - message: string; -} - -export function WorkflowPermissionModal() { - const [queue, setQueue] = useState<PermissionRequest[]>([]); - - useIpcEvent(WORKFLOW_EVENTS.WORKFLOW.PERMISSION, (payload) => { - const request: PermissionRequest = { - id: `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - ticket: payload.ticket, - agent: payload.agent, - message: payload.message, - }; - setQueue((prev) => [...prev, request]); - }); - - const handleDismiss = useCallback(() => { - setQueue((prev) => prev.slice(1)); - }, []); - - const handlePointerDownOutside = useCallback((e: Event) => { - e.preventDefault(); - }, []); - - const handleEscapeKeyDown = useCallback((e: KeyboardEvent) => { - e.preventDefault(); - }, []); - - if (queue.length === 0) { - return null; - } - - const current = queue[0]; - - return ( - <Dialog - open - onOpenChange={() => { - // Intentionally no-op: only the Dismiss button may close this modal - }} - > - <DialogContent - className="max-w-md" - onEscapeKeyDown={handleEscapeKeyDown} - onPointerDownOutside={handlePointerDownOutside} - > - <DialogHeader> - <div className="flex items-center gap-3"> - <div className="bg-primary/10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full"> - <Bell aria-hidden="true" className="text-primary h-5 w-5" /> - </div> - <div> - <DialogTitle>Agent notification</DialogTitle> - <DialogDescription className="mt-0.5 text-xs"> - <span className="font-medium">{current.agent}</span> - <span className="text-muted-foreground"> · {current.ticket}</span> - </DialogDescription> - </div> - </div> - </DialogHeader> - - <p className="text-foreground py-1 text-sm">{current.message}</p> - - {queue.length > 1 ? ( - <p aria-live="polite" className="text-muted-foreground text-xs"> - {queue.length - 1} more notification{queue.length - 1 === 1 ? '' : 's'} waiting - </p> - ) : null} - - <DialogFooter> - <Button size="md" variant="primary" onClick={handleDismiss}> - Dismiss - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} diff --git a/src/renderer/features/workflow/components/WorkflowPermissionModal/WorkflowPermissionModal.tsx b/src/renderer/features/workflow/components/WorkflowPermissionModal/WorkflowPermissionModal.tsx new file mode 100644 index 00000000..c9708fd7 --- /dev/null +++ b/src/renderer/features/workflow/components/WorkflowPermissionModal/WorkflowPermissionModal.tsx @@ -0,0 +1,84 @@ +/** + * WorkflowPermissionModal — High-priority notification for agent permission requests. + * + * Listens for event:workflow.permission events and opens a blocking modal. + * Multiple events are queued — each must be dismissed before the next appears. + * Hard to miss: non-dismissable via backdrop click or Escape key. + * + * Note: This is an informational notification. The permission event is emitted + * by the plugin when an agent requires user awareness; it does not block the + * agent waiting for a response. + */ + +import { Bell } from 'lucide-react'; + +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@ui'; + +import { useWorkflowPermissionModal } from './useWorkflowPermissionModal'; + +export function WorkflowPermissionModal() { + const { + queue, + handleDismiss, + handlePointerDownOutside, + handleEscapeKeyDown, + } = useWorkflowPermissionModal(); + + if (queue.length === 0) { + return null; + } + + const current = queue[0]; + + return ( + <Dialog + open + onOpenChange={() => { + // Intentionally no-op: only the Dismiss button may close this modal + }} + > + <DialogContent + className="max-w-md" + onEscapeKeyDown={handleEscapeKeyDown} + onPointerDownOutside={handlePointerDownOutside} + > + <DialogHeader> + <div className="flex items-center gap-3"> + <div className="bg-primary/10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full"> + <Bell aria-hidden="true" className="text-primary h-5 w-5" /> + </div> + <div> + <DialogTitle>Agent notification</DialogTitle> + <DialogDescription className="mt-0.5 text-xs"> + <span className="font-medium">{current.agent}</span> + <span className="text-muted-foreground"> · {current.ticket}</span> + </DialogDescription> + </div> + </div> + </DialogHeader> + + <p className="text-foreground py-1 text-sm">{current.message}</p> + + {queue.length > 1 ? ( + <p aria-live="polite" className="text-muted-foreground text-xs"> + {queue.length - 1} more notification{queue.length - 1 === 1 ? '' : 's'} waiting + </p> + ) : null} + + <DialogFooter> + <Button size="md" variant="primary" onClick={handleDismiss}> + Dismiss + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/features/workflow/components/WorkflowPermissionModal/index.ts b/src/renderer/features/workflow/components/WorkflowPermissionModal/index.ts new file mode 100644 index 00000000..b4e28b99 --- /dev/null +++ b/src/renderer/features/workflow/components/WorkflowPermissionModal/index.ts @@ -0,0 +1 @@ +export { WorkflowPermissionModal } from './WorkflowPermissionModal'; diff --git a/src/renderer/features/workflow/components/WorkflowPermissionModal/useWorkflowPermissionModal.ts b/src/renderer/features/workflow/components/WorkflowPermissionModal/useWorkflowPermissionModal.ts new file mode 100644 index 00000000..0ce20c97 --- /dev/null +++ b/src/renderer/features/workflow/components/WorkflowPermissionModal/useWorkflowPermissionModal.ts @@ -0,0 +1,45 @@ +import { useCallback, useState } from 'react'; + +import { WORKFLOW_EVENTS } from '@shared/ipc/workflow/channels'; + +import { useIpcEvent } from '@renderer/shared/hooks'; + +export interface PermissionRequest { + id: string; + ticket: string; + agent: string; + message: string; +} + +export function useWorkflowPermissionModal() { + const [queue, setQueue] = useState<PermissionRequest[]>([]); + + useIpcEvent(WORKFLOW_EVENTS.WORKFLOW.PERMISSION, (payload) => { + const request: PermissionRequest = { + id: `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ticket: payload.ticket, + agent: payload.agent, + message: payload.message, + }; + setQueue((prev) => [...prev, request]); + }); + + const handleDismiss = useCallback(() => { + setQueue((prev) => prev.slice(1)); + }, []); + + const handlePointerDownOutside = useCallback((e: Event) => { + e.preventDefault(); + }, []); + + const handleEscapeKeyDown = useCallback((e: KeyboardEvent) => { + e.preventDefault(); + }, []); + + return { + queue, + handleDismiss, + handlePointerDownOutside, + handleEscapeKeyDown, + }; +} diff --git a/src/renderer/features/workflow/hooks/useWorkflowEvents.ts b/src/renderer/features/workflow/hooks/useWorkflowEvents.ts deleted file mode 100644 index 3b8fff24..00000000 --- a/src/renderer/features/workflow/hooks/useWorkflowEvents.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Workflow IPC event listeners → store updates - * - * Bridges real-time task progress events from the main process - * to the workflow UI store. - */ - -import { useQueryClient } from '@tanstack/react-query'; - -import { TASKS_EVENTS } from '@shared/ipc/tasks/channels'; - -import { useIpcEvent } from '@renderer/shared/hooks'; - -import { taskKeys } from '@features/tasks/api/queryKeys'; - -/** Subscribe to workflow-related IPC events and update caches */ -export function useWorkflowEvents() { - const queryClient = useQueryClient(); - - useIpcEvent(TASKS_EVENTS.PROGRESS.UPDATED, ({ taskId }) => { - void queryClient.invalidateQueries({ queryKey: taskKeys.detail(taskId) }); - void queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }); -} diff --git a/src/renderer/features/workflow/index.ts b/src/renderer/features/workflow/index.ts index 934535d7..d16d0f93 100644 --- a/src/renderer/features/workflow/index.ts +++ b/src/renderer/features/workflow/index.ts @@ -16,9 +16,6 @@ export { workflowKeys } from './api/queryKeys'; export { WorkflowPermissionModal } from './components/WorkflowPermissionModal'; export { WorkflowStatusBar } from './components/WorkflowStatusBar'; -// Events -export { useWorkflowEvents } from './hooks/useWorkflowEvents'; - // Hooks export { useWorkflowContext } from './hooks/useWorkflowContext'; export { useWorkflowMilestones } from './hooks/useWorkflowMilestones'; diff --git a/src/renderer/features/workspace/api/useWorkspace.ts b/src/renderer/features/workspace/api/useWorkspace.ts index e62bb007..27cf435f 100644 --- a/src/renderer/features/workspace/api/useWorkspace.ts +++ b/src/renderer/features/workspace/api/useWorkspace.ts @@ -19,6 +19,7 @@ import { useEffect } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { WORKSPACE, WORKSPACE_EVENTS } from '@shared/ipc/workspace/channels'; +import type { AgentChatMessage } from '@shared/types/agent-dashboard'; import { useIpcEvent } from '@renderer/shared/hooks'; import { ipc } from '@renderer/shared/lib/ipc'; @@ -72,9 +73,36 @@ export function useWorkspaceInit(projectId: string | null, projectPath: string | /** Send a message to a workspace session. */ export function useWorkspaceSend() { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: ({ sessionId, message }: { sessionId: string; message: string }) => ipc(WORKSPACE.SEND.MESSAGE, { sessionId, message }), + + onMutate: ({ sessionId, message }) => { + const optimisticMessage: AgentChatMessage = { + id: crypto.randomUUID(), + agentId: sessionId, + role: 'user', + content: [{ type: 'text', text: message }], + timestamp: new Date().toISOString(), + }; + + queryClient.setQueryData<AgentChatMessage[]>( + ['agent-dashboard', 'messages', sessionId], + (old) => [...(old ?? []), optimisticMessage], + ); + + return { optimisticId: optimisticMessage.id }; + }, + + onError: (_err, { sessionId }, context) => { + if (!context) return; + queryClient.setQueryData<AgentChatMessage[]>( + ['agent-dashboard', 'messages', sessionId], + (old) => (old ?? []).filter((m) => m.id !== context.optimisticId), + ); + }, }); } diff --git a/src/renderer/features/workspaces/api/useWorkspaces.ts b/src/renderer/features/workspace/api/useWorkspaces.ts similarity index 93% rename from src/renderer/features/workspaces/api/useWorkspaces.ts rename to src/renderer/features/workspace/api/useWorkspaces.ts index 72b3df68..66f3144d 100644 --- a/src/renderer/features/workspaces/api/useWorkspaces.ts +++ b/src/renderer/features/workspace/api/useWorkspaces.ts @@ -4,12 +4,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { WORKSPACES } from '@shared/ipc/misc/workspaces.channels'; +import { WORKSPACES } from '@shared/ipc/workspaces'; import type { InvokeInput } from '@shared/ipc-contract'; import { ipc } from '@renderer/shared/lib/ipc'; -import { workspaceKeys } from './queryKeys'; +import { workspaceKeys } from './workspacesQueryKeys'; /** Fetch all workspaces */ export function useWorkspaces() { diff --git a/src/renderer/features/workspaces/api/queryKeys.ts b/src/renderer/features/workspace/api/workspacesQueryKeys.ts similarity index 100% rename from src/renderer/features/workspaces/api/queryKeys.ts rename to src/renderer/features/workspace/api/workspacesQueryKeys.ts diff --git a/src/renderer/features/workspace/components/PrimarySessionPanel.tsx b/src/renderer/features/workspace/components/PrimarySessionPanel.tsx deleted file mode 100644 index bda0a45a..00000000 --- a/src/renderer/features/workspace/components/PrimarySessionPanel.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/** - * PrimarySessionPanel — Left panel showing the always-on Primary Claude session. - * - * Renders streamed messages and a text input to send commands. - */ - -import { useEffect, useRef } from 'react'; - -import { useQuery } from '@tanstack/react-query'; -import { Send } from 'lucide-react'; - -import type { AgentChatMessage } from '@shared/types/agent-dashboard'; - -import { AgentChatPanel } from '@features/agent-dashboard'; - -import { Button } from '@ui/button'; -import { Input } from '@ui/input'; -import { StatusIndicator } from '@ui/status-indicator'; -import { ThinkingIndicator } from '@ui/thinking-indicator'; -import { Text } from '@ui/typography'; - -import { useWorkspaceSend } from '../api/useWorkspace'; -import { useSessionThinking } from '../hooks/useSessionThinking'; -import { messagesToChatItems } from '../lib/chat-utils'; -import { useWorkspaceStore } from '../store'; - -interface PrimarySessionPanelProps { - sessionId: string; - projectId: string; - projectName: string; - status: string; -} - -function sessionStatusVariant( - status: string, -): 'success' | 'warning' | 'error' | 'neutral' { - if (status === 'live') return 'success'; - if (status === 'restarting' || status === 'starting') return 'warning'; - return 'error'; -} - -export function PrimarySessionPanel({ - sessionId, - projectId: _projectId, - projectName, - status, -}: PrimarySessionPanelProps) { - const send = useWorkspaceSend(); - const isThinking = useSessionThinking(sessionId); - const draft = useWorkspaceStore((s) => s.inputDrafts[sessionId] ?? ''); - const setDraft = useWorkspaceStore((s) => s.setInputDraft); - const clearDraft = useWorkspaceStore((s) => s.clearInputDraft); - const scrollRef = useRef<HTMLDivElement>(null); - - // Reactively subscribe to the message cache (updated by useAgentDashboardEvents) - const { data: rawMessages = [] } = useQuery<AgentChatMessage[]>({ - queryKey: ['agent-dashboard', 'messages', sessionId], - queryFn: () => Promise.resolve([]), - staleTime: Infinity, - }); - - const chatItems = messagesToChatItems(rawMessages); - - // Auto-scroll on new messages - useEffect(() => { - scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); - }, [chatItems.length]); - - function handleSend() { - const message = draft.trim(); - if (message.length === 0 || status !== 'live') return; - send.mutate({ sessionId, message }); - clearDraft(sessionId); - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - } - - return ( - <div className="flex h-full flex-col"> - {/* Header */} - <div className="border-border flex h-10 items-center gap-2 border-b px-4"> - <StatusIndicator size="sm" variant={sessionStatusVariant(status)} /> - <Text className="font-medium" size="sm" variant="muted"> - Primary · {projectName} - </Text> - <Text className="ml-auto opacity-60" size="sm" variant="muted"> - claude-sonnet-4-6 - </Text> - </div> - - {/* Message stream */} - <div ref={scrollRef} className="min-h-0 flex-1"> - {chatItems.length === 0 ? ( - <div className="flex h-full items-center justify-center"> - <Text variant="muted">{status === 'starting' ? 'Starting session…' : 'Session ready. Send a message.'}</Text> - </div> - ) : ( - <AgentChatPanel messages={chatItems} /> - )} - </div> - - {/* Thinking indicator */} - {isThinking ? ( - <div className="border-border border-t px-4 py-2"> - <ThinkingIndicator label={`Primary · ${projectName}`} size="sm" /> - </div> - ) : null} - - {/* Input */} - <div className="border-border flex gap-2 border-t p-3"> - <Input - className="flex-1" - disabled={status !== 'live'} - placeholder={status === 'live' ? 'Ask Claude or give a command…' : `Session ${status}…`} - value={draft} - onChange={(e) => setDraft(sessionId, e.target.value)} - onKeyDown={handleKeyDown} - /> - <Button - disabled={draft.trim().length === 0 || status !== 'live' || send.isPending} - size="icon" - onClick={handleSend} - > - <Send className="h-4 w-4" /> - </Button> - </div> - </div> - ); -} diff --git a/src/renderer/features/workspace/components/PrimarySessionPanel/PrimarySessionPanel.tsx b/src/renderer/features/workspace/components/PrimarySessionPanel/PrimarySessionPanel.tsx new file mode 100644 index 00000000..9ff42086 --- /dev/null +++ b/src/renderer/features/workspace/components/PrimarySessionPanel/PrimarySessionPanel.tsx @@ -0,0 +1,102 @@ +/** + * PrimarySessionPanel — Left panel showing the always-on Primary Claude session. + * + * Renders streamed messages and a text input to send commands. + */ + +import { Send } from 'lucide-react'; + +import { Button, Input, Separator, StatusIndicator, Text, ThinkingIndicator } from '@ui'; + +import { AgentChatPanel } from '@features/agent-dashboard'; + +import { usePrimarySessionPanel } from './usePrimarySessionPanel'; + +interface PrimarySessionPanelProps { + sessionId: string; + projectId: string; + projectName: string; + status: string; +} + +function sessionStatusVariant( + status: string, +): 'success' | 'warning' | 'error' | 'neutral' { + if (status === 'live') return 'success'; + if (status === 'restarting' || status === 'starting') return 'warning'; + return 'error'; +} + +export function PrimarySessionPanel({ + sessionId, + projectId: _projectId, + projectName, + status, +}: PrimarySessionPanelProps) { + const { + send, + showThinking, + draft, + setDraft, + scrollRef, + chatItems, + handleSend, + handleKeyDown, + } = usePrimarySessionPanel({ sessionId, status }); + + return ( + <div className="flex h-full flex-col"> + {/* Header */} + <div className="border-border flex h-10 items-center gap-2 border-b px-4"> + <StatusIndicator size="sm" variant={sessionStatusVariant(status)} /> + <Text className="font-medium" size="sm" variant="muted"> + Primary · {projectName} + </Text> + <Text className="ml-auto opacity-60" size="sm" variant="muted"> + claude-sonnet-4-6 + </Text> + </div> + + {/* Message stream */} + <div ref={scrollRef} className="min-h-0 flex-1"> + {chatItems.length === 0 ? ( + <div className="flex h-full items-center justify-center"> + <Text variant="muted">{status === 'starting' ? 'Starting session…' : 'Session ready. Send a message.'}</Text> + </div> + ) : ( + <AgentChatPanel messages={chatItems} /> + )} + </div> + + {/* Thinking indicator */} + {showThinking ? ( + <> + <Separator /> + <div className="px-4 py-2"> + <ThinkingIndicator label={`Primary · ${projectName}`} size="sm" /> + </div> + </> + ) : null} + + {/* Input */} + <Separator /> + <div className="flex gap-2 p-3"> + <Input + className="flex-1" + disabled={status !== 'live'} + placeholder={status === 'live' ? 'Ask Claude or give a command…' : `Session ${status}…`} + value={draft} + onChange={(e) => setDraft(sessionId, e.target.value)} + onKeyDown={handleKeyDown} + /> + <Button + disabled={draft.trim().length === 0 || status !== 'live' || send.isPending} + size="icon" + onClick={handleSend} + > + <Send className="h-4 w-4" /> + </Button> + </div> + </div> + ); +} diff --git a/src/renderer/features/workspace/components/PrimarySessionPanel/index.ts b/src/renderer/features/workspace/components/PrimarySessionPanel/index.ts new file mode 100644 index 00000000..af5ed2ed --- /dev/null +++ b/src/renderer/features/workspace/components/PrimarySessionPanel/index.ts @@ -0,0 +1 @@ +export { PrimarySessionPanel } from './PrimarySessionPanel'; diff --git a/src/renderer/features/workspace/components/PrimarySessionPanel/usePrimarySessionPanel.ts b/src/renderer/features/workspace/components/PrimarySessionPanel/usePrimarySessionPanel.ts new file mode 100644 index 00000000..7ca74e81 --- /dev/null +++ b/src/renderer/features/workspace/components/PrimarySessionPanel/usePrimarySessionPanel.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from 'react'; + +import { useQuery } from '@tanstack/react-query'; + +import type { AgentChatMessage } from '@shared/types/agent-dashboard'; + +import { useWorkspaceSend } from '../../api/useWorkspace'; +import { useSessionMessageInput } from '../../hooks/useSessionMessageInput'; +import { useSessionThinking } from '../../hooks/useSessionThinking'; +import { messagesToChatItems } from '../../lib/chat-utils'; +import { useWorkspaceStore } from '../../store'; + +interface UsePrimarySessionPanelProps { + sessionId: string; + status: string; +} + +export function usePrimarySessionPanel({ sessionId, status }: UsePrimarySessionPanelProps) { + const send = useWorkspaceSend(); + const isThinking = useSessionThinking(sessionId); + const showThinking = send.isPending || isThinking; + const draft = useWorkspaceStore((s) => s.inputDrafts[sessionId] ?? ''); + const setDraft = useWorkspaceStore((s) => s.setInputDraft); + const clearDraft = useWorkspaceStore((s) => s.clearInputDraft); + const scrollRef = useRef<HTMLDivElement>(null); + + const { data: rawMessages = [] } = useQuery<AgentChatMessage[]>({ + queryKey: ['agent-dashboard', 'messages', sessionId], + queryFn: () => Promise.resolve([]), + staleTime: Infinity, + }); + + const chatItems = messagesToChatItems(rawMessages); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); + }, [chatItems.length]); + + const { handleSend, handleKeyDown } = useSessionMessageInput({ + sessionId, + draft, + status, + send, + clearDraft, + }); + + return { + send, + showThinking, + draft, + setDraft, + scrollRef, + chatItems, + handleSend, + handleKeyDown, + // Derived mutation states + isSending: send.isPending, + }; +} diff --git a/src/renderer/features/workspace/components/TeamLeadPanel.tsx b/src/renderer/features/workspace/components/TeamLeadPanel.tsx deleted file mode 100644 index 1f7d0400..00000000 --- a/src/renderer/features/workspace/components/TeamLeadPanel.tsx +++ /dev/null @@ -1,265 +0,0 @@ -/** - * TeamLeadPanel — A single Team Lead session card in the right column. - * - * Collapsible. Shows session status, message stream, and input. - * Mortal Team Leads (index >= 1) show a stop button. - */ - -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { useQuery } from '@tanstack/react-query'; -import { ChevronDown, ChevronUp, Send, X } from 'lucide-react'; - -import type { WorkspaceSession } from '@shared/ipc/workspace'; -import type { AgentChatMessage } from '@shared/types/agent-dashboard'; - -import { AgentChatPanel } from '@features/agent-dashboard'; - -import { Button } from '@ui/button'; -import { Input } from '@ui/input'; -import { StatusIndicator } from '@ui/status-indicator'; -import { ThinkingIndicator } from '@ui/thinking-indicator'; -import { Text } from '@ui/typography'; - -import { useStopTeamLead, useWorkspaceSend } from '../api/useWorkspace'; -import { useSessionThinking } from '../hooks/useSessionThinking'; -import { messagesToChatItems } from '../lib/chat-utils'; -import { useWorkspaceStore } from '../store'; - -interface TeamLeadPanelProps { - session: WorkspaceSession; -} - -function sessionStatusVariant( - status: string, -): 'success' | 'warning' | 'error' | 'neutral' { - if (status === 'live') return 'success'; - if (status === 'restarting' || status === 'starting') return 'warning'; - return 'error'; -} - -export function TeamLeadPanel({ session }: TeamLeadPanelProps) { - const { key, agentSessionId, status } = session; - const { projectId, index } = key; - const isImmortal = index === 0; - const label = index === 0 ? 'Team Lead 1' : `Team Lead ${index + 1}`; - - const send = useWorkspaceSend(); - const stop = useStopTeamLead(projectId); - const isThinking = useSessionThinking(agentSessionId); - const isCollapsed = useWorkspaceStore((s) => s.teamLeadCollapsed[agentSessionId] ?? false); - const toggle = useWorkspaceStore((s) => s.toggleTeamLeadCollapsed); - const draft = useWorkspaceStore((s) => s.inputDrafts[agentSessionId] ?? ''); - const setDraft = useWorkspaceStore((s) => s.setInputDraft); - const clearDraft = useWorkspaceStore((s) => s.clearInputDraft); - - const { data: rawMessages = [] } = useQuery<AgentChatMessage[]>({ - queryKey: ['agent-dashboard', 'messages', agentSessionId], - queryFn: () => Promise.resolve([]), - staleTime: Infinity, - }); - const chatItems = messagesToChatItems(rawMessages); - - function handleSend() { - const message = draft.trim(); - if (message.length === 0 || status !== 'live') return; - send.mutate({ sessionId: agentSessionId, message }); - clearDraft(agentSessionId); - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - } - - function handleStop() { - stop.mutate({ index }); - } - - /* ---- Drag-to-resize logic ---- */ - const DEFAULT_HEIGHT = 192; - const MIN_HEIGHT = 64; - - const [messageHeight, setMessageHeight] = useState(DEFAULT_HEIGHT); - const isDragging = useRef(false); - const dragFromTop = useRef(false); - const startY = useRef(0); - const startHeight = useRef(DEFAULT_HEIGHT); - const maxHeight = useRef(Infinity); - const cardRef = useRef<HTMLDivElement>(null); - const EDGE_ZONE = 6; // px from border edge that triggers resize cursor - - const handleDragMove = useCallback((e: MouseEvent) => { - if (!isDragging.current) return; - const rawDelta = e.clientY - startY.current; - // Top edge: drag up (negative delta) = grow; bottom edge: drag down (positive) = grow - const delta = dragFromTop.current ? -rawDelta : rawDelta; - const next = Math.min(maxHeight.current, Math.max(MIN_HEIGHT, startHeight.current + delta)); - setMessageHeight(next); - }, []); - - const handleDragEnd = useCallback(() => { - isDragging.current = false; - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - document.removeEventListener('mousemove', handleDragMove); - document.removeEventListener('mouseup', handleDragEnd); - }, [handleDragMove]); - - const getEdge = useCallback((e: React.MouseEvent): 'top' | 'bottom' | null => { - const card = cardRef.current; - if (!card) return null; - const rect = card.getBoundingClientRect(); - if (e.clientY - rect.top <= EDGE_ZONE) return 'top'; - if (rect.bottom - e.clientY <= EDGE_ZONE) return 'bottom'; - return null; - }, []); - - const computeMaxHeight = useCallback(() => { - const card = cardRef.current; - if (!card) return Infinity; - // Scroll container is the overflow-y-auto parent - const scrollParent = card.closest('[class*="overflow-y"]'); - if (!scrollParent || !(scrollParent instanceof HTMLElement)) return Infinity; - const containerHeight = scrollParent.clientHeight; - // Sum heights of all sibling cards (excluding this one) + gaps - const siblingCards = scrollParent.querySelectorAll(':scope > div > div'); - let othersHeight = 0; - for (const sibling of siblingCards) { - if (sibling !== card) { - othersHeight += (sibling as HTMLElement).offsetHeight; - } - } - // Account for padding (p-3 = 12px * 2) and gaps (space-y-3 = 12px per gap) - const gaps = Math.max(0, siblingCards.length - 1) * 12; - const padding = 24; - // Available for this card's message area = container - others - chrome - gaps - padding - // Card chrome = header (~40px) + input (~44px) + borders (~4px) - const cardChrome = card.offsetHeight - messageHeight; - return Math.max(MIN_HEIGHT, containerHeight - othersHeight - gaps - padding - cardChrome); - }, [messageHeight]); - - const handleCardMouseDown = useCallback( - (e: React.MouseEvent) => { - const edge = getEdge(e); - if (!edge) return; - e.preventDefault(); - isDragging.current = true; - dragFromTop.current = edge === 'top'; - startY.current = e.clientY; - startHeight.current = messageHeight; - maxHeight.current = computeMaxHeight(); - document.body.style.cursor = 'row-resize'; - document.body.style.userSelect = 'none'; - document.addEventListener('mousemove', handleDragMove); - document.addEventListener('mouseup', handleDragEnd); - }, - [messageHeight, handleDragMove, handleDragEnd, getEdge, computeMaxHeight], - ); - - const handleCardMouseMove = useCallback( - (e: React.MouseEvent) => { - const card = cardRef.current; - if (!card || isDragging.current) return; - card.style.cursor = getEdge(e) ? 'row-resize' : ''; - }, - [getEdge], - ); - - // Cleanup on unmount - useEffect(() => { - return () => { - document.removeEventListener('mousemove', handleDragMove); - document.removeEventListener('mouseup', handleDragEnd); - }; - }, [handleDragMove, handleDragEnd]); - - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- border-edge resize - <div - ref={cardRef} - className="border-border rounded-lg border" - onMouseDown={handleCardMouseDown} - onMouseMove={handleCardMouseMove} - > - {/* Card header */} - <div className="flex items-center gap-2 px-3 py-2"> - <StatusIndicator size="sm" variant={sessionStatusVariant(status)} /> - <Text className="font-medium" size="sm">{label}</Text> - {status === 'restarting' ? ( - <Text size="sm" variant="muted">restarting…</Text> - ) : null} - <div className="ml-auto flex items-center gap-1"> - {!isImmortal && ( - <Button - className="h-6 w-6" - disabled={stop.isPending} - size="icon" - title="Stop team lead" - variant="ghost" - onClick={handleStop} - > - <X className="h-3 w-3" /> - </Button> - )} - <Button - className="h-6 w-6" - size="icon" - variant="ghost" - onClick={() => toggle(agentSessionId)} - > - {isCollapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />} - </Button> - </div> - </div> - - {/* Collapsible body */} - {!isCollapsed && ( - <> - <div - className="border-border border-t" - style={{ height: messageHeight }} - > - {chatItems.length === 0 ? ( - <Text className="p-3" size="sm" variant="muted"> - {status === 'starting' ? 'Starting…' : 'Ready for a plan or instructions.'} - </Text> - ) : ( - <AgentChatPanel messages={chatItems} showHandOff={false} /> - )} - </div> - - {/* Thinking indicator */} - {isThinking ? ( - <div className="border-border border-t px-3 py-1.5"> - <ThinkingIndicator label={label} size="xs" /> - </div> - ) : null} - - {/* Input */} - <div className="border-border flex gap-2 border-t p-2"> - <Input - className="h-7 flex-1 text-xs" - disabled={status !== 'live'} - placeholder={status === 'live' ? 'Send to team lead…' : `${status}…`} - value={draft} - onChange={(e) => setDraft(agentSessionId, e.target.value)} - onKeyDown={handleKeyDown} - /> - <Button - className="h-7 w-7" - disabled={draft.trim().length === 0 || status !== 'live' || send.isPending} - size="icon" - onClick={handleSend} - > - <Send className="h-3 w-3" /> - </Button> - </div> - - </> - )} - </div> - ); -} diff --git a/src/renderer/features/workspace/components/TeamLeadPanel/TeamLeadPanel.tsx b/src/renderer/features/workspace/components/TeamLeadPanel/TeamLeadPanel.tsx new file mode 100644 index 00000000..8f794950 --- /dev/null +++ b/src/renderer/features/workspace/components/TeamLeadPanel/TeamLeadPanel.tsx @@ -0,0 +1,142 @@ +/** + * TeamLeadPanel — A single Team Lead session card in the right column. + * + * Collapsible. Shows session status, message stream, and input. + * Mortal Team Leads (index >= 1) show a stop button. + */ + +import { ChevronDown, ChevronUp, Send, X } from 'lucide-react'; + +import type { WorkspaceSession } from '@shared/ipc/workspace'; + +import { Button, Input, Separator, StatusIndicator, Text, ThinkingIndicator } from '@ui'; + +import { AgentChatPanel } from '@features/agent-dashboard'; + +import { useTeamLeadPanel } from './useTeamLeadPanel'; + +interface TeamLeadPanelProps { + session: WorkspaceSession; +} + +function sessionStatusVariant( + status: string, +): 'success' | 'warning' | 'error' | 'neutral' { + if (status === 'live') return 'success'; + if (status === 'restarting' || status === 'starting') return 'warning'; + return 'error'; +} + +export function TeamLeadPanel({ session }: TeamLeadPanelProps) { + const { + agentSessionId, + status, + label, + isImmortal, + send, + stop, + showThinking, + isCollapsed, + toggle, + draft, + setDraft, + chatItems, + messageHeight, + cardRef, + handleSend, + handleKeyDown, + handleStop, + handleCardMouseDown, + handleCardMouseMove, + } = useTeamLeadPanel(session); + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- border-edge resize + <div + ref={cardRef} + className="border-border rounded-lg border" + onMouseDown={handleCardMouseDown} + onMouseMove={handleCardMouseMove} + > + {/* Card header */} + <div className="flex items-center gap-2 px-3 py-2"> + <StatusIndicator size="sm" variant={sessionStatusVariant(status)} /> + <Text className="font-medium" size="sm">{label}</Text> + {status === 'restarting' ? ( + <Text size="sm" variant="muted">restarting…</Text> + ) : null} + <div className="ml-auto flex items-center gap-1"> + {!isImmortal && ( + <Button + className="h-6 w-6" + disabled={stop.isPending} + size="icon" + title="Stop team lead" + variant="ghost" + onClick={handleStop} + > + <X className="h-3 w-3" /> + </Button> + )} + <Button + className="h-6 w-6" + size="icon" + variant="ghost" + onClick={() => toggle(agentSessionId)} + > + {isCollapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />} + </Button> + </div> + </div> + + {/* Collapsible body */} + {!isCollapsed && ( + <> + <Separator /> + <div + style={{ height: messageHeight }} + > + {chatItems.length === 0 ? ( + <Text className="p-3" size="sm" variant="muted"> + {status === 'starting' ? 'Starting…' : 'Ready for a plan or instructions.'} + </Text> + ) : ( + <AgentChatPanel messages={chatItems} showHandOff={false} /> + )} + </div> + + {/* Thinking indicator */} + {showThinking ? ( + <> + <Separator /> + <div className="px-3 py-1.5"> + <ThinkingIndicator label={label} size="xs" /> + </div> + </> + ) : null} + + {/* Input */} + <Separator /> + <div className="flex gap-2 p-2"> + <Input + className="h-7 flex-1 text-xs" + disabled={status !== 'live'} + placeholder={status === 'live' ? 'Send to team lead…' : `${status}…`} + value={draft} + onChange={(e) => setDraft(agentSessionId, e.target.value)} + onKeyDown={handleKeyDown} + /> + <Button + className="h-7 w-7" + disabled={draft.trim().length === 0 || status !== 'live' || send.isPending} + size="icon" + onClick={handleSend} + > + <Send className="h-3 w-3" /> + </Button> + </div> + </> + )} + </div> + ); +} diff --git a/src/renderer/features/workspace/components/TeamLeadPanel/index.ts b/src/renderer/features/workspace/components/TeamLeadPanel/index.ts new file mode 100644 index 00000000..9097a41b --- /dev/null +++ b/src/renderer/features/workspace/components/TeamLeadPanel/index.ts @@ -0,0 +1 @@ +export { TeamLeadPanel } from './TeamLeadPanel'; diff --git a/src/renderer/features/workspace/components/TeamLeadPanel/useTeamLeadPanel.ts b/src/renderer/features/workspace/components/TeamLeadPanel/useTeamLeadPanel.ts new file mode 100644 index 00000000..678b696f --- /dev/null +++ b/src/renderer/features/workspace/components/TeamLeadPanel/useTeamLeadPanel.ts @@ -0,0 +1,81 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { WorkspaceSession } from '@shared/ipc/workspace'; +import type { AgentChatMessage } from '@shared/types/agent-dashboard'; + +import { useStopTeamLead, useWorkspaceSend } from '../../api/useWorkspace'; +import { useResizablePanel } from '../../hooks/useResizablePanel'; +import { useSessionMessageInput } from '../../hooks/useSessionMessageInput'; +import { useSessionThinking } from '../../hooks/useSessionThinking'; +import { messagesToChatItems } from '../../lib/chat-utils'; +import { useWorkspaceStore } from '../../store'; + +export function useTeamLeadPanel(session: WorkspaceSession) { + const { key, agentSessionId, status } = session; + const { projectId, index } = key; + const isImmortal = index === 0; + const label = index === 0 ? 'Team Lead 1' : `Team Lead ${index + 1}`; + + const send = useWorkspaceSend(); + const stop = useStopTeamLead(projectId); + const isThinking = useSessionThinking(agentSessionId); + const showThinking = send.isPending || isThinking; + + const isCollapsed = useWorkspaceStore((s) => s.teamLeadCollapsed[agentSessionId] ?? false); + const toggle = useWorkspaceStore((s) => s.toggleTeamLeadCollapsed); + const draft = useWorkspaceStore((s) => s.inputDrafts[agentSessionId] ?? ''); + const setDraft = useWorkspaceStore((s) => s.setInputDraft); + const clearDraft = useWorkspaceStore((s) => s.clearInputDraft); + + const { data: rawMessages = [] } = useQuery<AgentChatMessage[]>({ + queryKey: ['agent-dashboard', 'messages', agentSessionId], + queryFn: () => Promise.resolve([]), + staleTime: Infinity, + }); + const chatItems = messagesToChatItems(rawMessages); + + const { handleSend, handleKeyDown } = useSessionMessageInput({ + sessionId: agentSessionId, + draft, + status, + send, + clearDraft, + }); + + function handleStop() { + stop.mutate({ index }); + } + + /* ---- Drag-to-resize ---- */ + const { + height: messageHeight, + cardRef, + handleCardMouseDown, + handleCardMouseMove, + } = useResizablePanel({ defaultHeight: 192, minHeight: 64, edgeZone: 6 }); + + return { + agentSessionId, + status, + label, + isImmortal, + send, + stop, + showThinking, + isCollapsed, + toggle, + draft, + setDraft, + chatItems, + messageHeight, + cardRef, + handleSend, + handleKeyDown, + handleStop, + handleCardMouseDown, + handleCardMouseMove, + // Derived mutation states + isSending: send.isPending, + isStopping: stop.isPending, + }; +} diff --git a/src/renderer/features/workspace/components/TeamLeadPanelList.tsx b/src/renderer/features/workspace/components/TeamLeadPanelList/TeamLeadPanelList.tsx similarity index 88% rename from src/renderer/features/workspace/components/TeamLeadPanelList.tsx rename to src/renderer/features/workspace/components/TeamLeadPanelList/TeamLeadPanelList.tsx index 9fc4ff66..20a4182a 100644 --- a/src/renderer/features/workspace/components/TeamLeadPanelList.tsx +++ b/src/renderer/features/workspace/components/TeamLeadPanelList/TeamLeadPanelList.tsx @@ -7,12 +7,10 @@ import { Plus } from 'lucide-react'; import type { WorkspaceSession } from '@shared/ipc/workspace'; -import { Button } from '@ui/button'; -import { Text } from '@ui/typography'; +import { Button, Text } from '@ui'; -import { useSpawnTeamLead } from '../api/useWorkspace'; - -import { TeamLeadPanel } from './TeamLeadPanel'; +import { useSpawnTeamLead } from '../../api/useWorkspace'; +import { TeamLeadPanel } from '../TeamLeadPanel'; interface TeamLeadPanelListProps { projectId: string; diff --git a/src/renderer/features/workspace/components/TeamLeadPanelList/index.ts b/src/renderer/features/workspace/components/TeamLeadPanelList/index.ts new file mode 100644 index 00000000..52be717d --- /dev/null +++ b/src/renderer/features/workspace/components/TeamLeadPanelList/index.ts @@ -0,0 +1 @@ +export { TeamLeadPanelList } from './TeamLeadPanelList'; diff --git a/src/renderer/features/workspace/components/WorkspacePage.tsx b/src/renderer/features/workspace/components/WorkspacePage.tsx deleted file mode 100644 index 3b83090a..00000000 --- a/src/renderer/features/workspace/components/WorkspacePage.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/** - * WorkspacePage — Primary work area for each project. - * - * Left panel (55%): Primary Claude session. - * Right column (45%): Team Lead sessions + spawn button. - * Panels are resizable via drag handle. - */ - -import { useEffect } from 'react'; - -import { Group, Panel, Separator } from 'react-resizable-panels'; - -import { useLooseParams } from '@renderer/shared/hooks'; - -import { PageHeader, PageLayout } from '@ui'; - - -import { useAgentDashboardEvents } from '@features/agent-dashboard'; -import { useProjects } from '@features/projects'; - -import { useWorkspaceInit, useWorkspaceSessions } from '../api/useWorkspace'; -import { useWorkspaceStore } from '../store'; - -import { PrimarySessionPanel } from './PrimarySessionPanel'; -import { TeamLeadPanelList } from './TeamLeadPanelList'; - -export function WorkspacePage() { - useAgentDashboardEvents(); - const { projectId } = useLooseParams(); - const { data: projects } = useProjects(); - const setViewing = useWorkspaceStore((s) => s.setViewingProject); - - const project = projects?.find((p) => p.id === projectId); - const projectPath = project?.path ?? null; - const projectName = project?.name ?? 'Project'; - - useEffect(() => { - if (projectId !== undefined) setViewing(projectId); - }, [projectId, setViewing]); - - useWorkspaceInit(projectId ?? null, projectPath); - - const { data: sessions = [] } = useWorkspaceSessions(projectId ?? null); - - const primarySession = sessions.find((s) => s.key.type === 'primary'); - - if (projectId === undefined) { - return ( - <PageLayout> - <PageHeader> - <PageHeader.Row> - <PageHeader.Title>Workspace</PageHeader.Title> - </PageHeader.Row> - </PageHeader> - <div className="text-muted-foreground flex h-full items-center justify-center text-sm"> - Open a project to start a workspace session. - </div> - </PageLayout> - ); - } - - return ( - <PageLayout> - <PageHeader> - <PageHeader.Row> - <PageHeader.Title>Workspace</PageHeader.Title> - </PageHeader.Row> - </PageHeader> - - <Group className="h-full" orientation="horizontal"> - {/* Primary Claude — left panel */} - <Panel defaultSize={55} minSize={30}> - {primarySession === undefined ? ( - <div className="text-muted-foreground flex h-full items-center justify-center text-sm"> - Starting primary session… - </div> - ) : ( - <PrimarySessionPanel - projectId={projectId} - projectName={projectName} - sessionId={primarySession.agentSessionId} - status={primarySession.status} - /> - )} - </Panel> - - <Separator className="bg-border w-px cursor-col-resize transition-colors hover:bg-primary/30" /> - - {/* Team Leads — right panel */} - <Panel defaultSize={45} minSize={25}> - <div className="h-full"> - <TeamLeadPanelList projectId={projectId} sessions={sessions} /> - </div> - </Panel> - </Group> - </PageLayout> - ); -} diff --git a/src/renderer/features/workspace/components/WorkspacePage/WorkspacePage.tsx b/src/renderer/features/workspace/components/WorkspacePage/WorkspacePage.tsx new file mode 100644 index 00000000..8ce965f0 --- /dev/null +++ b/src/renderer/features/workspace/components/WorkspacePage/WorkspacePage.tsx @@ -0,0 +1,72 @@ +/** + * WorkspacePage — Primary work area for each project. + * + * Left panel (55%): Primary Claude session. + * Right column (45%): Team Lead sessions + spawn button. + * Panels are resizable via drag handle. + */ + +import { Group, Panel, Separator } from 'react-resizable-panels'; + +import { PageHeader, PageLayout } from '@ui'; + +import { PrimarySessionPanel } from '../PrimarySessionPanel'; +import { TeamLeadPanelList } from '../TeamLeadPanelList'; + +import { useWorkspacePage } from './useWorkspacePage'; + +export function WorkspacePage() { + const { projectId, projectName, sessions, primarySession } = useWorkspacePage(); + + if (projectId === undefined) { + return ( + <PageLayout> + <PageHeader> + <PageHeader.Row> + <PageHeader.Title>Workspace</PageHeader.Title> + </PageHeader.Row> + </PageHeader> + <div className="text-muted-foreground flex h-full items-center justify-center text-sm"> + Open a project to start a workspace session. + </div> + </PageLayout> + ); + } + + return ( + <PageLayout> + <PageHeader> + <PageHeader.Row> + <PageHeader.Title>Workspace</PageHeader.Title> + </PageHeader.Row> + </PageHeader> + + <Group className="h-full" orientation="horizontal"> + {/* Primary Claude — left panel */} + <Panel defaultSize={55} minSize={30}> + {primarySession === undefined ? ( + <div className="text-muted-foreground flex h-full items-center justify-center text-sm"> + Starting primary session… + </div> + ) : ( + <PrimarySessionPanel + projectId={projectId} + projectName={projectName} + sessionId={primarySession.agentSessionId} + status={primarySession.status} + /> + )} + </Panel> + + <Separator className="bg-border w-px cursor-col-resize transition-colors hover:bg-primary/30" /> + + {/* Team Leads — right panel */} + <Panel defaultSize={45} minSize={25}> + <div className="h-full"> + <TeamLeadPanelList projectId={projectId} sessions={sessions} /> + </div> + </Panel> + </Group> + </PageLayout> + ); +} diff --git a/src/renderer/features/workspace/components/WorkspacePage/index.ts b/src/renderer/features/workspace/components/WorkspacePage/index.ts new file mode 100644 index 00000000..74c573cf --- /dev/null +++ b/src/renderer/features/workspace/components/WorkspacePage/index.ts @@ -0,0 +1 @@ +export { WorkspacePage } from './WorkspacePage'; diff --git a/src/renderer/features/workspace/components/WorkspacePage/useWorkspacePage.ts b/src/renderer/features/workspace/components/WorkspacePage/useWorkspacePage.ts new file mode 100644 index 00000000..10852088 --- /dev/null +++ b/src/renderer/features/workspace/components/WorkspacePage/useWorkspacePage.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; + +import { useLooseParams } from '@renderer/shared/hooks'; + +import { useAgentDashboardEvents } from '@features/agent-dashboard'; +import { useProjects } from '@features/projects'; + +import { useWorkspaceInit, useWorkspaceSessions } from '../../api/useWorkspace'; +import { useWorkspaceStore } from '../../store'; + +export function useWorkspacePage() { + useAgentDashboardEvents(); + const { projectId } = useLooseParams(); + const { data: projects } = useProjects(); + const setViewing = useWorkspaceStore((s) => s.setViewingProject); + + const project = projects?.find((p) => p.id === projectId); + const projectPath = project?.path ?? null; + const projectName = project?.name ?? 'Project'; + + useEffect(() => { + if (projectId !== undefined) setViewing(projectId); + }, [projectId, setViewing]); + + useWorkspaceInit(projectId ?? null, projectPath); + + const { data: sessions = [] } = useWorkspaceSessions(projectId ?? null); + + const primarySession = sessions.find((s) => s.key.type === 'primary'); + + return { + projectId, + projectName, + sessions, + primarySession, + }; +} diff --git a/src/renderer/features/workspace/hooks/useResizablePanel.ts b/src/renderer/features/workspace/hooks/useResizablePanel.ts new file mode 100644 index 00000000..f4454ccb --- /dev/null +++ b/src/renderer/features/workspace/hooks/useResizablePanel.ts @@ -0,0 +1,113 @@ +/** + * useResizablePanel — Drag-to-resize logic for card panels. + * + * Extracts the ~70 lines of drag logic from useTeamLeadPanel into a + * reusable hook. Supports both top-edge and bottom-edge drag handles + * with configurable default, min height, and edge detection zone. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface UseResizablePanelOpts { + defaultHeight?: number; + minHeight?: number; + edgeZone?: number; +} + +export function useResizablePanel(opts: UseResizablePanelOpts = {}) { + const { defaultHeight = 192, minHeight = 64, edgeZone = 6 } = opts; + + const [height, setHeight] = useState(defaultHeight); + const isDragging = useRef(false); + const dragFromTop = useRef(false); + const startY = useRef(0); + const startHeight = useRef(defaultHeight); + const maxHeight = useRef(Infinity); + const cardRef = useRef<HTMLDivElement>(null); + + const getEdge = useCallback( + (e: React.MouseEvent): 'top' | 'bottom' | null => { + const card = cardRef.current; + if (!card) return null; + const rect = card.getBoundingClientRect(); + if (e.clientY - rect.top <= edgeZone) return 'top'; + if (rect.bottom - e.clientY <= edgeZone) return 'bottom'; + return null; + }, + [edgeZone], + ); + + const computeMaxHeight = useCallback(() => { + const card = cardRef.current; + if (!card) return Infinity; + const scrollParent = card.closest('[class*="overflow-y"]'); + if (!scrollParent || !(scrollParent instanceof HTMLElement)) return Infinity; + const containerHeight = scrollParent.clientHeight; + const siblingCards = scrollParent.querySelectorAll(':scope > div > div'); + let othersHeight = 0; + for (const sibling of siblingCards) { + if (sibling !== card) { + othersHeight += (sibling as HTMLElement).offsetHeight; + } + } + const gaps = Math.max(0, siblingCards.length - 1) * 12; + const padding = 24; + const cardChrome = card.offsetHeight - height; + return Math.max(minHeight, containerHeight - othersHeight - gaps - padding - cardChrome); + }, [height, minHeight]); + + const handleDragMove = useCallback( + (e: MouseEvent) => { + if (!isDragging.current) return; + const rawDelta = e.clientY - startY.current; + const delta = dragFromTop.current ? -rawDelta : rawDelta; + const next = Math.min(maxHeight.current, Math.max(minHeight, startHeight.current + delta)); + setHeight(next); + }, + [minHeight], + ); + + const handleDragEnd = useCallback(() => { + isDragging.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', handleDragMove); + document.removeEventListener('mouseup', handleDragEnd); + }, [handleDragMove]); + + const handleCardMouseDown = useCallback( + (e: React.MouseEvent) => { + const edge = getEdge(e); + if (!edge) return; + e.preventDefault(); + isDragging.current = true; + dragFromTop.current = edge === 'top'; + startY.current = e.clientY; + startHeight.current = height; + maxHeight.current = computeMaxHeight(); + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', handleDragMove); + document.addEventListener('mouseup', handleDragEnd); + }, + [height, handleDragMove, handleDragEnd, getEdge, computeMaxHeight], + ); + + const handleCardMouseMove = useCallback( + (e: React.MouseEvent) => { + const card = cardRef.current; + if (!card || isDragging.current) return; + card.style.cursor = getEdge(e) ? 'row-resize' : ''; + }, + [getEdge], + ); + + useEffect(() => { + return () => { + document.removeEventListener('mousemove', handleDragMove); + document.removeEventListener('mouseup', handleDragEnd); + }; + }, [handleDragMove, handleDragEnd]); + + return { height, cardRef, handleCardMouseDown, handleCardMouseMove }; +} diff --git a/src/renderer/features/workspace/hooks/useSessionMessageInput.ts b/src/renderer/features/workspace/hooks/useSessionMessageInput.ts new file mode 100644 index 00000000..22fff7b4 --- /dev/null +++ b/src/renderer/features/workspace/hooks/useSessionMessageInput.ts @@ -0,0 +1,39 @@ +/** + * useSessionMessageInput — Shared send + keyboard handling for session panels. + * + * Extracts the identical handleSend / handleKeyDown logic found in both + * PrimarySessionPanel and TeamLeadPanel into a single reusable hook. + */ + +import { useCallback } from 'react'; + +interface UseSessionMessageInputOpts { + sessionId: string; + draft: string; + status: string; + send: { mutate: (payload: { sessionId: string; message: string }) => void }; + clearDraft: (sessionId: string) => void; +} + +export function useSessionMessageInput(opts: UseSessionMessageInputOpts) { + const { sessionId, draft, status, send, clearDraft } = opts; + + const handleSend = useCallback(() => { + const message = draft.trim(); + if (message.length === 0 || status !== 'live') return; + send.mutate({ sessionId, message }); + clearDraft(sessionId); + }, [sessionId, draft, status, send, clearDraft]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + return { handleSend, handleKeyDown }; +} diff --git a/src/renderer/features/workspace/index.ts b/src/renderer/features/workspace/index.ts index ba68378b..9b9d1382 100644 --- a/src/renderer/features/workspace/index.ts +++ b/src/renderer/features/workspace/index.ts @@ -1,2 +1,24 @@ -/** Workspace feature — public API */ +/** + * workspace — Runtime session layer + SQLite entity CRUD layer. + * + * Session layer: spawning and stopping agent team-lead sessions for a project. + * IPC domain: WORKSPACE.* (src/shared/ipc/workspace/) + * Backend: busSessionManager (command bus, NOT SQLite CRUD) + * + * CRUD layer: manage Workspace records in the database (create/read/update/delete). + * IPC domain: WORKSPACES.* (src/shared/ipc/workspaces/) + * Backend: WorkspacesService + migration 0014_add_workspaces_table + */ export { WorkspacePage } from './components/WorkspacePage'; + +// API hooks (CRUD) +export { + useWorkspaces, + useCreateWorkspace, + useUpdateWorkspace, + useDeleteWorkspace, +} from './api/useWorkspaces'; +export { workspaceKeys } from './api/workspacesQueryKeys'; + +// Store (persisted active workspace selection) +export { useWorkspaceStore as useWorkspacesStore } from './workspaces-store'; diff --git a/src/renderer/features/workspaces/store.ts b/src/renderer/features/workspace/workspaces-store.ts similarity index 100% rename from src/renderer/features/workspaces/store.ts rename to src/renderer/features/workspace/workspaces-store.ts diff --git a/src/renderer/features/workspaces/hooks/useWorkspaceEvents.ts b/src/renderer/features/workspaces/hooks/useWorkspaceEvents.ts deleted file mode 100644 index a944877f..00000000 --- a/src/renderer/features/workspaces/hooks/useWorkspaceEvents.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Workspace IPC event listeners → query invalidation - * - * Bridges Hub WebSocket events to React Query cache for workspaces. - */ - -import { useQueryClient } from '@tanstack/react-query'; - -import { HUB_EVENTS } from '@shared/ipc/hub/channels'; - -import { useHubEvent } from '@renderer/shared/hooks'; - -import { workspaceKeys } from '../api/queryKeys'; - -/** Subscribe to hub workspace events and invalidate queries */ -export function useWorkspaceEvents() { - const queryClient = useQueryClient(); - - useHubEvent(HUB_EVENTS.WORKSPACE.UPDATED, () => { - void queryClient.invalidateQueries({ queryKey: workspaceKeys.list() }); - }); -} diff --git a/src/renderer/features/workspaces/index.ts b/src/renderer/features/workspaces/index.ts deleted file mode 100644 index b16a2a0f..00000000 --- a/src/renderer/features/workspaces/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Workspaces feature — public API - */ - -// API hooks -export { - useWorkspaces, - useCreateWorkspace, - useUpdateWorkspace, - useDeleteWorkspace, -} from './api/useWorkspaces'; -export { workspaceKeys } from './api/queryKeys'; - -// Events -export { useWorkspaceEvents } from './hooks/useWorkspaceEvents'; - -// Store -export { useWorkspaceStore } from './store'; diff --git a/src/renderer/index.html b/src/renderer/index.html index 4b35174f..c19630f5 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ <title>ADC diff --git a/src/renderer/shared/components/AppUpdateNotification.tsx b/src/renderer/shared/components/AppUpdateNotification.tsx index 54099a38..ce2ca43c 100644 --- a/src/renderer/shared/components/AppUpdateNotification.tsx +++ b/src/renderer/shared/components/AppUpdateNotification.tsx @@ -15,6 +15,8 @@ import { APP, APP_EVENTS } from '@shared/ipc/app/channels'; import { useIpcEvent } from '@renderer/shared/hooks'; import { ipc } from '@renderer/shared/lib/ipc'; +import { Button } from '@ui'; + // -- Types -- type UpdatePhase = 'available' | 'downloaded'; @@ -79,31 +81,34 @@ export function AppUpdateNotification() { {isDownloaded ? ( - + ) : ( - + )} - + + ); } diff --git a/src/renderer/shared/components/AuthNotification.tsx b/src/renderer/shared/components/AuthNotification.tsx index 5f9bab2d..146c165c 100644 --- a/src/renderer/shared/components/AuthNotification.tsx +++ b/src/renderer/shared/components/AuthNotification.tsx @@ -10,8 +10,12 @@ import { useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { AlertTriangle, ExternalLink, Settings, X } from 'lucide-react'; +import { ROUTES } from '@shared/constants'; + import { useClaudeAuth } from '@renderer/shared/hooks'; +import { Button } from '@ui'; + // ── Component ──────────────────────────────────────────────── export function AuthNotification() { @@ -30,7 +34,7 @@ export function AuthNotification() { const isInstalled = auth.installed; function handleGoToSettings() { - void navigate({ to: '/settings' }); + void navigate({ to: ROUTES.SETTINGS }); } return ( @@ -48,14 +52,14 @@ export function AuthNotification() {

{isInstalled ? ( - + ) : (
- + + ); diff --git a/src/renderer/shared/components/ConfirmDialog.tsx b/src/renderer/shared/components/ConfirmDialog.tsx index 16d9a41e..03e835ea 100644 --- a/src/renderer/shared/components/ConfirmDialog.tsx +++ b/src/renderer/shared/components/ConfirmDialog.tsx @@ -16,7 +16,7 @@ import { useEffect } from 'react'; import { AlertTriangle, Loader2 } from 'lucide-react'; -import { cn } from '@renderer/shared/lib/utils'; +import { Button, Heading, Separator } from '@ui'; interface ConfirmDialogProps { open: boolean; @@ -96,7 +96,7 @@ export function ConfirmDialog({ ) : null} -

{title}

+ {title} {/* Body */} @@ -105,39 +105,29 @@ export function ConfirmDialog({ {/* Footer */} -
- - +
diff --git a/src/renderer/shared/components/EventBridge.tsx b/src/renderer/shared/components/EventBridge.tsx index 439872e4..132c6fc1 100644 --- a/src/renderer/shared/components/EventBridge.tsx +++ b/src/renderer/shared/components/EventBridge.tsx @@ -4,9 +4,9 @@ * Mounted once in RootLayout. Subscribes to every IPC event channel that * affects cached data and invalidates the corresponding React Query keys. * - * Replaces the ad-hoc hub-query-sync approach and scattered useIpcEvent - * calls with a single declarative registry. To add a new event-to-cache - * mapping, add an entry to EVENT_REGISTRY below. + * Provides a single declarative registry replacing scattered useIpcEvent + * calls. To add a new event-to-cache mapping, add an entry to + * EVENT_REGISTRY below. * * Renders null — purely side-effect. */ @@ -18,9 +18,11 @@ import { type QueryClient, useQueryClient } from '@tanstack/react-query'; import { AGENT_DASHBOARD_EVENTS } from '@shared/ipc/agent-dashboard/channels'; import { BUS_EVENTS } from '@shared/ipc/bus/channels'; import type { sessionRecordSchema } from '@shared/ipc/bus/schemas'; -import { HUB_EVENTS } from '@shared/ipc/hub/channels'; +// peerKeys lives in @shared/ipc/peers so EventBridge can use it without +// crossing the renderer's shared->features boundary (audit 05/T13). +import { peerKeys, PEERS_EVENTS } from '@shared/ipc/peers'; +import type { DiscoveredPeer } from '@shared/ipc/peers'; import { PROGRESS_EVENTS } from '@shared/ipc/progress/channels'; -import { HUB_TASKS_EVENTS, TASKS_EVENTS } from '@shared/ipc/tasks/channels'; import type { AgentTeamsDataSchema } from '@shared/ipc/visualization/schemas'; import { WORKFLOW_ENGINE_EVENTS } from '@shared/ipc/workflow-engine/channels'; import { WORKFLOW_TEMPLATES_EVENTS } from '@shared/ipc/workflow-templates/channels'; @@ -92,7 +94,6 @@ function extractTextPreview(content: ContentBlock[]): string { // ─── Shared Key Constants ─────────────────────────────────── const PROGRESS_LIST = ['progress', 'list'] as const; -const TASKS = ['tasks'] as const; const AGENT_SESSIONS = ['agent-dashboard', 'sessions'] as const; const WORKFLOW_TEMPLATES = ['workflowTemplates'] as const; const WORKFLOW_ENGINE = ['workflow-engine'] as const; @@ -109,18 +110,6 @@ const EVENT_REGISTRY: Partial> = { [PROGRESS_EVENTS.ACTION.FAILED]: { keys: [PROGRESS_LIST, ['progress', 'sessions']] }, [PROGRESS_EVENTS.WORKFLOW.STEP]: { keys: [PROGRESS_LIST] }, - // Hub entity events - [HUB_TASKS_EVENTS.TASK.CREATED]: { keys: [TASKS] }, - [HUB_TASKS_EVENTS.TASK.UPDATED]: { keys: [TASKS] }, - [HUB_TASKS_EVENTS.TASK.DELETED]: { keys: [TASKS] }, - [HUB_TASKS_EVENTS.TASK_RUN.COMPLETED]: { keys: [TASKS] }, - [HUB_TASKS_EVENTS.PROGRESS.UPDATED]: { keys: [TASKS] }, - [HUB_EVENTS.DEVICE.ONLINE]: { keys: [['devices']] }, - [HUB_EVENTS.DEVICE.OFFLINE]: { keys: [['devices']] }, - [HUB_EVENTS.WORKSPACE.UPDATED]: { keys: [['workspaces']] }, - [HUB_EVENTS.PROJECT.UPDATED]: { keys: [['projects']] }, - [HUB_EVENTS.CONNECTION.CHANGED]: { keys: [['hub', 'status']] }, - // Agent dashboard events [AGENT_DASHBOARD_EVENTS.SESSION.STARTED]: { keys: [AGENT_SESSIONS] }, [AGENT_DASHBOARD_EVENTS.SESSION.ENDED]: { keys: [AGENT_SESSIONS] }, @@ -137,15 +126,16 @@ const EVENT_REGISTRY: Partial> = { [WORKFLOW_ENGINE_EVENTS.RUN.COMPLETED]: { keys: [WORKFLOW_ENGINE] }, [WORKFLOW_ENGINE_EVENTS.RUN.ERROR]: { keys: [WORKFLOW_ENGINE] }, - // Task status events - [TASKS_EVENTS.STATUS.CHANGED]: { keys: [TASKS] }, - // Bus session events — update visualization agent nodes in-place [BUS_EVENTS.SESSION.SPAWNED]: { handler: 'append' as const }, [BUS_EVENTS.SESSION.ACTIVE]: { handler: 'append' as const }, [BUS_EVENTS.SESSION.COMPLETED]: { handler: 'append' as const }, [BUS_EVENTS.SESSION.ERROR]: { handler: 'append' as const }, [BUS_EVENTS.SESSION.KILLED]: { handler: 'append' as const }, + + // Peer discovery + trust events + [PEERS_EVENTS.DISCOVERY.CHANGED]: { handler: 'append' as const }, + [PEERS_EVENTS.TRUST.CHANGED]: { keys: [peerKeys.paired()] }, }; // ─── Append Handlers ──────────────────────────────────────── @@ -155,6 +145,12 @@ const EVENT_REGISTRY: Partial> = { * Each event channel that uses `handler: 'append'` needs a case here. */ function handleAppend(queryClient: QueryClient, event: EventChannel, payload: unknown) { + if (event === PEERS_EVENTS.DISCOVERY.CHANGED) { + const { peers } = payload as { peers: DiscoveredPeer[] }; + queryClient.setQueryData(peerKeys.discovered(), peers); + return; + } + if (event === AGENT_DASHBOARD_EVENTS.MESSAGE.RECEIVED) { const message = payload as AgentChatMessage; diff --git a/src/renderer/shared/components/HubConnectionIndicator.tsx b/src/renderer/shared/components/HubConnectionIndicator.tsx deleted file mode 100644 index 68a733c7..00000000 --- a/src/renderer/shared/components/HubConnectionIndicator.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/** - * HubConnectionIndicator -- Sidebar status dot for Hub connection - * - * Shows a colored dot indicating the Hub connection state. - * Tooltip displays status text and Hub URL when connected. - * Click navigates to the Settings page. - */ - -import { useState } from 'react'; - -import { useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; - -import { ROUTES } from '@shared/constants'; -import { HUB_EVENTS } from '@shared/ipc/hub/channels'; - -import { useIpcEvent } from '@renderer/shared/hooks'; -import { cn } from '@renderer/shared/lib/utils'; - -// eslint-disable-next-line boundaries/dependencies -- TODO: fix shared->features violation -import { useHubStatus, hubKeys } from '@features/settings/api/useHub'; - -// -- Types -- - -interface StatusConfig { - color: string; - label: string; -} - -// -- Constants -- - -const STATUS_MAP: Record = { - connected: { color: 'bg-green-500', label: 'Hub connected' }, - connecting: { color: 'bg-yellow-500', label: 'Connecting to Hub...' }, - error: { color: 'bg-red-500', label: 'Hub connection error' }, - disconnected: { color: 'bg-muted-foreground/50', label: 'Hub disconnected' }, -}; - -const DEFAULT_STATUS: StatusConfig = { - color: 'bg-muted-foreground/50', - label: 'Hub disconnected', -}; - -// -- Component -- - -interface HubConnectionIndicatorProps { - collapsed: boolean; -} - -export function HubConnectionIndicator({ collapsed }: HubConnectionIndicatorProps) { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const { data: hubStatus } = useHubStatus(); - const [tooltipVisible, setTooltipVisible] = useState(false); - - // Invalidate hub status on connection change events - useIpcEvent(HUB_EVENTS.CONNECTION.CHANGED, () => { - void queryClient.invalidateQueries({ queryKey: hubKeys.status() }); - }); - - const statusKey = hubStatus?.status ?? 'disconnected'; - const config = STATUS_MAP[statusKey] ?? DEFAULT_STATUS; - const hubUrl = hubStatus?.hubUrl; - - const tooltipText = hubUrl ? `${config.label} - ${hubUrl}` : config.label; - - function handleClick() { - void navigate({ to: ROUTES.SETTINGS }); - } - - return ( -
- - - {/* Tooltip (only when expanded, since collapsed uses native title) */} - {!collapsed && tooltipVisible ? ( -
- {tooltipText} -
- ) : null} -
- ); -} diff --git a/src/renderer/shared/components/HubNotification.tsx b/src/renderer/shared/components/HubNotification.tsx deleted file mode 100644 index 05f9e76b..00000000 --- a/src/renderer/shared/components/HubNotification.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/** - * HubNotification — Toast notification for hub connection status changes - * - * Listens for hub connection and sync events from main process. - * Shows brief notifications when connection state changes or sync completes. - * Auto-dismisses after 4 seconds. - */ - -import { useCallback, useEffect, useState } from 'react'; - -import { CheckCircle, Cloud, CloudOff, RefreshCw, X } from 'lucide-react'; - -import { HUB_EVENTS } from '@shared/ipc/hub/channels'; - -import { useIpcEvent } from '@renderer/shared/hooks'; - -// ── Types ───────────────────────────────────────────────────── - -interface HubNotificationItem { - id: string; - type: 'connection' | 'sync'; - status: 'connected' | 'disconnected' | 'connecting' | 'error' | 'synced'; - message: string; -} - -// ── Helpers ─────────────────────────────────────────────────── - -function getStatusIcon(status: HubNotificationItem['status']) { - switch (status) { - case 'connected': - return
diff --git a/src/renderer/shared/components/ui/composition/DetailPanel.tsx b/src/renderer/shared/components/ui/composition/DetailPanel.tsx index 1492872c..be9816bd 100644 --- a/src/renderer/shared/components/ui/composition/DetailPanel.tsx +++ b/src/renderer/shared/components/ui/composition/DetailPanel.tsx @@ -60,7 +60,7 @@ export function DetailPanel({ )} > {/* Header */} -
+
{title} +
); diff --git a/src/renderer/shared/components/ui/sidebar.tsx b/src/renderer/shared/components/ui/sidebar.tsx index f56f5a42..f3347527 100644 --- a/src/renderer/shared/components/ui/sidebar.tsx +++ b/src/renderer/shared/components/ui/sidebar.tsx @@ -248,17 +248,18 @@ function SidebarTrigger({ // ─── Rail ────────────────────────────────────────────────── -function SidebarRail({ className, ...props }: ComponentProps<'button'>) { +function SidebarRail({ className, ...props }: ComponentProps) { const { toggleSidebar } = useSidebar(); return ( -